Support ICU in TS components

This commit is contained in:
Chocobozzz 2022-05-24 16:29:01 +02:00
parent e435cf44c0
commit eaa529528c
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
29 changed files with 392 additions and 158 deletions

View File

@ -100,6 +100,7 @@
"html-loader": "^3.0.1",
"html-webpack-plugin": "^5.3.1",
"https-browserify": "^1.0.0",
"intl-messageformat": "^10.0.1",
"jschannel": "^1.0.2",
"linkify-html": "^3.0.2",
"linkify-plugin-mention": "^3.0.2",

View File

@ -23,10 +23,10 @@
</h2>
<div class="actor-counters">
<div class="followers" i18n>{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div>
<div class="followers" i18n>{videoChannel.followersCount, plural, =0 {No subscribers} =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div>
<span class="videos-count" *ngIf="getTotalVideosOf(videoChannel) !== undefined" i18n>
{getTotalVideosOf(videoChannel), plural, =1 {1 videos} other {{{ getTotalVideosOf(videoChannel) }} videos}}
{getTotalVideosOf(videoChannel), plural, =0 {No videos} =1 {1 video} other {{{ getTotalVideosOf(videoChannel) }} videos}}
</span>
</div>

View File

@ -33,10 +33,10 @@
</div>
<div class="actor-counters">
<span i18n>{naiveAggregatedSubscribers(), plural, =1 {1 subscriber} other {{{ naiveAggregatedSubscribers() }} subscribers}}</span>
<span i18n>{naiveAggregatedSubscribers(), plural, =0 {No subscribers} =1 {1 subscriber} other {{{ naiveAggregatedSubscribers() }} subscribers}}</span>
<span class="videos-count" *ngIf="accountVideosCount !== undefined" i18n>
{accountVideosCount, plural, =1 {1 videos} other {{{ accountVideosCount }} videos}}
{accountVideosCount, plural, =0 {No videos} =1 {1 video} other {{{ accountVideosCount }} videos}}
</span>
</div>
</div>

View File

@ -30,8 +30,6 @@ export class AccountsComponent implements OnInit, OnDestroy {
links: ListOverflowItem[] = []
hideMenu = false
accountFollowerTitle = ''
accountVideosCount: number
accountDescriptionHTML = ''
accountDescriptionExpanded = false
@ -121,12 +119,6 @@ export class AccountsComponent implements OnInit, OnDestroy {
this.notifier.success($localize`Username copied`)
}
subscribersDisplayFor (count: number) {
if (count === 1) return $localize`1 subscriber`
return $localize`${count} subscribers`
}
searchChanged (search: string) {
const queryParams = { search }
@ -150,8 +142,6 @@ export class AccountsComponent implements OnInit, OnDestroy {
}
private async onAccount (account: Account) {
this.accountFollowerTitle = $localize`${account.followersCount} direct account followers`
this.accountDescriptionHTML = await this.markdown.textMarkdownToHTML(account.description)
// After the markdown renderer to avoid layout changes

View File

@ -1,5 +1,6 @@
import { Injectable } from '@angular/core'
import { FormGroup } from '@angular/forms'
import { prepareIcu } from '@app/helpers'
export type ResolutionOption = {
id: string
@ -86,9 +87,10 @@ export class EditConfigurationService {
return {
value,
atMost: noneOnAuto, // auto switches everything to a least estimation since ffmpeg will take as many threads as possible
unit: value > 1
? $localize`threads`
: $localize`thread`
unit: prepareIcu($localize`{value, plural, =1 {thread} other {threads}}`)(
{ value },
$localize`threads`
)
}
}
}

View File

@ -1,5 +1,6 @@
import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
import { Notifier } from '@app/core'
import { prepareIcu } from '@app/helpers'
import { splitAndGetNotEmpty, UNIQUE_HOSTS_OR_HANDLE_VALIDATOR } from '@app/shared/form-validators/host-validators'
import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
import { InstanceFollowService } from '@app/shared/shared-instance'
@ -60,7 +61,13 @@ export class FollowModalComponent extends FormReactive implements OnInit {
this.followService.follow(hostsOrHandles)
.subscribe({
next: () => {
this.notifier.success($localize`Follow request(s) sent!`)
this.notifier.success(
prepareIcu($localize`{count, plural, =1 {Follow request} other {Follow requests}} sent!`)(
{ count: hostsOrHandles.length },
$localize`Follow request(s) sent!`
)
)
this.newFollow.emit()
},

View File

@ -7,6 +7,7 @@ import { DropdownAction } from '@app/shared/shared-main'
import { BulkService } from '@app/shared/shared-moderation'
import { VideoCommentAdmin, VideoCommentService } from '@app/shared/shared-video-comment'
import { FeedFormat, UserRight } from '@shared/models'
import { prepareIcu } from '@app/helpers'
@Component({
selector: 'my-video-comment-list',
@ -145,7 +146,13 @@ export class VideoCommentListComponent extends RestTable implements OnInit {
this.videoCommentService.deleteVideoComments(commentArgs)
.subscribe({
next: () => {
this.notifier.success($localize`${commentArgs.length} comments deleted.`)
this.notifier.success(
prepareIcu($localize`{count, plural, =1 {1 comment} other {{count} comments}} deleted.`)(
{ count: commentArgs.length },
$localize`${commentArgs.length} comment(s) deleted.`
)
)
this.reloadData()
},

View File

@ -2,7 +2,7 @@ import { SortMeta } from 'primeng/api'
import { Component, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, ConfirmService, LocalStorageService, Notifier, RestPagination, RestTable, ServerService } from '@app/core'
import { getAPIHost } from '@app/helpers'
import { prepareIcu, getAPIHost } from '@app/helpers'
import { AdvancedInputFilter } from '@app/shared/shared-forms'
import { Actor, DropdownAction } from '@app/shared/shared-main'
import { AccountMutedStatus, BlocklistService, UserBanModalComponent, UserModerationDisplayType } from '@app/shared/shared-moderation'
@ -209,13 +209,25 @@ export class UserListComponent extends RestTable implements OnInit {
}
async unbanUsers (users: User[]) {
const res = await this.confirmService.confirm($localize`Do you really want to unban ${users.length} users?`, $localize`Unban`)
const res = await this.confirmService.confirm(
prepareIcu($localize`Do you really want to unban {count, plural, =1 {1 user} other {{count} users}}?`)(
{ count: users.length },
$localize`Do you really want to unban ${users.length} users?`
),
$localize`Unban`
)
if (res === false) return
this.userAdminService.unbanUsers(users)
.subscribe({
next: () => {
this.notifier.success($localize`${users.length} users unbanned.`)
this.notifier.success(
prepareIcu($localize`{count, plural, =1 {1 user} other {{count} users}} unbanned.`)(
{ count: users.length },
$localize`${users.length} users unbanned.`
)
)
this.reloadData()
},
@ -224,21 +236,28 @@ export class UserListComponent extends RestTable implements OnInit {
}
async removeUsers (users: User[]) {
for (const user of users) {
if (user.username === 'root') {
this.notifier.error($localize`You cannot delete root.`)
return
}
if (users.some(u => u.username === 'root')) {
this.notifier.error($localize`You cannot delete root.`)
return
}
const message = $localize`If you remove these users, you will not be able to create others with the same username!`
const message = $localize`<p>You can't create users or channels with a username that already used by a deleted user/channel.</p>` +
$localize`It means the following usernames will be permanently deleted and cannot be recovered:` +
'<ul>' + users.map(u => '<li>' + u.username + '</li>').join('') + '</ul>'
const res = await this.confirmService.confirm(message, $localize`Delete`)
if (res === false) return
this.userAdminService.removeUser(users)
.subscribe({
next: () => {
this.notifier.success($localize`${users.length} users deleted.`)
this.notifier.success(
prepareIcu($localize`{count, plural, =1 {1 user} other {{count} users}} deleted.`)(
{ count: users.length },
$localize`${users.length} users deleted.`
)
)
this.reloadData()
},
@ -250,7 +269,13 @@ export class UserListComponent extends RestTable implements OnInit {
this.userAdminService.updateUsers(users, { emailVerified: true })
.subscribe({
next: () => {
this.notifier.success($localize`${users.length} users email set as verified.`)
this.notifier.success(
prepareIcu($localize`{count, plural, =1 {1 user} other {{count} users}} email set as verified.`)(
{ count: users.length },
$localize`${users.length} users email set as verified.`
)
)
this.reloadData()
},

View File

@ -3,6 +3,7 @@ import { finalize } from 'rxjs/operators'
import { Component, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, ConfirmService, Notifier, RestPagination, RestTable } from '@app/core'
import { prepareIcu } from '@app/helpers'
import { AdvancedInputFilter } from '@app/shared/shared-forms'
import { DropdownAction, Video, VideoService } from '@app/shared/shared-main'
import { VideoBlockComponent, VideoBlockService } from '@app/shared/shared-moderation'
@ -196,14 +197,24 @@ export class VideoListComponent extends RestTable implements OnInit {
}
private async removeVideos (videos: Video[]) {
const message = $localize`Are you sure you want to delete these ${videos.length} videos?`
const message = prepareIcu($localize`Are you sure you want to delete {count, plural, =1 {this video} other {these {count} videos}}?`)(
{ count: videos.length },
$localize`Are you sure you want to delete these ${videos.length} videos?`
)
const res = await this.confirmService.confirm(message, $localize`Delete`)
if (res === false) return
this.videoService.removeVideo(videos.map(v => v.id))
.subscribe({
next: () => {
this.notifier.success($localize`Deleted ${videos.length} videos.`)
this.notifier.success(
prepareIcu($localize`Deleted {count, plural, =1 {1 video} other {{count} videos}}.`)(
{ count: videos.length },
$localize`Deleted ${videos.length} videos.`
)
)
this.reloadData()
},
@ -215,7 +226,13 @@ export class VideoListComponent extends RestTable implements OnInit {
this.videoBlockService.unblockVideo(videos.map(v => v.id))
.subscribe({
next: () => {
this.notifier.success($localize`Unblocked ${videos.length} videos.`)
this.notifier.success(
prepareIcu($localize`Unblocked {count, plural, =1 {1 video} other {{count} videos}}.`)(
{ count: videos.length },
$localize`Unblocked ${videos.length} videos.`
)
)
this.reloadData()
},
@ -224,9 +241,21 @@ export class VideoListComponent extends RestTable implements OnInit {
}
private async removeVideoFiles (videos: Video[], type: 'hls' | 'webtorrent') {
const message = type === 'hls'
? $localize`Are you sure you want to delete ${videos.length} HLS streaming playlists?`
: $localize`Are you sure you want to delete WebTorrent files of ${videos.length} videos?`
let message: string
if (type === 'hls') {
// eslint-disable-next-line max-len
message = prepareIcu($localize`Are you sure you want to delete {count, plural, =1 {1 HLS streaming playlist} other {{count} HLS streaming playlists}}?`)(
{ count: videos.length },
$localize`Are you sure you want to delete ${videos.length} HLS streaming playlists?`
)
} else {
// eslint-disable-next-line max-len
message = prepareIcu($localize`Are you sure you want to delete WebTorrent files of {count, plural, =1 {1 video} other {{count} videos}}?`)(
{ count: videos.length },
$localize`Are you sure you want to delete WebTorrent files of ${videos.length} videos?`
)
}
const res = await this.confirmService.confirm(message, $localize`Delete`)
if (res === false) return

View File

@ -37,7 +37,7 @@ export class MyAccountNotificationPreferencesComponent implements OnInit {
myVideoPublished: $localize`Video published (after transcoding/scheduled update)`,
myVideoImportFinished: $localize`Video import finished`,
newUserRegistration: $localize`A new user registered on your instance`,
newFollow: $localize`You or your channel(s) has a new follower`,
newFollow: $localize`You or one of your channels has a new follower`,
commentMention: $localize`Someone mentioned you in video comments`,
newInstanceFollower: $localize`Your instance has a new follower`,
autoInstanceFollowing: $localize`Your instance automatically followed another instance`,

View File

@ -93,8 +93,8 @@ export class MyHistoryComponent implements OnInit, DisableForReuseHook {
.subscribe({
next: () => {
const message = this.videosHistoryEnabled === true
? $localize`Videos history is enabled`
: $localize`Videos history is disabled`
? $localize`Video history is enabled`
: $localize`Video history is disabled`
this.notifier.success(message)
@ -117,8 +117,8 @@ export class MyHistoryComponent implements OnInit, DisableForReuseHook {
}
async clearAllHistory () {
const title = $localize`Delete videos history`
const message = $localize`Are you sure you want to delete all your videos history?`
const title = $localize`Delete video history`
const message = $localize`Are you sure you want to delete all your video history?`
const res = await this.confirmService.confirm(message, title)
if (res !== true) return
@ -126,7 +126,7 @@ export class MyHistoryComponent implements OnInit, DisableForReuseHook {
this.userHistoryService.clearAll()
.subscribe({
next: () => {
this.notifier.success($localize`Videos history deleted`)
this.notifier.success($localize`Video history deleted`)
this.reloadData()
},

View File

@ -4,7 +4,7 @@ import { Component, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, ComponentPagination, ConfirmService, Notifier, ScreenService, ServerService, User } from '@app/core'
import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
import { immutableAssign } from '@app/helpers'
import { prepareIcu, immutableAssign } from '@app/helpers'
import { AdvancedInputFilter } from '@app/shared/shared-forms'
import { DropdownAction, Video, VideoService } from '@app/shared/shared-main'
import { LiveStreamInformationComponent } from '@app/shared/shared-video-live'
@ -167,7 +167,10 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
.map(k => parseInt(k, 10))
const res = await this.confirmService.confirm(
$localize`Do you really want to delete ${toDeleteVideosIds.length} videos?`,
prepareIcu($localize`Do you really want to delete {length, plural, =1 {this video} other {{length} videos}}?`)(
{ length: toDeleteVideosIds.length },
$localize`Do you really want to delete ${toDeleteVideosIds.length} videos?`
),
$localize`Delete`
)
if (res === false) return
@ -184,7 +187,13 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
.pipe(toArray())
.subscribe({
next: () => {
this.notifier.success($localize`${toDeleteVideosIds.length} videos deleted.`)
this.notifier.success(
prepareIcu($localize`{length, plural, =1 {Video has been deleted} other {{length} videos have been deleted}}`)(
{ length: toDeleteVideosIds.length },
$localize`${toDeleteVideosIds.length} have been deleted.`
)
)
this.selection = {}
},

View File

@ -248,11 +248,11 @@ export class SearchComponent implements OnInit, OnDestroy {
}
private updateTitle () {
const suffix = this.currentSearch
? ' ' + this.currentSearch
: ''
const title = this.currentSearch
? $localize`Search ${this.currentSearch}`
: $localize`Search`
this.metaService.setTitle($localize`Search` + suffix)
this.metaService.setTitle(title)
}
private updateUrlFromAdvancedSearch () {

View File

@ -72,10 +72,10 @@
</div>
<div class="actor-counters">
<span i18n>{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</span>
<span i18n>{videoChannel.followersCount, plural, =0 {No subscribers} =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</span>
<span class="videos-count" *ngIf="channelVideosCount !== undefined" i18n>
{channelVideosCount, plural, =1 {1 videos} other {{{ channelVideosCount }} videos}}
{channelVideosCount, plural, =0 {No videos} =1 {1 video} other {{{ channelVideosCount }} videos}}
</span>
</div>
</div>

View File

@ -204,13 +204,28 @@ export class VideosListCommonPageComponent implements OnInit, OnDestroy, Disable
if ([ 'hot', 'trending', 'likes', 'views' ].includes(sanitizedSort)) {
this.title = $localize`Trending`
if (sanitizedSort === 'hot') this.titleTooltip = $localize`Videos with the most interactions for recent videos`
if (sanitizedSort === 'likes') this.titleTooltip = $localize`Videos that have the most likes`
if (sanitizedSort === 'views') this.titleTooltip = undefined
if (sanitizedSort === 'hot') {
this.titleTooltip = $localize`Videos with the most interactions for recent videos`
return
}
if (sanitizedSort === 'likes') {
this.titleTooltip = $localize`Videos that have the most likes`
return
}
if (sanitizedSort === 'views') {
this.titleTooltip = undefined
return
}
if (sanitizedSort === 'trending') {
if (this.trendingDays === 1) this.titleTooltip = $localize`Videos with the most views during the last 24 hours`
else this.titleTooltip = $localize`Videos with the most views during the last ${this.trendingDays} days`
if (this.trendingDays === 1) {
this.titleTooltip = $localize`Videos with the most views during the last 24 hours`
return
}
this.titleTooltip = $localize`Videos with the most views during the last ${this.trendingDays} days`
}
return

View File

@ -34,50 +34,18 @@ export class RestExtractor {
return target
}
handleError (err: any) {
let errorMessage
if (err.error instanceof Error) {
// A client-side or network error occurred. Handle it accordingly.
errorMessage = err.error.detail || err.error.title
console.error('An error occurred:', errorMessage)
} else if (typeof err.error === 'string') {
errorMessage = err.error
} else if (err.status !== undefined) {
// A server-side error occurred.
if (err.error?.errors) {
const errors = err.error.errors
const errorsArray: string[] = []
Object.keys(errors).forEach(key => {
errorsArray.push(errors[key].msg)
})
errorMessage = errorsArray.join('. ')
} else if (err.error?.error) {
errorMessage = err.error.error
} else if (err.status === HttpStatusCode.PAYLOAD_TOO_LARGE_413) {
// eslint-disable-next-line max-len
errorMessage = $localize`Media is too large for the server. Please contact you administrator if you want to increase the limit size.`
} else if (err.status === HttpStatusCode.TOO_MANY_REQUESTS_429) {
const secondsLeft = err.headers.get('retry-after')
if (secondsLeft) {
const minutesLeft = Math.floor(parseInt(secondsLeft, 10) / 60)
errorMessage = $localize`Too many attempts, please try again after ${minutesLeft} minutes.`
} else {
errorMessage = $localize`Too many attempts, please try again later.`
}
} else if (err.status === HttpStatusCode.INTERNAL_SERVER_ERROR_500) {
errorMessage = $localize`Server error. Please retry later.`
}
errorMessage = errorMessage || 'Unknown error.'
console.error(`Backend returned code ${err.status}, errorMessage is: ${errorMessage}`)
} else {
console.error(err)
errorMessage = err
redirectTo404IfNotFound (obj: { status: number }, type: 'video' | 'other', status = [ HttpStatusCode.NOT_FOUND_404 ]) {
if (obj?.status && status.includes(obj.status)) {
// Do not use redirectService to avoid circular dependencies
this.router.navigate([ '/404' ], { state: { type, obj }, skipLocationChange: true })
}
return observableThrowError(() => obj)
}
handleError (err: any) {
const errorMessage = this.buildErrorMessage(err)
const errorObj: { message: string, status: string, body: string } = {
message: errorMessage,
status: undefined,
@ -92,12 +60,63 @@ export class RestExtractor {
return observableThrowError(() => errorObj)
}
redirectTo404IfNotFound (obj: { status: number }, type: 'video' | 'other', status = [ HttpStatusCode.NOT_FOUND_404 ]) {
if (obj?.status && status.includes(obj.status)) {
// Do not use redirectService to avoid circular dependencies
this.router.navigate([ '/404' ], { state: { type, obj }, skipLocationChange: true })
private buildErrorMessage (err: any) {
if (err.error instanceof Error) {
// A client-side or network error occurred. Handle it accordingly.
const errorMessage = err.error.detail || err.error.title
console.error('An error occurred:', errorMessage)
return errorMessage
}
return observableThrowError(() => obj)
if (typeof err.error === 'string') {
return err.error
}
if (err.status !== undefined) {
const errorMessage = this.buildServerErrorMessage(err)
console.error(`Backend returned code ${err.status}, errorMessage is: ${errorMessage}`)
return errorMessage
}
console.error(err)
return err
}
private buildServerErrorMessage (err: any) {
// A server-side error occurred.
if (err.error?.errors) {
const errors = err.error.errors
return Object.keys(errors)
.map(key => errors[key].msg)
.join('. ')
}
if (err.error?.error) {
return err.error.error
}
if (err.status === HttpStatusCode.PAYLOAD_TOO_LARGE_413) {
return $localize`Media is too large for the server. Please contact you administrator if you want to increase the limit size.`
}
if (err.status === HttpStatusCode.TOO_MANY_REQUESTS_429) {
const secondsLeft = err.headers.get('retry-after')
if (secondsLeft) {
const minutesLeft = Math.floor(parseInt(secondsLeft, 10) / 60)
return $localize`Too many attempts, please try again after ${minutesLeft} minutes.`
}
return $localize`Too many attempts, please try again later.`
}
if (err.status === HttpStatusCode.INTERNAL_SERVER_ERROR_500) {
return $localize`Server error. Please retry later.`
}
return $localize`Unknown server error`
}
}

View File

@ -1,4 +1,5 @@
import { environment } from '../../environments/environment'
import IntlMessageFormat from 'intl-messageformat'
function isOnDevLocale () {
return environment.production === false && window.location.search === '?lang=fr'
@ -8,7 +9,31 @@ function getDevLocale () {
return 'fr-FR'
}
function prepareIcu (icu: string) {
let alreadyWarned = false
try {
const msg = new IntlMessageFormat(icu, $localize.locale)
return (context: { [id: string]: number | string }, fallback: string) => {
try {
return msg.format(context) as string
} catch (err) {
if (!alreadyWarned) console.warn('Cannot format ICU %s.', icu, err)
alreadyWarned = true
return fallback
}
}
} catch (err) {
console.warn('Cannot build intl message %s.', icu, err)
return (_context: unknown, fallback: string) => fallback
}
}
export {
getDevLocale,
prepareIcu,
isOnDevLocale
}

View File

@ -2,36 +2,43 @@ import { HttpErrorResponse } from '@angular/common/http'
import { Notifier } from '@app/core'
import { HttpStatusCode } from '@shared/models'
function genericUploadErrorHandler (parameters: {
function genericUploadErrorHandler (options: {
err: Pick<HttpErrorResponse, 'message' | 'status' | 'headers'>
name: string
notifier: Notifier
sticky?: boolean
}) {
const { err, name, notifier, sticky } = { sticky: false, ...parameters }
const title = $localize`The upload failed`
let message = err.message
if (err instanceof ErrorEvent) { // network error
message = $localize`The connection was interrupted`
notifier.error(message, title, null, sticky)
} else if (err.status === HttpStatusCode.INTERNAL_SERVER_ERROR_500) {
message = $localize`The server encountered an error`
notifier.error(message, title, null, sticky)
} else if (err.status === HttpStatusCode.REQUEST_TIMEOUT_408) {
message = $localize`Your ${name} file couldn't be transferred before the set timeout (usually 10min)`
notifier.error(message, title, null, sticky)
} else if (err.status === HttpStatusCode.PAYLOAD_TOO_LARGE_413) {
const maxFileSize = err.headers?.get('X-File-Maximum-Size') || '8G'
message = $localize`Your ${name} file was too large (max. size: ${maxFileSize})`
notifier.error(message, title, null, sticky)
} else {
notifier.error(err.message, title)
}
const { err, name, notifier, sticky = false } = options
const title = $localize`Upload failed`
const message = buildMessage(name, err)
notifier.error(message, title, null, sticky)
return message
}
export {
genericUploadErrorHandler
}
// ---------------------------------------------------------------------------
function buildMessage (name: string, err: Pick<HttpErrorResponse, 'message' | 'status' | 'headers'>) {
if (err instanceof ErrorEvent) { // network error
return $localize`The connection was interrupted`
}
if (err.status === HttpStatusCode.INTERNAL_SERVER_ERROR_500) {
return $localize`The server encountered an error`
}
if (err.status === HttpStatusCode.REQUEST_TIMEOUT_408) {
return $localize`Your ${name} file couldn't be transferred before the server proxy timeout`
}
if (err.status === HttpStatusCode.PAYLOAD_TOO_LARGE_413) {
const maxFileSize = err.headers?.get('X-File-Maximum-Size') || '8G'
return $localize`Your ${name} file was too large (max. size: ${maxFileSize})`
}
return err.message
}

View File

@ -1,6 +1,7 @@
import { Component, forwardRef, Input } from '@angular/core'
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
import { Notifier } from '@app/core'
import { prepareIcu } from '@app/helpers'
import { SelectOptionsItem } from '../../../../types/select-options-item.model'
import { ItemSelectCheckboxValue } from './select-checkbox.component'
@ -78,7 +79,12 @@ export class SelectCheckboxAllComponent implements ControlValueAccessor {
if (!outputItems) return true
if (outputItems.length >= this.maxItems) {
this.notifier.error($localize`You can't select more than ${this.maxItems} items`)
this.notifier.error(
prepareIcu($localize`You can't select more than {maxItems, plural, =1 {1 item} other {{maxItems} items}}`)(
{ maxItems: this.maxItems },
$localize`You can't select more than ${this.maxItems} items`
)
)
return false
}

View File

@ -1,5 +1,6 @@
import { Component, OnInit } from '@angular/core'
import { ServerService } from '@app/core'
import { prepareIcu } from '@app/helpers'
import { ServerConfig } from '@shared/models'
import { PeertubeModalService } from '../shared-main/peertube-modal/peertube-modal.service'
@ -65,15 +66,20 @@ export class InstanceFeaturesTableComponent implements OnInit {
private getApproximateTime (seconds: number) {
const hours = Math.floor(seconds / 3600)
let pluralSuffix = ''
if (hours > 1) pluralSuffix = 's'
if (hours > 0) return `~ ${hours} hour${pluralSuffix}`
if (hours !== 0) {
return prepareIcu($localize`~ {hours, plural, =1 {1 hour} other {{hours} hours}}`)(
{ hours },
$localize`~ ${hours} hours`
)
}
const minutes = Math.floor(seconds % 3600 / 60)
if (minutes === 1) return $localize`~ 1 minute`
return $localize`~ ${minutes} minutes`
return prepareIcu($localize`~ {minutes, plural, =1 {1 minute} other {{minutes} minutes}}`)(
{ minutes },
$localize`~ ${minutes} minutes`
)
}
private buildQuotaHelpIndication () {

View File

@ -1,37 +1,51 @@
import { Pipe, PipeTransform } from '@angular/core'
import { prepareIcu } from '@app/helpers'
// Thanks: https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site
@Pipe({ name: 'myFromNow' })
export class FromNowPipe implements PipeTransform {
private yearICU = prepareIcu($localize`{interval, plural, =1 {1 year ago} other {{interval} years ago}}`)
private monthICU = prepareIcu($localize`{interval, plural, =1 {1 month ago} other {{interval} months ago}}`)
private weekICU = prepareIcu($localize`{interval, plural, =1 {1 week ago} other {{interval} weeks ago}}`)
private dayICU = prepareIcu($localize`{interval, plural, =1 {1 day ago} other {{interval} days ago}}`)
private hourICU = prepareIcu($localize`{interval, plural, =1 {1 hour ago} other {{interval} hours ago}}`)
transform (arg: number | Date | string) {
const argDate = new Date(arg)
const seconds = Math.floor((Date.now() - argDate.getTime()) / 1000)
let interval = Math.floor(seconds / 31536000)
if (interval > 1) return $localize`${interval} years ago`
if (interval === 1) return $localize`1 year ago`
if (interval >= 1) {
return this.yearICU({ interval }, $localize`${interval} year(s) ago`)
}
interval = Math.floor(seconds / 2419200)
// 12 months = 360 days, but a year ~ 365 days
// Display "1 year ago" rather than "12 months ago"
if (interval >= 12) return $localize`1 year ago`
if (interval > 1) return $localize`${interval} months ago`
if (interval === 1) return $localize`1 month ago`
if (interval >= 1) {
return this.monthICU({ interval }, $localize`${interval} month(s) ago`)
}
interval = Math.floor(seconds / 604800)
// 4 weeks ~ 28 days, but our month is 30 days
// Display "1 month ago" rather than "4 weeks ago"
if (interval >= 4) return $localize`1 month ago`
if (interval > 1) return $localize`${interval} weeks ago`
if (interval === 1) return $localize`1 week ago`
if (interval >= 1) {
return this.weekICU({ interval }, $localize`${interval} week(s) ago`)
}
interval = Math.floor(seconds / 86400)
if (interval > 1) return $localize`${interval} days ago`
if (interval === 1) return $localize`1 day ago`
if (interval >= 1) {
return this.dayICU({ interval }, $localize`${interval} day(s) ago`)
}
interval = Math.floor(seconds / 3600)
if (interval > 1) return $localize`${interval} hours ago`
if (interval === 1) return $localize`1 hour ago`
if (interval >= 1) {
return this.hourICU({ interval }, $localize`${interval} hour(s) ago`)
}
interval = Math.floor(seconds / 60)
if (interval >= 1) return $localize`${interval} min ago`

View File

@ -1,6 +1,6 @@
import { AuthUser } from '@app/core'
import { User } from '@app/core/users/user.model'
import { durationToString, getAbsoluteAPIUrl, getAbsoluteEmbedUrl } from '@app/helpers'
import { durationToString, prepareIcu, getAbsoluteAPIUrl, getAbsoluteEmbedUrl } from '@app/helpers'
import { Actor } from '@app/shared/shared-main/account/actor.model'
import { buildVideoWatchPath } from '@shared/core-utils'
import { peertubeTranslate } from '@shared/core-utils/i18n'
@ -19,6 +19,9 @@ import {
} from '@shared/models'
export class Video implements VideoServerModel {
private static readonly viewsICU = prepareIcu($localize`{views, plural, =0 {No view} =1 {1 view} other {{views} views}}`)
private static readonly viewersICU = prepareIcu($localize`{viewers, plural, =0 {No viewers} =1 {1 viewer} other {{viewers} viewers}}`)
byVideoChannel: string
byAccount: string
@ -269,12 +272,10 @@ export class Video implements VideoServerModel {
}
getExactNumberOfViews () {
if (this.views < 1000) return ''
if (this.isLive) {
return $localize`${this.views} viewers`
return Video.viewersICU({ viewers: this.viewers }, $localize`${this.viewers} viewer(s)`)
}
return $localize`${this.views} views`
return Video.viewsICU({ views: this.views }, $localize`{${this.views} view(s)}`)
}
}

View File

@ -1,6 +1,7 @@
import { forkJoin } from 'rxjs'
import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
import { Notifier } from '@app/core'
import { prepareIcu } from '@app/helpers'
import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
@ -63,9 +64,16 @@ export class UserBanModalComponent extends FormReactive implements OnInit {
forkJoin(observables)
.subscribe({
next: () => {
const message = Array.isArray(this.usersToBan)
? $localize`${this.usersToBan.length} users banned.`
: $localize`User ${this.usersToBan.username} banned.`
let message: string
if (Array.isArray(this.usersToBan)) {
message = prepareIcu($localize`{count, plural, =1 {1 user} other {{count} users}} banned.`)(
{ count: this.usersToBan.length },
$localize`${this.usersToBan.length} users banned.`
)
} else {
message = $localize`User ${this.usersToBan.username} banned.`
}
this.notifier.success(message)
@ -79,7 +87,12 @@ export class UserBanModalComponent extends FormReactive implements OnInit {
}
getModalTitle () {
if (Array.isArray(this.usersToBan)) return $localize`Ban ${this.usersToBan.length} users`
if (Array.isArray(this.usersToBan)) {
return prepareIcu($localize`Ban {count, plural, =1 {1 user} other {{count} users}}`)(
{ count: this.usersToBan.length },
$localize`Ban ${this.usersToBan.length} users`
)
}
return $localize`Ban "${this.usersToBan.username}"`
}

View File

@ -100,7 +100,8 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
return
}
const message = $localize`If you remove user ${user.username}, you won't be able to create another with the same username!`
// eslint-disable-next-line max-len
const message = $localize`If you remove this user, you won't be able to create another user or channel with <strong>${user.username}</strong> username!`
const res = await this.confirmService.confirm(message, $localize`Delete ${user.username}`)
if (res === false) return

View File

@ -1,5 +1,6 @@
import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
import { Notifier } from '@app/core'
import { prepareIcu } from '@app/helpers'
import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
import { Video } from '@app/shared/shared-main'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
@ -80,9 +81,10 @@ export class VideoBlockComponent extends FormReactive implements OnInit {
this.videoBlocklistService.blockVideo(options)
.subscribe({
next: () => {
const message = this.isMultiple
? $localize`Blocked ${this.videos.length} videos.`
: $localize`Blocked ${this.getSingleVideo().name}`
const message = prepareIcu($localize`{count, plural, =1 {Blocked {videoName}} other {Blocked {count} videos}}.`)(
{ count: this.videos.length, videoName: this.getSingleVideo().name },
$localize`Blocked ${this.videos.length} videos.`
)
this.notifier.success(message)
this.hide()

View File

@ -30,7 +30,7 @@
</my-help>
<div>
<my-select-languages formControlName="videoLanguages"></my-select-languages>
<my-select-languages [maxLanguages]="20" formControlName="videoLanguages"></my-select-languages>
</div>
</div>

View File

@ -175,7 +175,7 @@ export class VideoMiniatureComponent implements OnInit {
if (video.scheduledUpdate) {
const updateAt = new Date(video.scheduledUpdate.updateAt.toString()).toLocaleString(this.localeId)
return $localize`Publication scheduled on ` + updateAt
return $localize`Publication scheduled on ${updateAt}`
}
if (video.state.id === VideoState.TRANSCODING_FAILED) {

View File

@ -6,7 +6,7 @@
"sourceMap": true,
"declaration": false,
"moduleResolution": "node",
"module": "esnext",
"module": "es2020",
"experimentalDecorators": true,
"noImplicitAny": true,
"noImplicitThis": true,
@ -15,11 +15,12 @@
"importHelpers": true,
"allowSyntheticDefaultImports": true,
"strictBindCallApply": true,
"target": "es2015",
"target": "es2017",
"typeRoots": [
"node_modules/@types"
],
"lib": [
"ES2020.Intl",
"es2018",
"es2017",
"es2016",

View File

@ -1327,6 +1327,45 @@
minimatch "^3.0.4"
strip-json-comments "^3.1.1"
"@formatjs/ecma402-abstract@1.11.6":
version "1.11.6"
resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.11.6.tgz#0e828ddfed6fb3413ae379e48fb7170fb0795db5"
integrity sha512-6TcI+IroIK+GTWXBJ643LBJklmCBsqLt1sUTGWfzdBcI5Y6b1L1iamrJB1B5OAQLnhzWveLbmzPYHYsFEZfeig==
dependencies:
"@formatjs/intl-localematcher" "0.2.27"
tslib "2.4.0"
"@formatjs/fast-memoize@1.2.3":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@formatjs/fast-memoize/-/fast-memoize-1.2.3.tgz#5c950bd64c4959e30bbd16b22a17040fbeb9c4d2"
integrity sha512-RVI3e4M7mIxAhKbbyS78H8++fsoiSRZgxh0zReHfvV6p1cpfgG2/k2qJYhJq0RXh6orVtUEsQ3xK9i4tDfsOSg==
dependencies:
tslib "2.4.0"
"@formatjs/icu-messageformat-parser@2.1.2":
version "2.1.2"
resolved "https://registry.yarnpkg.com/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.1.2.tgz#9ff4dfc4f1ed613cca2c188b29f299854b86b7f8"
integrity sha512-FYQ2pkgbDJxJlst/U5MU2H7+bR9HrZ4x8J4c0etrya24pJzQxYguVlAhc2S6NoEImlQ2LmIIGsURaBQu9bCtew==
dependencies:
"@formatjs/ecma402-abstract" "1.11.6"
"@formatjs/icu-skeleton-parser" "1.3.8"
tslib "2.4.0"
"@formatjs/icu-skeleton-parser@1.3.8":
version "1.3.8"
resolved "https://registry.yarnpkg.com/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.3.8.tgz#3d150fcb45b4867c1db84237ca1f1f701d598918"
integrity sha512-CVdsPMs/KvrIDKhMDw8bSq/Zst2bhdn/bTUfVCHi/c/bj462lChIJmW/JP/FaGKgZzdG8slGyVIFLonpG4uqFA==
dependencies:
"@formatjs/ecma402-abstract" "1.11.6"
tslib "2.4.0"
"@formatjs/intl-localematcher@0.2.27":
version "0.2.27"
resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.2.27.tgz#8a837ddca17a55d86e4ab68bcbb25b15f547d61d"
integrity sha512-XHYcVas2ebDTh3VtfdluvbTjqyMUHqFHARnuJo5KYF/0MKOTmozVSK7PJGnu1IEHdmRdTWuG6TB+2RnkasaxVw==
dependencies:
tslib "2.4.0"
"@gar/promisify@^1.0.1":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6"
@ -6537,6 +6576,16 @@ interpret@^2.2.0:
resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9"
integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==
intl-messageformat@^10.0.1:
version "10.0.1"
resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-10.0.1.tgz#dae7ae81a477e92ea8691dd73c60d5eb5003f866"
integrity sha512-oZWDsNbauuWmPd98+zLEfNojuJkBdVpEWIcWQVCTxSJrhag2/czZnwKBsYa8NcVf4t0fWo0k77v+CBCudKEcjw==
dependencies:
"@formatjs/ecma402-abstract" "1.11.6"
"@formatjs/fast-memoize" "1.2.3"
"@formatjs/icu-messageformat-parser" "2.1.2"
tslib "2.4.0"
invert-kv@^1.0.0:
version "1.0.0"
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.
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"
integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==
tslib@2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3"
integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==
tslib@^1.8.1, tslib@^1.9.0:
version "1.14.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"