aboutsummaryrefslogtreecommitdiffhomepage
path: root/client
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2022-05-24 16:29:01 +0200
committerChocobozzz <me@florianbigard.com>2022-06-08 13:40:40 +0200
commiteaa529528cafcfb291009f9f99d296c81e792899 (patch)
treec8e3562f73312fb331a363e1daeaeb4949cc8448 /client
parente435cf44c00aba359bf0f265d06bff4841b3f7fe (diff)
downloadPeerTube-eaa529528cafcfb291009f9f99d296c81e792899.tar.gz
PeerTube-eaa529528cafcfb291009f9f99d296c81e792899.tar.zst
PeerTube-eaa529528cafcfb291009f9f99d296c81e792899.zip
Support ICU in TS components
Diffstat (limited to 'client')
-rw-r--r--client/package.json1
-rw-r--r--client/src/app/+accounts/account-video-channels/account-video-channels.component.html4
-rw-r--r--client/src/app/+accounts/accounts.component.html4
-rw-r--r--client/src/app/+accounts/accounts.component.ts10
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-configuration.service.ts8
-rw-r--r--client/src/app/+admin/follows/following-list/follow-modal.component.ts9
-rw-r--r--client/src/app/+admin/overview/comments/video-comment-list.component.ts9
-rw-r--r--client/src/app/+admin/overview/users/user-list/user-list.component.ts47
-rw-r--r--client/src/app/+admin/overview/videos/video-list.component.ts41
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts2
-rw-r--r--client/src/app/+my-library/my-history/my-history.component.ts10
-rw-r--r--client/src/app/+my-library/my-videos/my-videos.component.ts15
-rw-r--r--client/src/app/+search/search.component.ts8
-rw-r--r--client/src/app/+video-channels/video-channels.component.html4
-rw-r--r--client/src/app/+videos/video-list/videos-list-common-page.component.ts25
-rw-r--r--client/src/app/core/rest/rest-extractor.service.ts111
-rw-r--r--client/src/app/helpers/i18n-utils.ts25
-rw-r--r--client/src/app/helpers/utils/upload.ts49
-rw-r--r--client/src/app/shared/shared-forms/select/select-checkbox-all.component.ts8
-rw-r--r--client/src/app/shared/shared-instance/instance-features-table.component.ts18
-rw-r--r--client/src/app/shared/shared-main/angular/from-now.pipe.ts34
-rw-r--r--client/src/app/shared/shared-main/video/video.model.ts11
-rw-r--r--client/src/app/shared/shared-moderation/user-ban-modal.component.ts21
-rw-r--r--client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts3
-rw-r--r--client/src/app/shared/shared-moderation/video-block.component.ts8
-rw-r--r--client/src/app/shared/shared-user-settings/user-video-settings.component.html2
-rw-r--r--client/src/app/shared/shared-video-miniature/video-miniature.component.ts2
-rw-r--r--client/tsconfig.json5
-rw-r--r--client/yarn.lock54
29 files changed, 391 insertions, 157 deletions
diff --git a/client/package.json b/client/package.json
index b0d175b1a..30f1c4cbe 100644
--- a/client/package.json
+++ b/client/package.json
@@ -100,6 +100,7 @@
100 "html-loader": "^3.0.1", 100 "html-loader": "^3.0.1",
101 "html-webpack-plugin": "^5.3.1", 101 "html-webpack-plugin": "^5.3.1",
102 "https-browserify": "^1.0.0", 102 "https-browserify": "^1.0.0",
103 "intl-messageformat": "^10.0.1",
103 "jschannel": "^1.0.2", 104 "jschannel": "^1.0.2",
104 "linkify-html": "^3.0.2", 105 "linkify-html": "^3.0.2",
105 "linkify-plugin-mention": "^3.0.2", 106 "linkify-plugin-mention": "^3.0.2",
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 @@
23 </h2> 23 </h2>
24 24
25 <div class="actor-counters"> 25 <div class="actor-counters">
26 <div class="followers" i18n>{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div> 26 <div class="followers" i18n>{videoChannel.followersCount, plural, =0 {No subscribers} =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div>
27 27
28 <span class="videos-count" *ngIf="getTotalVideosOf(videoChannel) !== undefined" i18n> 28 <span class="videos-count" *ngIf="getTotalVideosOf(videoChannel) !== undefined" i18n>
29 {getTotalVideosOf(videoChannel), plural, =1 {1 videos} other {{{ getTotalVideosOf(videoChannel) }} videos}} 29 {getTotalVideosOf(videoChannel), plural, =0 {No videos} =1 {1 video} other {{{ getTotalVideosOf(videoChannel) }} videos}}
30 </span> 30 </span>
31 </div> 31 </div>
32 32
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 @@
33 </div> 33 </div>
34 34
35 <div class="actor-counters"> 35 <div class="actor-counters">
36 <span i18n>{naiveAggregatedSubscribers(), plural, =1 {1 subscriber} other {{{ naiveAggregatedSubscribers() }} subscribers}}</span> 36 <span i18n>{naiveAggregatedSubscribers(), plural, =0 {No subscribers} =1 {1 subscriber} other {{{ naiveAggregatedSubscribers() }} subscribers}}</span>
37 37
38 <span class="videos-count" *ngIf="accountVideosCount !== undefined" i18n> 38 <span class="videos-count" *ngIf="accountVideosCount !== undefined" i18n>
39 {accountVideosCount, plural, =1 {1 videos} other {{{ accountVideosCount }} videos}} 39 {accountVideosCount, plural, =0 {No videos} =1 {1 video} other {{{ accountVideosCount }} videos}}
40 </span> 40 </span>
41 </div> 41 </div>
42 </div> 42 </div>
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 {
30 links: ListOverflowItem[] = [] 30 links: ListOverflowItem[] = []
31 hideMenu = false 31 hideMenu = false
32 32
33 accountFollowerTitle = ''
34
35 accountVideosCount: number 33 accountVideosCount: number
36 accountDescriptionHTML = '' 34 accountDescriptionHTML = ''
37 accountDescriptionExpanded = false 35 accountDescriptionExpanded = false
@@ -121,12 +119,6 @@ export class AccountsComponent implements OnInit, OnDestroy {
121 this.notifier.success($localize`Username copied`) 119 this.notifier.success($localize`Username copied`)
122 } 120 }
123 121
124 subscribersDisplayFor (count: number) {
125 if (count === 1) return $localize`1 subscriber`
126
127 return $localize`${count} subscribers`
128 }
129
130 searchChanged (search: string) { 122 searchChanged (search: string) {
131 const queryParams = { search } 123 const queryParams = { search }
132 124
@@ -150,8 +142,6 @@ export class AccountsComponent implements OnInit, OnDestroy {
150 } 142 }
151 143
152 private async onAccount (account: Account) { 144 private async onAccount (account: Account) {
153 this.accountFollowerTitle = $localize`${account.followersCount} direct account followers`
154
155 this.accountDescriptionHTML = await this.markdown.textMarkdownToHTML(account.description) 145 this.accountDescriptionHTML = await this.markdown.textMarkdownToHTML(account.description)
156 146
157 // After the markdown renderer to avoid layout changes 147 // 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 @@
1import { Injectable } from '@angular/core' 1import { Injectable } from '@angular/core'
2import { FormGroup } from '@angular/forms' 2import { FormGroup } from '@angular/forms'
3import { prepareIcu } from '@app/helpers'
3 4
4export type ResolutionOption = { 5export type ResolutionOption = {
5 id: string 6 id: string
@@ -86,9 +87,10 @@ export class EditConfigurationService {
86 return { 87 return {
87 value, 88 value,
88 atMost: noneOnAuto, // auto switches everything to a least estimation since ffmpeg will take as many threads as possible 89 atMost: noneOnAuto, // auto switches everything to a least estimation since ffmpeg will take as many threads as possible
89 unit: value > 1 90 unit: prepareIcu($localize`{value, plural, =1 {thread} other {threads}}`)(
90 ? $localize`threads` 91 { value },
91 : $localize`thread` 92 $localize`threads`
93 )
92 } 94 }
93 } 95 }
94} 96}
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 @@
1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' 1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { prepareIcu } from '@app/helpers'
3import { splitAndGetNotEmpty, UNIQUE_HOSTS_OR_HANDLE_VALIDATOR } from '@app/shared/form-validators/host-validators' 4import { splitAndGetNotEmpty, UNIQUE_HOSTS_OR_HANDLE_VALIDATOR } from '@app/shared/form-validators/host-validators'
4import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 5import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
5import { InstanceFollowService } from '@app/shared/shared-instance' 6import { InstanceFollowService } from '@app/shared/shared-instance'
@@ -60,7 +61,13 @@ export class FollowModalComponent extends FormReactive implements OnInit {
60 this.followService.follow(hostsOrHandles) 61 this.followService.follow(hostsOrHandles)
61 .subscribe({ 62 .subscribe({
62 next: () => { 63 next: () => {
63 this.notifier.success($localize`Follow request(s) sent!`) 64 this.notifier.success(
65 prepareIcu($localize`{count, plural, =1 {Follow request} other {Follow requests}} sent!`)(
66 { count: hostsOrHandles.length },
67 $localize`Follow request(s) sent!`
68 )
69 )
70
64 this.newFollow.emit() 71 this.newFollow.emit()
65 }, 72 },
66 73
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'
7import { BulkService } from '@app/shared/shared-moderation' 7import { BulkService } from '@app/shared/shared-moderation'
8import { VideoCommentAdmin, VideoCommentService } from '@app/shared/shared-video-comment' 8import { VideoCommentAdmin, VideoCommentService } from '@app/shared/shared-video-comment'
9import { FeedFormat, UserRight } from '@shared/models' 9import { FeedFormat, UserRight } from '@shared/models'
10import { prepareIcu } from '@app/helpers'
10 11
11@Component({ 12@Component({
12 selector: 'my-video-comment-list', 13 selector: 'my-video-comment-list',
@@ -145,7 +146,13 @@ export class VideoCommentListComponent extends RestTable implements OnInit {
145 this.videoCommentService.deleteVideoComments(commentArgs) 146 this.videoCommentService.deleteVideoComments(commentArgs)
146 .subscribe({ 147 .subscribe({
147 next: () => { 148 next: () => {
148 this.notifier.success($localize`${commentArgs.length} comments deleted.`) 149 this.notifier.success(
150 prepareIcu($localize`{count, plural, =1 {1 comment} other {{count} comments}} deleted.`)(
151 { count: commentArgs.length },
152 $localize`${commentArgs.length} comment(s) deleted.`
153 )
154 )
155
149 this.reloadData() 156 this.reloadData()
150 }, 157 },
151 158
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'
2import { Component, OnInit, ViewChild } from '@angular/core' 2import { Component, OnInit, ViewChild } from '@angular/core'
3import { ActivatedRoute, Router } from '@angular/router' 3import { ActivatedRoute, Router } from '@angular/router'
4import { AuthService, ConfirmService, LocalStorageService, Notifier, RestPagination, RestTable, ServerService } from '@app/core' 4import { AuthService, ConfirmService, LocalStorageService, Notifier, RestPagination, RestTable, ServerService } from '@app/core'
5import { getAPIHost } from '@app/helpers' 5import { prepareIcu, getAPIHost } from '@app/helpers'
6import { AdvancedInputFilter } from '@app/shared/shared-forms' 6import { AdvancedInputFilter } from '@app/shared/shared-forms'
7import { Actor, DropdownAction } from '@app/shared/shared-main' 7import { Actor, DropdownAction } from '@app/shared/shared-main'
8import { AccountMutedStatus, BlocklistService, UserBanModalComponent, UserModerationDisplayType } from '@app/shared/shared-moderation' 8import { AccountMutedStatus, BlocklistService, UserBanModalComponent, UserModerationDisplayType } from '@app/shared/shared-moderation'
@@ -209,13 +209,25 @@ export class UserListComponent extends RestTable implements OnInit {
209 } 209 }
210 210
211 async unbanUsers (users: User[]) { 211 async unbanUsers (users: User[]) {
212 const res = await this.confirmService.confirm($localize`Do you really want to unban ${users.length} users?`, $localize`Unban`) 212 const res = await this.confirmService.confirm(
213 prepareIcu($localize`Do you really want to unban {count, plural, =1 {1 user} other {{count} users}}?`)(
214 { count: users.length },
215 $localize`Do you really want to unban ${users.length} users?`
216 ),
217 $localize`Unban`
218 )
219
213 if (res === false) return 220 if (res === false) return
214 221
215 this.userAdminService.unbanUsers(users) 222 this.userAdminService.unbanUsers(users)
216 .subscribe({ 223 .subscribe({
217 next: () => { 224 next: () => {
218 this.notifier.success($localize`${users.length} users unbanned.`) 225 this.notifier.success(
226 prepareIcu($localize`{count, plural, =1 {1 user} other {{count} users}} unbanned.`)(
227 { count: users.length },
228 $localize`${users.length} users unbanned.`
229 )
230 )
219 this.reloadData() 231 this.reloadData()
220 }, 232 },
221 233
@@ -224,21 +236,28 @@ export class UserListComponent extends RestTable implements OnInit {
224 } 236 }
225 237
226 async removeUsers (users: User[]) { 238 async removeUsers (users: User[]) {
227 for (const user of users) { 239 if (users.some(u => u.username === 'root')) {
228 if (user.username === 'root') { 240 this.notifier.error($localize`You cannot delete root.`)
229 this.notifier.error($localize`You cannot delete root.`) 241 return
230 return
231 }
232 } 242 }
233 243
234 const message = $localize`If you remove these users, you will not be able to create others with the same username!` 244 const message = $localize`<p>You can't create users or channels with a username that already used by a deleted user/channel.</p>` +
245 $localize`It means the following usernames will be permanently deleted and cannot be recovered:` +
246 '<ul>' + users.map(u => '<li>' + u.username + '</li>').join('') + '</ul>'
247
235 const res = await this.confirmService.confirm(message, $localize`Delete`) 248 const res = await this.confirmService.confirm(message, $localize`Delete`)
236 if (res === false) return 249 if (res === false) return
237 250
238 this.userAdminService.removeUser(users) 251 this.userAdminService.removeUser(users)
239 .subscribe({ 252 .subscribe({
240 next: () => { 253 next: () => {
241 this.notifier.success($localize`${users.length} users deleted.`) 254 this.notifier.success(
255 prepareIcu($localize`{count, plural, =1 {1 user} other {{count} users}} deleted.`)(
256 { count: users.length },
257 $localize`${users.length} users deleted.`
258 )
259 )
260
242 this.reloadData() 261 this.reloadData()
243 }, 262 },
244 263
@@ -250,7 +269,13 @@ export class UserListComponent extends RestTable implements OnInit {
250 this.userAdminService.updateUsers(users, { emailVerified: true }) 269 this.userAdminService.updateUsers(users, { emailVerified: true })
251 .subscribe({ 270 .subscribe({
252 next: () => { 271 next: () => {
253 this.notifier.success($localize`${users.length} users email set as verified.`) 272 this.notifier.success(
273 prepareIcu($localize`{count, plural, =1 {1 user} other {{count} users}} email set as verified.`)(
274 { count: users.length },
275 $localize`${users.length} users email set as verified.`
276 )
277 )
278
254 this.reloadData() 279 this.reloadData()
255 }, 280 },
256 281
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'
3import { Component, OnInit, ViewChild } from '@angular/core' 3import { Component, OnInit, ViewChild } from '@angular/core'
4import { ActivatedRoute, Router } from '@angular/router' 4import { ActivatedRoute, Router } from '@angular/router'
5import { AuthService, ConfirmService, Notifier, RestPagination, RestTable } from '@app/core' 5import { AuthService, ConfirmService, Notifier, RestPagination, RestTable } from '@app/core'
6import { prepareIcu } from '@app/helpers'
6import { AdvancedInputFilter } from '@app/shared/shared-forms' 7import { AdvancedInputFilter } from '@app/shared/shared-forms'
7import { DropdownAction, Video, VideoService } from '@app/shared/shared-main' 8import { DropdownAction, Video, VideoService } from '@app/shared/shared-main'
8import { VideoBlockComponent, VideoBlockService } from '@app/shared/shared-moderation' 9import { VideoBlockComponent, VideoBlockService } from '@app/shared/shared-moderation'
@@ -196,14 +197,24 @@ export class VideoListComponent extends RestTable implements OnInit {
196 } 197 }
197 198
198 private async removeVideos (videos: Video[]) { 199 private async removeVideos (videos: Video[]) {
199 const message = $localize`Are you sure you want to delete these ${videos.length} videos?` 200 const message = prepareIcu($localize`Are you sure you want to delete {count, plural, =1 {this video} other {these {count} videos}}?`)(
201 { count: videos.length },
202 $localize`Are you sure you want to delete these ${videos.length} videos?`
203 )
204
200 const res = await this.confirmService.confirm(message, $localize`Delete`) 205 const res = await this.confirmService.confirm(message, $localize`Delete`)
201 if (res === false) return 206 if (res === false) return
202 207
203 this.videoService.removeVideo(videos.map(v => v.id)) 208 this.videoService.removeVideo(videos.map(v => v.id))
204 .subscribe({ 209 .subscribe({
205 next: () => { 210 next: () => {
206 this.notifier.success($localize`Deleted ${videos.length} videos.`) 211 this.notifier.success(
212 prepareIcu($localize`Deleted {count, plural, =1 {1 video} other {{count} videos}}.`)(
213 { count: videos.length },
214 $localize`Deleted ${videos.length} videos.`
215 )
216 )
217
207 this.reloadData() 218 this.reloadData()
208 }, 219 },
209 220
@@ -215,7 +226,13 @@ export class VideoListComponent extends RestTable implements OnInit {
215 this.videoBlockService.unblockVideo(videos.map(v => v.id)) 226 this.videoBlockService.unblockVideo(videos.map(v => v.id))
216 .subscribe({ 227 .subscribe({
217 next: () => { 228 next: () => {
218 this.notifier.success($localize`Unblocked ${videos.length} videos.`) 229 this.notifier.success(
230 prepareIcu($localize`Unblocked {count, plural, =1 {1 video} other {{count} videos}}.`)(
231 { count: videos.length },
232 $localize`Unblocked ${videos.length} videos.`
233 )
234 )
235
219 this.reloadData() 236 this.reloadData()
220 }, 237 },
221 238
@@ -224,9 +241,21 @@ export class VideoListComponent extends RestTable implements OnInit {
224 } 241 }
225 242
226 private async removeVideoFiles (videos: Video[], type: 'hls' | 'webtorrent') { 243 private async removeVideoFiles (videos: Video[], type: 'hls' | 'webtorrent') {
227 const message = type === 'hls' 244 let message: string
228 ? $localize`Are you sure you want to delete ${videos.length} HLS streaming playlists?` 245
229 : $localize`Are you sure you want to delete WebTorrent files of ${videos.length} videos?` 246 if (type === 'hls') {
247 // eslint-disable-next-line max-len
248 message = prepareIcu($localize`Are you sure you want to delete {count, plural, =1 {1 HLS streaming playlist} other {{count} HLS streaming playlists}}?`)(
249 { count: videos.length },
250 $localize`Are you sure you want to delete ${videos.length} HLS streaming playlists?`
251 )
252 } else {
253 // eslint-disable-next-line max-len
254 message = prepareIcu($localize`Are you sure you want to delete WebTorrent files of {count, plural, =1 {1 video} other {{count} videos}}?`)(
255 { count: videos.length },
256 $localize`Are you sure you want to delete WebTorrent files of ${videos.length} videos?`
257 )
258 }
230 259
231 const res = await this.confirmService.confirm(message, $localize`Delete`) 260 const res = await this.confirmService.confirm(message, $localize`Delete`)
232 if (res === false) return 261 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 {
37 myVideoPublished: $localize`Video published (after transcoding/scheduled update)`, 37 myVideoPublished: $localize`Video published (after transcoding/scheduled update)`,
38 myVideoImportFinished: $localize`Video import finished`, 38 myVideoImportFinished: $localize`Video import finished`,
39 newUserRegistration: $localize`A new user registered on your instance`, 39 newUserRegistration: $localize`A new user registered on your instance`,
40 newFollow: $localize`You or your channel(s) has a new follower`, 40 newFollow: $localize`You or one of your channels has a new follower`,
41 commentMention: $localize`Someone mentioned you in video comments`, 41 commentMention: $localize`Someone mentioned you in video comments`,
42 newInstanceFollower: $localize`Your instance has a new follower`, 42 newInstanceFollower: $localize`Your instance has a new follower`,
43 autoInstanceFollowing: $localize`Your instance automatically followed another instance`, 43 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 {
93 .subscribe({ 93 .subscribe({
94 next: () => { 94 next: () => {
95 const message = this.videosHistoryEnabled === true 95 const message = this.videosHistoryEnabled === true
96 ? $localize`Videos history is enabled` 96 ? $localize`Video history is enabled`
97 : $localize`Videos history is disabled` 97 : $localize`Video history is disabled`
98 98
99 this.notifier.success(message) 99 this.notifier.success(message)
100 100
@@ -117,8 +117,8 @@ export class MyHistoryComponent implements OnInit, DisableForReuseHook {
117 } 117 }
118 118
119 async clearAllHistory () { 119 async clearAllHistory () {
120 const title = $localize`Delete videos history` 120 const title = $localize`Delete video history`
121 const message = $localize`Are you sure you want to delete all your videos history?` 121 const message = $localize`Are you sure you want to delete all your video history?`
122 122
123 const res = await this.confirmService.confirm(message, title) 123 const res = await this.confirmService.confirm(message, title)
124 if (res !== true) return 124 if (res !== true) return
@@ -126,7 +126,7 @@ export class MyHistoryComponent implements OnInit, DisableForReuseHook {
126 this.userHistoryService.clearAll() 126 this.userHistoryService.clearAll()
127 .subscribe({ 127 .subscribe({
128 next: () => { 128 next: () => {
129 this.notifier.success($localize`Videos history deleted`) 129 this.notifier.success($localize`Video history deleted`)
130 130
131 this.reloadData() 131 this.reloadData()
132 }, 132 },
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'
4import { ActivatedRoute, Router } from '@angular/router' 4import { ActivatedRoute, Router } from '@angular/router'
5import { AuthService, ComponentPagination, ConfirmService, Notifier, ScreenService, ServerService, User } from '@app/core' 5import { AuthService, ComponentPagination, ConfirmService, Notifier, ScreenService, ServerService, User } from '@app/core'
6import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook' 6import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
7import { immutableAssign } from '@app/helpers' 7import { prepareIcu, immutableAssign } from '@app/helpers'
8import { AdvancedInputFilter } from '@app/shared/shared-forms' 8import { AdvancedInputFilter } from '@app/shared/shared-forms'
9import { DropdownAction, Video, VideoService } from '@app/shared/shared-main' 9import { DropdownAction, Video, VideoService } from '@app/shared/shared-main'
10import { LiveStreamInformationComponent } from '@app/shared/shared-video-live' 10import { LiveStreamInformationComponent } from '@app/shared/shared-video-live'
@@ -167,7 +167,10 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
167 .map(k => parseInt(k, 10)) 167 .map(k => parseInt(k, 10))
168 168
169 const res = await this.confirmService.confirm( 169 const res = await this.confirmService.confirm(
170 $localize`Do you really want to delete ${toDeleteVideosIds.length} videos?`, 170 prepareIcu($localize`Do you really want to delete {length, plural, =1 {this video} other {{length} videos}}?`)(
171 { length: toDeleteVideosIds.length },
172 $localize`Do you really want to delete ${toDeleteVideosIds.length} videos?`
173 ),
171 $localize`Delete` 174 $localize`Delete`
172 ) 175 )
173 if (res === false) return 176 if (res === false) return
@@ -184,7 +187,13 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
184 .pipe(toArray()) 187 .pipe(toArray())
185 .subscribe({ 188 .subscribe({
186 next: () => { 189 next: () => {
187 this.notifier.success($localize`${toDeleteVideosIds.length} videos deleted.`) 190 this.notifier.success(
191 prepareIcu($localize`{length, plural, =1 {Video has been deleted} other {{length} videos have been deleted}}`)(
192 { length: toDeleteVideosIds.length },
193 $localize`${toDeleteVideosIds.length} have been deleted.`
194 )
195 )
196
188 this.selection = {} 197 this.selection = {}
189 }, 198 },
190 199
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 {
248 } 248 }
249 249
250 private updateTitle () { 250 private updateTitle () {
251 const suffix = this.currentSearch 251 const title = this.currentSearch
252 ? ' ' + this.currentSearch 252 ? $localize`Search ${this.currentSearch}`
253 : '' 253 : $localize`Search`
254 254
255 this.metaService.setTitle($localize`Search` + suffix) 255 this.metaService.setTitle(title)
256 } 256 }
257 257
258 private updateUrlFromAdvancedSearch () { 258 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 @@
72 </div> 72 </div>
73 73
74 <div class="actor-counters"> 74 <div class="actor-counters">
75 <span i18n>{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</span> 75 <span i18n>{videoChannel.followersCount, plural, =0 {No subscribers} =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</span>
76 76
77 <span class="videos-count" *ngIf="channelVideosCount !== undefined" i18n> 77 <span class="videos-count" *ngIf="channelVideosCount !== undefined" i18n>
78 {channelVideosCount, plural, =1 {1 videos} other {{{ channelVideosCount }} videos}} 78 {channelVideosCount, plural, =0 {No videos} =1 {1 video} other {{{ channelVideosCount }} videos}}
79 </span> 79 </span>
80 </div> 80 </div>
81 </div> 81 </div>
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
204 if ([ 'hot', 'trending', 'likes', 'views' ].includes(sanitizedSort)) { 204 if ([ 'hot', 'trending', 'likes', 'views' ].includes(sanitizedSort)) {
205 this.title = $localize`Trending` 205 this.title = $localize`Trending`
206 206
207 if (sanitizedSort === 'hot') this.titleTooltip = $localize`Videos with the most interactions for recent videos` 207 if (sanitizedSort === 'hot') {
208 if (sanitizedSort === 'likes') this.titleTooltip = $localize`Videos that have the most likes` 208 this.titleTooltip = $localize`Videos with the most interactions for recent videos`
209 if (sanitizedSort === 'views') this.titleTooltip = undefined 209 return
210 }
211
212 if (sanitizedSort === 'likes') {
213 this.titleTooltip = $localize`Videos that have the most likes`
214 return
215 }
216
217 if (sanitizedSort === 'views') {
218 this.titleTooltip = undefined
219 return
220 }
210 221
211 if (sanitizedSort === 'trending') { 222 if (sanitizedSort === 'trending') {
212 if (this.trendingDays === 1) this.titleTooltip = $localize`Videos with the most views during the last 24 hours` 223 if (this.trendingDays === 1) {
213 else this.titleTooltip = $localize`Videos with the most views during the last ${this.trendingDays} days` 224 this.titleTooltip = $localize`Videos with the most views during the last 24 hours`
225 return
226 }
227
228 this.titleTooltip = $localize`Videos with the most views during the last ${this.trendingDays} days`
214 } 229 }
215 230
216 return 231 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 {
34 return target 34 return target
35 } 35 }
36 36
37 handleError (err: any) { 37 redirectTo404IfNotFound (obj: { status: number }, type: 'video' | 'other', status = [ HttpStatusCode.NOT_FOUND_404 ]) {
38 let errorMessage 38 if (obj?.status && status.includes(obj.status)) {
39 // Do not use redirectService to avoid circular dependencies
40 this.router.navigate([ '/404' ], { state: { type, obj }, skipLocationChange: true })
41 }
39 42
40 if (err.error instanceof Error) { 43 return observableThrowError(() => obj)
41 // A client-side or network error occurred. Handle it accordingly. 44 }
42 errorMessage = err.error.detail || err.error.title
43 console.error('An error occurred:', errorMessage)
44 } else if (typeof err.error === 'string') {
45 errorMessage = err.error
46 } else if (err.status !== undefined) {
47 // A server-side error occurred.
48 if (err.error?.errors) {
49 const errors = err.error.errors
50 const errorsArray: string[] = []
51
52 Object.keys(errors).forEach(key => {
53 errorsArray.push(errors[key].msg)
54 })
55
56 errorMessage = errorsArray.join('. ')
57 } else if (err.error?.error) {
58 errorMessage = err.error.error
59 } else if (err.status === HttpStatusCode.PAYLOAD_TOO_LARGE_413) {
60 // eslint-disable-next-line max-len
61 errorMessage = $localize`Media is too large for the server. Please contact you administrator if you want to increase the limit size.`
62 } else if (err.status === HttpStatusCode.TOO_MANY_REQUESTS_429) {
63 const secondsLeft = err.headers.get('retry-after')
64 if (secondsLeft) {
65 const minutesLeft = Math.floor(parseInt(secondsLeft, 10) / 60)
66 errorMessage = $localize`Too many attempts, please try again after ${minutesLeft} minutes.`
67 } else {
68 errorMessage = $localize`Too many attempts, please try again later.`
69 }
70 } else if (err.status === HttpStatusCode.INTERNAL_SERVER_ERROR_500) {
71 errorMessage = $localize`Server error. Please retry later.`
72 }
73 45
74 errorMessage = errorMessage || 'Unknown error.' 46 handleError (err: any) {
75 console.error(`Backend returned code ${err.status}, errorMessage is: ${errorMessage}`) 47 const errorMessage = this.buildErrorMessage(err)
76 } else {
77 console.error(err)
78 errorMessage = err
79 }
80 48
81 const errorObj: { message: string, status: string, body: string } = { 49 const errorObj: { message: string, status: string, body: string } = {
82 message: errorMessage, 50 message: errorMessage,
@@ -92,12 +60,63 @@ export class RestExtractor {
92 return observableThrowError(() => errorObj) 60 return observableThrowError(() => errorObj)
93 } 61 }
94 62
95 redirectTo404IfNotFound (obj: { status: number }, type: 'video' | 'other', status = [ HttpStatusCode.NOT_FOUND_404 ]) { 63 private buildErrorMessage (err: any) {
96 if (obj?.status && status.includes(obj.status)) { 64 if (err.error instanceof Error) {
97 // Do not use redirectService to avoid circular dependencies 65 // A client-side or network error occurred. Handle it accordingly.
98 this.router.navigate([ '/404' ], { state: { type, obj }, skipLocationChange: true }) 66 const errorMessage = err.error.detail || err.error.title
67 console.error('An error occurred:', errorMessage)
68
69 return errorMessage
99 } 70 }
100 71
101 return observableThrowError(() => obj) 72 if (typeof err.error === 'string') {
73 return err.error
74 }
75
76 if (err.status !== undefined) {
77 const errorMessage = this.buildServerErrorMessage(err)
78 console.error(`Backend returned code ${err.status}, errorMessage is: ${errorMessage}`)
79
80 return errorMessage
81 }
82
83 console.error(err)
84 return err
85 }
86
87 private buildServerErrorMessage (err: any) {
88 // A server-side error occurred.
89 if (err.error?.errors) {
90 const errors = err.error.errors
91
92 return Object.keys(errors)
93 .map(key => errors[key].msg)
94 .join('. ')
95 }
96
97 if (err.error?.error) {
98 return err.error.error
99 }
100
101 if (err.status === HttpStatusCode.PAYLOAD_TOO_LARGE_413) {
102 return $localize`Media is too large for the server. Please contact you administrator if you want to increase the limit size.`
103 }
104
105 if (err.status === HttpStatusCode.TOO_MANY_REQUESTS_429) {
106 const secondsLeft = err.headers.get('retry-after')
107
108 if (secondsLeft) {
109 const minutesLeft = Math.floor(parseInt(secondsLeft, 10) / 60)
110 return $localize`Too many attempts, please try again after ${minutesLeft} minutes.`
111 }
112
113 return $localize`Too many attempts, please try again later.`
114 }
115
116 if (err.status === HttpStatusCode.INTERNAL_SERVER_ERROR_500) {
117 return $localize`Server error. Please retry later.`
118 }
119
120 return $localize`Unknown server error`
102 } 121 }
103} 122}
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 @@
1import { environment } from '../../environments/environment' 1import { environment } from '../../environments/environment'
2import IntlMessageFormat from 'intl-messageformat'
2 3
3function isOnDevLocale () { 4function isOnDevLocale () {
4 return environment.production === false && window.location.search === '?lang=fr' 5 return environment.production === false && window.location.search === '?lang=fr'
@@ -8,7 +9,31 @@ function getDevLocale () {
8 return 'fr-FR' 9 return 'fr-FR'
9} 10}
10 11
12function prepareIcu (icu: string) {
13 let alreadyWarned = false
14
15 try {
16 const msg = new IntlMessageFormat(icu, $localize.locale)
17
18 return (context: { [id: string]: number | string }, fallback: string) => {
19 try {
20 return msg.format(context) as string
21 } catch (err) {
22 if (!alreadyWarned) console.warn('Cannot format ICU %s.', icu, err)
23
24 alreadyWarned = true
25 return fallback
26 }
27 }
28 } catch (err) {
29 console.warn('Cannot build intl message %s.', icu, err)
30
31 return (_context: unknown, fallback: string) => fallback
32 }
33}
34
11export { 35export {
12 getDevLocale, 36 getDevLocale,
37 prepareIcu,
13 isOnDevLocale 38 isOnDevLocale
14} 39}
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'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { HttpStatusCode } from '@shared/models' 3import { HttpStatusCode } from '@shared/models'
4 4
5function genericUploadErrorHandler (parameters: { 5function genericUploadErrorHandler (options: {
6 err: Pick<HttpErrorResponse, 'message' | 'status' | 'headers'> 6 err: Pick<HttpErrorResponse, 'message' | 'status' | 'headers'>
7 name: string 7 name: string
8 notifier: Notifier 8 notifier: Notifier
9 sticky?: boolean 9 sticky?: boolean
10}) { 10}) {
11 const { err, name, notifier, sticky } = { sticky: false, ...parameters } 11 const { err, name, notifier, sticky = false } = options
12 const title = $localize`The upload failed` 12 const title = $localize`Upload failed`
13 let message = err.message 13 const message = buildMessage(name, err)
14
15 if (err instanceof ErrorEvent) { // network error
16 message = $localize`The connection was interrupted`
17 notifier.error(message, title, null, sticky)
18 } else if (err.status === HttpStatusCode.INTERNAL_SERVER_ERROR_500) {
19 message = $localize`The server encountered an error`
20 notifier.error(message, title, null, sticky)
21 } else if (err.status === HttpStatusCode.REQUEST_TIMEOUT_408) {
22 message = $localize`Your ${name} file couldn't be transferred before the set timeout (usually 10min)`
23 notifier.error(message, title, null, sticky)
24 } else if (err.status === HttpStatusCode.PAYLOAD_TOO_LARGE_413) {
25 const maxFileSize = err.headers?.get('X-File-Maximum-Size') || '8G'
26 message = $localize`Your ${name} file was too large (max. size: ${maxFileSize})`
27 notifier.error(message, title, null, sticky)
28 } else {
29 notifier.error(err.message, title)
30 }
31 14
15 notifier.error(message, title, null, sticky)
32 return message 16 return message
33} 17}
34 18
35export { 19export {
36 genericUploadErrorHandler 20 genericUploadErrorHandler
37} 21}
22
23// ---------------------------------------------------------------------------
24
25function buildMessage (name: string, err: Pick<HttpErrorResponse, 'message' | 'status' | 'headers'>) {
26 if (err instanceof ErrorEvent) { // network error
27 return $localize`The connection was interrupted`
28 }
29
30 if (err.status === HttpStatusCode.INTERNAL_SERVER_ERROR_500) {
31 return $localize`The server encountered an error`
32 }
33
34 if (err.status === HttpStatusCode.REQUEST_TIMEOUT_408) {
35 return $localize`Your ${name} file couldn't be transferred before the server proxy timeout`
36 }
37
38 if (err.status === HttpStatusCode.PAYLOAD_TOO_LARGE_413) {
39 const maxFileSize = err.headers?.get('X-File-Maximum-Size') || '8G'
40 return $localize`Your ${name} file was too large (max. size: ${maxFileSize})`
41 }
42
43 return err.message
44}
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 @@
1import { Component, forwardRef, Input } from '@angular/core' 1import { Component, forwardRef, Input } from '@angular/core'
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' 2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { Notifier } from '@app/core' 3import { Notifier } from '@app/core'
4import { prepareIcu } from '@app/helpers'
4import { SelectOptionsItem } from '../../../../types/select-options-item.model' 5import { SelectOptionsItem } from '../../../../types/select-options-item.model'
5import { ItemSelectCheckboxValue } from './select-checkbox.component' 6import { ItemSelectCheckboxValue } from './select-checkbox.component'
6 7
@@ -78,7 +79,12 @@ export class SelectCheckboxAllComponent implements ControlValueAccessor {
78 if (!outputItems) return true 79 if (!outputItems) return true
79 80
80 if (outputItems.length >= this.maxItems) { 81 if (outputItems.length >= this.maxItems) {
81 this.notifier.error($localize`You can't select more than ${this.maxItems} items`) 82 this.notifier.error(
83 prepareIcu($localize`You can't select more than {maxItems, plural, =1 {1 item} other {{maxItems} items}}`)(
84 { maxItems: this.maxItems },
85 $localize`You can't select more than ${this.maxItems} items`
86 )
87 )
82 88
83 return false 89 return false
84 } 90 }
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 @@
1import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
2import { ServerService } from '@app/core' 2import { ServerService } from '@app/core'
3import { prepareIcu } from '@app/helpers'
3import { ServerConfig } from '@shared/models' 4import { ServerConfig } from '@shared/models'
4import { PeertubeModalService } from '../shared-main/peertube-modal/peertube-modal.service' 5import { PeertubeModalService } from '../shared-main/peertube-modal/peertube-modal.service'
5 6
@@ -65,15 +66,20 @@ export class InstanceFeaturesTableComponent implements OnInit {
65 66
66 private getApproximateTime (seconds: number) { 67 private getApproximateTime (seconds: number) {
67 const hours = Math.floor(seconds / 3600) 68 const hours = Math.floor(seconds / 3600)
68 let pluralSuffix = ''
69 if (hours > 1) pluralSuffix = 's'
70 if (hours > 0) return `~ ${hours} hour${pluralSuffix}`
71 69
72 const minutes = Math.floor(seconds % 3600 / 60) 70 if (hours !== 0) {
71 return prepareIcu($localize`~ {hours, plural, =1 {1 hour} other {{hours} hours}}`)(
72 { hours },
73 $localize`~ ${hours} hours`
74 )
75 }
73 76
74 if (minutes === 1) return $localize`~ 1 minute` 77 const minutes = Math.floor(seconds % 3600 / 60)
75 78
76 return $localize`~ ${minutes} minutes` 79 return prepareIcu($localize`~ {minutes, plural, =1 {1 minute} other {{minutes} minutes}}`)(
80 { minutes },
81 $localize`~ ${minutes} minutes`
82 )
77 } 83 }
78 84
79 private buildQuotaHelpIndication () { 85 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 @@
1import { Pipe, PipeTransform } from '@angular/core' 1import { Pipe, PipeTransform } from '@angular/core'
2import { prepareIcu } from '@app/helpers'
2 3
3// Thanks: https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site 4// Thanks: https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site
4@Pipe({ name: 'myFromNow' }) 5@Pipe({ name: 'myFromNow' })
5export class FromNowPipe implements PipeTransform { 6export class FromNowPipe implements PipeTransform {
7 private yearICU = prepareIcu($localize`{interval, plural, =1 {1 year ago} other {{interval} years ago}}`)
8 private monthICU = prepareIcu($localize`{interval, plural, =1 {1 month ago} other {{interval} months ago}}`)
9 private weekICU = prepareIcu($localize`{interval, plural, =1 {1 week ago} other {{interval} weeks ago}}`)
10 private dayICU = prepareIcu($localize`{interval, plural, =1 {1 day ago} other {{interval} days ago}}`)
11 private hourICU = prepareIcu($localize`{interval, plural, =1 {1 hour ago} other {{interval} hours ago}}`)
12
6 transform (arg: number | Date | string) { 13 transform (arg: number | Date | string) {
7 const argDate = new Date(arg) 14 const argDate = new Date(arg)
8 const seconds = Math.floor((Date.now() - argDate.getTime()) / 1000) 15 const seconds = Math.floor((Date.now() - argDate.getTime()) / 1000)
9 16
10 let interval = Math.floor(seconds / 31536000) 17 let interval = Math.floor(seconds / 31536000)
11 if (interval > 1) return $localize`${interval} years ago` 18 if (interval >= 1) {
12 if (interval === 1) return $localize`1 year ago` 19 return this.yearICU({ interval }, $localize`${interval} year(s) ago`)
20 }
13 21
14 interval = Math.floor(seconds / 2419200) 22 interval = Math.floor(seconds / 2419200)
15 // 12 months = 360 days, but a year ~ 365 days 23 // 12 months = 360 days, but a year ~ 365 days
16 // Display "1 year ago" rather than "12 months ago" 24 // Display "1 year ago" rather than "12 months ago"
17 if (interval >= 12) return $localize`1 year ago` 25 if (interval >= 12) return $localize`1 year ago`
18 if (interval > 1) return $localize`${interval} months ago` 26
19 if (interval === 1) return $localize`1 month ago` 27 if (interval >= 1) {
28 return this.monthICU({ interval }, $localize`${interval} month(s) ago`)
29 }
20 30
21 interval = Math.floor(seconds / 604800) 31 interval = Math.floor(seconds / 604800)
22 // 4 weeks ~ 28 days, but our month is 30 days 32 // 4 weeks ~ 28 days, but our month is 30 days
23 // Display "1 month ago" rather than "4 weeks ago" 33 // Display "1 month ago" rather than "4 weeks ago"
24 if (interval >= 4) return $localize`1 month ago` 34 if (interval >= 4) return $localize`1 month ago`
25 if (interval > 1) return $localize`${interval} weeks ago` 35
26 if (interval === 1) return $localize`1 week ago` 36 if (interval >= 1) {
37 return this.weekICU({ interval }, $localize`${interval} week(s) ago`)
38 }
27 39
28 interval = Math.floor(seconds / 86400) 40 interval = Math.floor(seconds / 86400)
29 if (interval > 1) return $localize`${interval} days ago` 41 if (interval >= 1) {
30 if (interval === 1) return $localize`1 day ago` 42 return this.dayICU({ interval }, $localize`${interval} day(s) ago`)
43 }
31 44
32 interval = Math.floor(seconds / 3600) 45 interval = Math.floor(seconds / 3600)
33 if (interval > 1) return $localize`${interval} hours ago` 46 if (interval >= 1) {
34 if (interval === 1) return $localize`1 hour ago` 47 return this.hourICU({ interval }, $localize`${interval} hour(s) ago`)
48 }
35 49
36 interval = Math.floor(seconds / 60) 50 interval = Math.floor(seconds / 60)
37 if (interval >= 1) return $localize`${interval} min ago` 51 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 @@
1import { AuthUser } from '@app/core' 1import { AuthUser } from '@app/core'
2import { User } from '@app/core/users/user.model' 2import { User } from '@app/core/users/user.model'
3import { durationToString, getAbsoluteAPIUrl, getAbsoluteEmbedUrl } from '@app/helpers' 3import { durationToString, prepareIcu, getAbsoluteAPIUrl, getAbsoluteEmbedUrl } from '@app/helpers'
4import { Actor } from '@app/shared/shared-main/account/actor.model' 4import { Actor } from '@app/shared/shared-main/account/actor.model'
5import { buildVideoWatchPath } from '@shared/core-utils' 5import { buildVideoWatchPath } from '@shared/core-utils'
6import { peertubeTranslate } from '@shared/core-utils/i18n' 6import { peertubeTranslate } from '@shared/core-utils/i18n'
@@ -19,6 +19,9 @@ import {
19} from '@shared/models' 19} from '@shared/models'
20 20
21export class Video implements VideoServerModel { 21export class Video implements VideoServerModel {
22 private static readonly viewsICU = prepareIcu($localize`{views, plural, =0 {No view} =1 {1 view} other {{views} views}}`)
23 private static readonly viewersICU = prepareIcu($localize`{viewers, plural, =0 {No viewers} =1 {1 viewer} other {{viewers} viewers}}`)
24
22 byVideoChannel: string 25 byVideoChannel: string
23 byAccount: string 26 byAccount: string
24 27
@@ -269,12 +272,10 @@ export class Video implements VideoServerModel {
269 } 272 }
270 273
271 getExactNumberOfViews () { 274 getExactNumberOfViews () {
272 if (this.views < 1000) return ''
273
274 if (this.isLive) { 275 if (this.isLive) {
275 return $localize`${this.views} viewers` 276 return Video.viewersICU({ viewers: this.viewers }, $localize`${this.viewers} viewer(s)`)
276 } 277 }
277 278
278 return $localize`${this.views} views` 279 return Video.viewsICU({ views: this.views }, $localize`{${this.views} view(s)}`)
279 } 280 }
280} 281}
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 @@
1import { forkJoin } from 'rxjs' 1import { forkJoin } from 'rxjs'
2import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' 2import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
3import { Notifier } from '@app/core' 3import { Notifier } from '@app/core'
4import { prepareIcu } from '@app/helpers'
4import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 5import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
5import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 6import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
6import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 7import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
@@ -63,9 +64,16 @@ export class UserBanModalComponent extends FormReactive implements OnInit {
63 forkJoin(observables) 64 forkJoin(observables)
64 .subscribe({ 65 .subscribe({
65 next: () => { 66 next: () => {
66 const message = Array.isArray(this.usersToBan) 67 let message: string
67 ? $localize`${this.usersToBan.length} users banned.` 68
68 : $localize`User ${this.usersToBan.username} banned.` 69 if (Array.isArray(this.usersToBan)) {
70 message = prepareIcu($localize`{count, plural, =1 {1 user} other {{count} users}} banned.`)(
71 { count: this.usersToBan.length },
72 $localize`${this.usersToBan.length} users banned.`
73 )
74 } else {
75 message = $localize`User ${this.usersToBan.username} banned.`
76 }
69 77
70 this.notifier.success(message) 78 this.notifier.success(message)
71 79
@@ -79,7 +87,12 @@ export class UserBanModalComponent extends FormReactive implements OnInit {
79 } 87 }
80 88
81 getModalTitle () { 89 getModalTitle () {
82 if (Array.isArray(this.usersToBan)) return $localize`Ban ${this.usersToBan.length} users` 90 if (Array.isArray(this.usersToBan)) {
91 return prepareIcu($localize`Ban {count, plural, =1 {1 user} other {{count} users}}`)(
92 { count: this.usersToBan.length },
93 $localize`Ban ${this.usersToBan.length} users`
94 )
95 }
83 96
84 return $localize`Ban "${this.usersToBan.username}"` 97 return $localize`Ban "${this.usersToBan.username}"`
85 } 98 }
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 {
100 return 100 return
101 } 101 }
102 102
103 const message = $localize`If you remove user ${user.username}, you won't be able to create another with the same username!` 103 // eslint-disable-next-line max-len
104 const message = $localize`If you remove this user, you won't be able to create another user or channel with <strong>${user.username}</strong> username!`
104 const res = await this.confirmService.confirm(message, $localize`Delete ${user.username}`) 105 const res = await this.confirmService.confirm(message, $localize`Delete ${user.username}`)
105 if (res === false) return 106 if (res === false) return
106 107
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 @@
1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' 1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { prepareIcu } from '@app/helpers'
3import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 4import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
4import { Video } from '@app/shared/shared-main' 5import { Video } from '@app/shared/shared-main'
5import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 6import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
@@ -80,9 +81,10 @@ export class VideoBlockComponent extends FormReactive implements OnInit {
80 this.videoBlocklistService.blockVideo(options) 81 this.videoBlocklistService.blockVideo(options)
81 .subscribe({ 82 .subscribe({
82 next: () => { 83 next: () => {
83 const message = this.isMultiple 84 const message = prepareIcu($localize`{count, plural, =1 {Blocked {videoName}} other {Blocked {count} videos}}.`)(
84 ? $localize`Blocked ${this.videos.length} videos.` 85 { count: this.videos.length, videoName: this.getSingleVideo().name },
85 : $localize`Blocked ${this.getSingleVideo().name}` 86 $localize`Blocked ${this.videos.length} videos.`
87 )
86 88
87 this.notifier.success(message) 89 this.notifier.success(message)
88 this.hide() 90 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 @@
30 </my-help> 30 </my-help>
31 31
32 <div> 32 <div>
33 <my-select-languages formControlName="videoLanguages"></my-select-languages> 33 <my-select-languages [maxLanguages]="20" formControlName="videoLanguages"></my-select-languages>
34 </div> 34 </div>
35 </div> 35 </div>
36 36
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 {
175 175
176 if (video.scheduledUpdate) { 176 if (video.scheduledUpdate) {
177 const updateAt = new Date(video.scheduledUpdate.updateAt.toString()).toLocaleString(this.localeId) 177 const updateAt = new Date(video.scheduledUpdate.updateAt.toString()).toLocaleString(this.localeId)
178 return $localize`Publication scheduled on ` + updateAt 178 return $localize`Publication scheduled on ${updateAt}`
179 } 179 }
180 180
181 if (video.state.id === VideoState.TRANSCODING_FAILED) { 181 if (video.state.id === VideoState.TRANSCODING_FAILED) {
diff --git a/client/tsconfig.json b/client/tsconfig.json
index 41814d036..7a0584d5c 100644
--- a/client/tsconfig.json
+++ b/client/tsconfig.json
@@ -6,7 +6,7 @@
6 "sourceMap": true, 6 "sourceMap": true,
7 "declaration": false, 7 "declaration": false,
8 "moduleResolution": "node", 8 "moduleResolution": "node",
9 "module": "esnext", 9 "module": "es2020",
10 "experimentalDecorators": true, 10 "experimentalDecorators": true,
11 "noImplicitAny": true, 11 "noImplicitAny": true,
12 "noImplicitThis": true, 12 "noImplicitThis": true,
@@ -15,11 +15,12 @@
15 "importHelpers": true, 15 "importHelpers": true,
16 "allowSyntheticDefaultImports": true, 16 "allowSyntheticDefaultImports": true,
17 "strictBindCallApply": true, 17 "strictBindCallApply": true,
18 "target": "es2015", 18 "target": "es2017",
19 "typeRoots": [ 19 "typeRoots": [
20 "node_modules/@types" 20 "node_modules/@types"
21 ], 21 ],
22 "lib": [ 22 "lib": [
23 "ES2020.Intl",
23 "es2018", 24 "es2018",
24 "es2017", 25 "es2017",
25 "es2016", 26 "es2016",
diff --git a/client/yarn.lock b/client/yarn.lock
index 0adb80854..9a8be2b08 100644
--- a/client/yarn.lock
+++ b/client/yarn.lock
@@ -1327,6 +1327,45 @@
1327 minimatch "^3.0.4" 1327 minimatch "^3.0.4"
1328 strip-json-comments "^3.1.1" 1328 strip-json-comments "^3.1.1"
1329 1329
1330"@formatjs/ecma402-abstract@1.11.6":
1331 version "1.11.6"
1332 resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.11.6.tgz#0e828ddfed6fb3413ae379e48fb7170fb0795db5"
1333 integrity sha512-6TcI+IroIK+GTWXBJ643LBJklmCBsqLt1sUTGWfzdBcI5Y6b1L1iamrJB1B5OAQLnhzWveLbmzPYHYsFEZfeig==
1334 dependencies:
1335 "@formatjs/intl-localematcher" "0.2.27"
1336 tslib "2.4.0"
1337
1338"@formatjs/fast-memoize@1.2.3":
1339 version "1.2.3"
1340 resolved "https://registry.yarnpkg.com/@formatjs/fast-memoize/-/fast-memoize-1.2.3.tgz#5c950bd64c4959e30bbd16b22a17040fbeb9c4d2"
1341 integrity sha512-RVI3e4M7mIxAhKbbyS78H8++fsoiSRZgxh0zReHfvV6p1cpfgG2/k2qJYhJq0RXh6orVtUEsQ3xK9i4tDfsOSg==
1342 dependencies:
1343 tslib "2.4.0"
1344
1345"@formatjs/icu-messageformat-parser@2.1.2":
1346 version "2.1.2"
1347 resolved "https://registry.yarnpkg.com/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.1.2.tgz#9ff4dfc4f1ed613cca2c188b29f299854b86b7f8"
1348 integrity sha512-FYQ2pkgbDJxJlst/U5MU2H7+bR9HrZ4x8J4c0etrya24pJzQxYguVlAhc2S6NoEImlQ2LmIIGsURaBQu9bCtew==
1349 dependencies:
1350 "@formatjs/ecma402-abstract" "1.11.6"
1351 "@formatjs/icu-skeleton-parser" "1.3.8"
1352 tslib "2.4.0"
1353
1354"@formatjs/icu-skeleton-parser@1.3.8":
1355 version "1.3.8"
1356 resolved "https://registry.yarnpkg.com/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.3.8.tgz#3d150fcb45b4867c1db84237ca1f1f701d598918"
1357 integrity sha512-CVdsPMs/KvrIDKhMDw8bSq/Zst2bhdn/bTUfVCHi/c/bj462lChIJmW/JP/FaGKgZzdG8slGyVIFLonpG4uqFA==
1358 dependencies:
1359 "@formatjs/ecma402-abstract" "1.11.6"
1360 tslib "2.4.0"
1361
1362"@formatjs/intl-localematcher@0.2.27":
1363 version "0.2.27"
1364 resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.2.27.tgz#8a837ddca17a55d86e4ab68bcbb25b15f547d61d"
1365 integrity sha512-XHYcVas2ebDTh3VtfdluvbTjqyMUHqFHARnuJo5KYF/0MKOTmozVSK7PJGnu1IEHdmRdTWuG6TB+2RnkasaxVw==
1366 dependencies:
1367 tslib "2.4.0"
1368
1330"@gar/promisify@^1.0.1": 1369"@gar/promisify@^1.0.1":
1331 version "1.1.3" 1370 version "1.1.3"
1332 resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" 1371 resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6"
@@ -6537,6 +6576,16 @@ interpret@^2.2.0:
6537 resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9" 6576 resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9"
6538 integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw== 6577 integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==
6539 6578
6579intl-messageformat@^10.0.1:
6580 version "10.0.1"
6581 resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-10.0.1.tgz#dae7ae81a477e92ea8691dd73c60d5eb5003f866"
6582 integrity sha512-oZWDsNbauuWmPd98+zLEfNojuJkBdVpEWIcWQVCTxSJrhag2/czZnwKBsYa8NcVf4t0fWo0k77v+CBCudKEcjw==
6583 dependencies:
6584 "@formatjs/ecma402-abstract" "1.11.6"
6585 "@formatjs/fast-memoize" "1.2.3"
6586 "@formatjs/icu-messageformat-parser" "2.1.2"
6587 tslib "2.4.0"
6588
6540invert-kv@^1.0.0: 6589invert-kv@^1.0.0:
6541 version "1.0.0" 6590 version "1.0.0"
6542 resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" 6591 resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6"
@@ -11055,6 +11104,11 @@ tslib@2.3.1, tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.
11055 resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" 11104 resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"
11056 integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== 11105 integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==
11057 11106
11107tslib@2.4.0:
11108 version "2.4.0"
11109 resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3"
11110 integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==
11111
11058tslib@^1.8.1, tslib@^1.9.0: 11112tslib@^1.8.1, tslib@^1.9.0:
11059 version "1.14.1" 11113 version "1.14.1"
11060 resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" 11114 resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"