1
0
Fork 0
mirror of https://github.com/Chocobozzz/PeerTube.git synced 2025-10-05 19:42:24 +02:00

Add email translations

Convert emails from Pug template to Handlebars because i18next doesn't
support Pug
This commit is contained in:
Chocobozzz 2025-07-18 11:04:30 +02:00
parent b45fbf4337
commit d6e4dac032
No known key found for this signature in database
GPG key ID: 583A612D890159BE
223 changed files with 9859 additions and 1426 deletions

View file

@ -223,8 +223,19 @@ Instance configurations are in `config/test-{1,2,3}.yaml`.
To test emails with PeerTube: To test emails with PeerTube:
* Run [mailslurper](http://mailslurper.com/) * Run [MailDev](https://github.com/maildev/maildev) using Docker
* Run PeerTube using mailslurper SMTP port: `NODE_CONFIG='{ "smtp": { "hostname": "localhost", "port": 2500, "tls": false } }' NODE_ENV=dev node dist/server` * Run PeerTube using MailDev SMTP port: `NODE_CONFIG='{ "smtp": { "hostname": "localhost", "port": 2500, "tls": false } }' NODE_ENV=dev node dist/server`
To test all emails without having to run actions manually on the web interface, you can run notification unit tests with environment variables to relay emails to your MailDev instance. For example:
```sh
MAILDEV_RELAY_HOST=localhost MAILDEV_RELAY_PORT=2500 mocha --exit --bail packages/tests/src/api/notifications/comments-notifications.ts
```
You can then go to the MailDev web interface and see how emails look like.
The admin web interface also have a button to send some email templates to a specific email address.
### Environment variables ### Environment variables

View file

@ -73,6 +73,18 @@
></my-markdown-textarea> ></my-markdown-textarea>
</div> </div>
<div class="form-group">
<label i18n for="instanceDefaultLanguage">Default language</label>
<div class="form-group-description">
<div i18n>Default language used for users, in emails for example.</div>
<div i18n>The web interface still uses the web browser preferred language if not overridden by user preference</div>
</div>
<div>
<my-select-options inputId="instanceDefaultLanguage" formControlName="defaultLanguage" [items]="defaultLanguageItems"></my-select-options>
</div>
</div>
<div class="form-group"> <div class="form-group">
<label i18n for="instanceCategories">Main instance categories</label> <label i18n for="instanceCategories">Main instance categories</label>

View file

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { Component, OnDestroy, OnInit, inject } from '@angular/core' import { Component, inject, OnDestroy, OnInit } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute, RouterLink } from '@angular/router' import { ActivatedRoute, RouterLink } from '@angular/router'
import { CanComponentDeactivate, ServerService } from '@app/core' import { CanComponentDeactivate, ServerService } from '@app/core'
@ -17,8 +17,11 @@ import {
} from '@app/shared/form-validators/form-validator.model' } from '@app/shared/form-validators/form-validator.model'
import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service' import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service' import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { SelectOptionsComponent } from '@app/shared/shared-forms/select/select-options.component'
import { SelectRadioComponent } from '@app/shared/shared-forms/select/select-radio.component' import { SelectRadioComponent } from '@app/shared/shared-forms/select/select-radio.component'
import { getCompleteLocale, I18N_LOCALES } from '@peertube/peertube-core-utils'
import { ActorImage, CustomConfig, NSFWPolicyType, VideoConstant } from '@peertube/peertube-models' import { ActorImage, CustomConfig, NSFWPolicyType, VideoConstant } from '@peertube/peertube-models'
import merge from 'lodash-es/merge'
import { Subscription } from 'rxjs' import { Subscription } from 'rxjs'
import { SelectOptionsItem } from 'src/types/select-options-item.model' import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { AdminConfigService } from '../../../shared/shared-admin/admin-config.service' import { AdminConfigService } from '../../../shared/shared-admin/admin-config.service'
@ -44,6 +47,7 @@ type Form = {
shortDescription: FormControl<string> shortDescription: FormControl<string>
description: FormControl<string> description: FormControl<string>
categories: FormControl<number[]> categories: FormControl<number[]>
defaultLanguage: FormControl<string>
languages: FormControl<string[]> languages: FormControl<string[]>
serverCountry: FormControl<string> serverCountry: FormControl<string>
@ -87,7 +91,8 @@ type Form = {
PeertubeCheckboxComponent, PeertubeCheckboxComponent,
PeerTubeTemplateDirective, PeerTubeTemplateDirective,
HelpComponent, HelpComponent,
AdminSaveBarComponent AdminSaveBarComponent,
SelectOptionsComponent
] ]
}) })
export class AdminConfigInformationComponent implements OnInit, OnDestroy, CanComponentDeactivate { export class AdminConfigInformationComponent implements OnInit, OnDestroy, CanComponentDeactivate {
@ -126,6 +131,8 @@ export class AdminConfigInformationComponent implements OnInit, OnDestroy, CanCo
} }
] ]
defaultLanguageItems: SelectOptionsItem[] = []
private customConfig: CustomConfig private customConfig: CustomConfig
private customConfigSub: Subscription private customConfigSub: Subscription
@ -143,6 +150,7 @@ export class AdminConfigInformationComponent implements OnInit, OnDestroy, CanCo
this.languageItems = data.languages.map(l => ({ label: l.label, id: l.id })) this.languageItems = data.languages.map(l => ({ label: l.label, id: l.id }))
this.categoryItems = data.categories.map(l => ({ label: l.label, id: l.id })) this.categoryItems = data.categories.map(l => ({ label: l.label, id: l.id }))
this.defaultLanguageItems = Object.entries(I18N_LOCALES).map(([ id, label ]) => ({ label, id }))
this.buildForm() this.buildForm()
@ -185,6 +193,8 @@ export class AdminConfigInformationComponent implements OnInit, OnDestroy, CanCo
hardwareInformation: null, hardwareInformation: null,
defaultLanguage: null,
categories: null, categories: null,
languages: null, languages: null,
@ -200,7 +210,14 @@ export class AdminConfigInformationComponent implements OnInit, OnDestroy, CanCo
} }
} }
const defaultValues: FormDefaultTyped<Form> = this.customConfig const defaultValues: FormDefaultTyped<Form> = merge(
this.customConfig,
{
instance: {
defaultLanguage: getCompleteLocale(this.customConfig.instance.defaultLanguage)
}
} satisfies FormDefaultTyped<Form>
)
const { const {
form, form,

View file

@ -1,15 +1,33 @@
<h2 class="fs-5" i18n>IP address</h2> <div class="root">
<div>
<h2 class="fs-5" i18n>IP address</h2>
<p i18n>PeerTube thinks your web browser public IP is <strong>{{ debug?.ip }}</strong>.</p> <p i18n>PeerTube thinks your web browser public IP is <strong>{{ debug?.ip }}</strong>.</p>
<p i18n>If this is not your correct public IP, please consider fixing it because:</p> <p i18n>If this is not your correct public IP, please consider fixing it because:</p>
<ul> <ul>
<li i18n>Views may not be counted correctly (reduced compared to what they should be)</li> <li i18n>Views may not be counted correctly (reduced compared to what they should be)</li>
<li i18n>Anti brute force system could be overzealous</li> <li i18n>Anti brute force system could be overzealous</li>
<li i18n>P2P system could not work correctly</li> <li i18n>P2P system could not work correctly</li>
</ul> </ul>
<p i18n>To fix it:<p> <p i18n>To fix it:<p>
<ul> <ul>
<li i18n>Check the <code>trust_proxy</code> configuration key</li> <li i18n>Check the <code>trust_proxy</code> configuration key</li>
</ul> </ul>
</div>
<div class="mt-4" [hidden]="isEmailDisabled()">
<h2 class="fs-5" i18n>Emails</h2>
<form (ngSubmit)="sendTestEmails()">
<div class="form-group">
<label i18n for="fromName">Send test emails to this address</label>
<div i18n class="form-group-description">PeerTube can send all available email templates to the following email address</div>
<input [(ngModel)]="testEmail" type="email" id="test-email" name="test-email" class="form-control">
</div>
<input type="submit" i18n-value value="Send test emails" class="peertube-button primary-button" [disabled]="!testEmail" />
</form>
</div>
</div>

View file

@ -1,7 +1,11 @@
@use '_variables' as *; @use "_variables" as *;
@use '_mixins' as *; @use "_mixins" as *;
code { code {
font-size: 14px; font-size: 14px;
font-weight: $font-semibold; font-weight: $font-semibold;
} }
.root {
max-width: 500px;
}

View file

@ -1,23 +1,31 @@
import { CommonModule } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core' import { Component, OnInit, inject } from '@angular/core'
import { Notifier } from '@app/core' import { FormsModule } from '@angular/forms'
import { Notifier, ServerService } from '@app/core'
import { Debug } from '@peertube/peertube-models' import { Debug } from '@peertube/peertube-models'
import { DebugService } from './debug.service' import { DebugService } from './debug.service'
@Component({ @Component({
templateUrl: './debug.component.html', templateUrl: './debug.component.html',
styleUrls: [ './debug.component.scss' ], styleUrls: [ './debug.component.scss' ],
imports: [] imports: [ CommonModule, FormsModule ]
}) })
export class DebugComponent implements OnInit { export class DebugComponent implements OnInit {
private debugService = inject(DebugService) private debugService = inject(DebugService)
private notifier = inject(Notifier) private notifier = inject(Notifier)
private server = inject(ServerService)
debug: Debug debug: Debug
testEmail: string
ngOnInit (): void { ngOnInit (): void {
this.load() this.load()
} }
isEmailDisabled () {
return this.server.getHTMLConfig().email.enabled === false
}
load () { load () {
this.debugService.getDebug() this.debugService.getDebug()
.subscribe({ .subscribe({
@ -26,4 +34,17 @@ export class DebugComponent implements OnInit {
error: err => this.notifier.error(err.message) error: err => this.notifier.error(err.message)
}) })
} }
sendTestEmails () {
this.debugService.testEmails(this.testEmail)
.subscribe({
next: () => {
this.testEmail = ''
this.notifier.success($localize`Emails will be sent!`)
},
error: err => this.notifier.error(err.message)
})
}
} }

View file

@ -3,7 +3,7 @@ import { catchError } from 'rxjs/operators'
import { HttpClient } from '@angular/common/http' import { HttpClient } from '@angular/common/http'
import { Injectable, inject } from '@angular/core' import { Injectable, inject } from '@angular/core'
import { RestExtractor } from '@app/core' import { RestExtractor } from '@app/core'
import { Debug } from '@peertube/peertube-models' import { Debug, SendDebugCommand } from '@peertube/peertube-models'
import { environment } from '../../../../environments/environment' import { environment } from '../../../../environments/environment'
@Injectable() @Injectable()
@ -19,4 +19,16 @@ export class DebugService {
catchError(err => this.restExtractor.handleError(err)) catchError(err => this.restExtractor.handleError(err))
) )
} }
testEmails (email: string) {
const body: SendDebugCommand = {
command: 'test-emails',
email
}
return this.authHttp.post(DebugService.BASE_DEBUG_URL + '/run-command', body)
.pipe(
catchError(err => this.restExtractor.handleError(err))
)
}
} }

View file

@ -21,6 +21,7 @@
<div class="pt-two-cols mt-5"> <!-- interface grid --> <div class="pt-two-cols mt-5"> <!-- interface grid -->
<div class="title-col"> <div class="title-col">
<div class="anchor" id="interface-settings"></div> <!-- interface settings anchor -->
<h2 i18n>INTERFACE</h2> <h2 i18n>INTERFACE</h2>
</div> </div>

View file

@ -1,7 +1,7 @@
import { DOCUMENT, getLocaleDirection, NgClass, NgIf, PlatformLocation } from '@angular/common' import { DOCUMENT, getLocaleDirection, NgClass, NgIf, PlatformLocation } from '@angular/common'
import { AfterViewInit, Component, inject, LOCALE_ID, OnDestroy, OnInit, viewChild } from '@angular/core' import { AfterViewInit, Component, inject, LOCALE_ID, OnDestroy, OnInit, viewChild } from '@angular/core'
import { DomSanitizer, SafeHtml } from '@angular/platform-browser' import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
import { ActivatedRoute, Event, GuardsCheckStart, RouteConfigLoadEnd, RouteConfigLoadStart, Router, RouterOutlet } from '@angular/router' import { Event, GuardsCheckStart, RouteConfigLoadEnd, RouteConfigLoadStart, Router, RouterOutlet } from '@angular/router'
import { import {
AuthService, AuthService,
Hotkey, Hotkey,
@ -83,7 +83,6 @@ export class AppComponent implements OnInit, AfterViewInit, OnDestroy {
private scrollService = inject(ScrollService) private scrollService = inject(ScrollService)
private userLocalStorage = inject(UserLocalStorageService) private userLocalStorage = inject(UserLocalStorageService)
private peertubeModal = inject(PeertubeModalService) private peertubeModal = inject(PeertubeModalService)
private route = inject(ActivatedRoute)
menu = inject(MenuService) menu = inject(MenuService)

View file

@ -1,5 +1,5 @@
import { Routes, UrlMatchResult, UrlSegment } from '@angular/router' import { Routes, UrlMatchResult, UrlSegment } from '@angular/router'
import { POSSIBLE_LOCALES } from '@peertube/peertube-core-utils' import { AVAILABLE_LOCALES } from '@peertube/peertube-core-utils'
import { MetaGuard } from './core' import { MetaGuard } from './core'
import { EmptyComponent } from './empty.component' import { EmptyComponent } from './empty.component'
import { HomepageRedirectComponent } from './homepage-redirect.component' import { HomepageRedirectComponent } from './homepage-redirect.component'
@ -241,7 +241,7 @@ const routes: Routes = [
] ]
// Avoid 404 when changing language // Avoid 404 when changing language
for (const locale of POSSIBLE_LOCALES) { for (const locale of AVAILABLE_LOCALES) {
routes.push({ routes.push({
path: locale, path: locale,
component: HomepageRedirectComponent component: HomepageRedirectComponent

View file

@ -76,6 +76,8 @@ export class User implements UserServerModel {
twoFactorEnabled: boolean twoFactorEnabled: boolean
language: string
createdAt: Date createdAt: Date
constructor (hash: Partial<UserServerModel>) { constructor (hash: Partial<UserServerModel>) {

View file

@ -1,9 +1,10 @@
import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable, LOCALE_ID, inject } from '@angular/core'
import { AuthService } from '@app/core/auth'
import { getCompleteLocale } from '@peertube/peertube-core-utils'
import { ActorImage, User as UserServerModel, UserUpdateMe, UserVideoQuota } from '@peertube/peertube-models'
import { Observable, of } from 'rxjs' import { Observable, of } from 'rxjs'
import { catchError, first, map, shareReplay } from 'rxjs/operators' import { catchError, first, map, shareReplay } from 'rxjs/operators'
import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable, inject } from '@angular/core'
import { AuthService } from '@app/core/auth'
import { ActorImage, User as UserServerModel, UserUpdateMe, UserVideoQuota } from '@peertube/peertube-models'
import { environment } from '../../../environments/environment' import { environment } from '../../../environments/environment'
import { RestExtractor } from '../rest' import { RestExtractor } from '../rest'
import { UserLocalStorageService } from './user-local-storage.service' import { UserLocalStorageService } from './user-local-storage.service'
@ -14,9 +15,11 @@ export class UserService {
private authHttp = inject(HttpClient) private authHttp = inject(HttpClient)
private authService = inject(AuthService) private authService = inject(AuthService)
private restExtractor = inject(RestExtractor) private restExtractor = inject(RestExtractor)
private localeId = inject(LOCALE_ID)
private userLocalStorageService = inject(UserLocalStorageService) private userLocalStorageService = inject(UserLocalStorageService)
static BASE_USERS_URL = environment.apiUrl + '/api/v1/users/' static BASE_USERS_URL = environment.apiUrl + '/api/v1/users/'
static BASE_CLIENT_CONFIG_URL = environment.apiUrl + '/api/v1/client-config/'
private userCache: { [id: number]: Observable<UserServerModel> } = {} private userCache: { [id: number]: Observable<UserServerModel> } = {}
private signupInThisSession = false private signupInThisSession = false
@ -60,7 +63,11 @@ export class UserService {
} }
getAnonymousUser () { getAnonymousUser () {
return new User(this.userLocalStorageService.getUserInfo()) return new User({
...this.userLocalStorageService.getUserInfo(),
language: getCompleteLocale(this.localeId)
})
} }
getAnonymousOrLoggedUser () { getAnonymousOrLoggedUser () {
@ -188,4 +195,12 @@ export class UserService {
.get<string[]>(url, { params }) .get<string[]>(url, { params })
.pipe(catchError(res => this.restExtractor.handleError(res))) .pipe(catchError(res => this.restExtractor.handleError(res)))
} }
updateInterfaceLanguage (language: string) {
const url = UserService.BASE_CLIENT_CONFIG_URL + 'update-interface-language'
const body = { language }
return this.authHttp.post(url, body)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
} }

View file

@ -65,23 +65,20 @@
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<a <a *ngIf="user.account" ngbDropdownItem class="dropdown-item" routerLink="/my-account">
*ngIf="user.account" ngbDropdownItem class="dropdown-item" routerLink="/my-account"
#manageAccount
>
<my-global-icon iconName="cog" aria-hidden="true"></my-global-icon> <ng-container i18n>Manage my account</ng-container> <my-global-icon iconName="cog" aria-hidden="true"></my-global-icon> <ng-container i18n>Manage my account</ng-container>
</a> </a>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<button <a
myPluginSelector pluginSelectorId="menu-user-dropdown-language-item" myPluginSelector pluginSelectorId="menu-user-dropdown-language-item"
ngbDropdownItem class="dropdown-item" (click)="openLanguageChooser()" ngbDropdownItem class="dropdown-item" routerLink="/my-account/settings" fragment="interface-settings"
> >
<my-global-icon iconName="language" aria-hidden="true"></my-global-icon> <my-global-icon iconName="language" aria-hidden="true"></my-global-icon>
<span i18n>Interface:</span> <span i18n>Interface:</span>
<span class="ms-auto ps-2 muted">{{ currentInterfaceLanguage }}</span> <span class="ms-auto ps-2 muted">{{ currentInterfaceLanguage }}</span>
</button> </a>
<button *ngIf="!isInMobileView" ngbDropdownItem class="dropdown-item" (click)="openHotkeysCheatSheet()"> <button *ngIf="!isInMobileView" ngbDropdownItem class="dropdown-item" (click)="openHotkeysCheatSheet()">
<my-global-icon iconName="keyboard" aria-hidden="true"></my-global-icon> <my-global-icon iconName="keyboard" aria-hidden="true"></my-global-icon>
@ -100,5 +97,4 @@
<my-button theme="tertiary" rounded="true" class="menu-button margin-button" icon="menu" (click)="toggleMenu()"></my-button> <my-button theme="tertiary" rounded="true" class="menu-button margin-button" icon="menu" (click)="toggleMenu()"></my-button>
</div> </div>
<my-language-chooser #languageChooserModal></my-language-chooser> <my-quick-settings #quickSettingsModal></my-quick-settings>
<my-quick-settings #quickSettingsModal (openLanguageModal)="languageChooserModal.show()"></my-quick-settings>

View file

@ -1,9 +1,8 @@
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { Component, OnDestroy, OnInit, inject, viewChild } from '@angular/core' import { Component, inject, LOCALE_ID, OnDestroy, OnInit, viewChild } from '@angular/core'
import { NavigationEnd, Router, RouterLink } from '@angular/router' import { NavigationEnd, Router, RouterLink } from '@angular/router'
import { AuthService, AuthStatus, AuthUser, HotkeysService, MenuService, RedirectService, ScreenService, ServerService } from '@app/core' import { AuthService, AuthStatus, AuthUser, HotkeysService, MenuService, RedirectService, ScreenService, ServerService } from '@app/core'
import { NotificationDropdownComponent } from '@app/header/notification-dropdown.component' import { NotificationDropdownComponent } from '@app/header/notification-dropdown.component'
import { LanguageChooserComponent } from '@app/menu/language-chooser.component'
import { QuickSettingsModalComponent } from '@app/menu/quick-settings-modal.component' import { QuickSettingsModalComponent } from '@app/menu/quick-settings-modal.component'
import { ActorAvatarComponent } from '@app/shared/shared-actor-image/actor-avatar.component' import { ActorAvatarComponent } from '@app/shared/shared-actor-image/actor-avatar.component'
import { PeertubeModalService } from '@app/shared/shared-main/peertube-modal/peertube-modal.service' import { PeertubeModalService } from '@app/shared/shared-main/peertube-modal/peertube-modal.service'
@ -11,15 +10,16 @@ import { PluginSelectorDirective } from '@app/shared/shared-main/plugins/plugin-
import { LoginLinkComponent } from '@app/shared/shared-main/users/login-link.component' import { LoginLinkComponent } from '@app/shared/shared-main/users/login-link.component'
import { SignupLabelComponent } from '@app/shared/shared-main/users/signup-label.component' import { SignupLabelComponent } from '@app/shared/shared-main/users/signup-label.component'
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { findAppropriateImage, getCompleteLocale, I18N_LOCALES } from '@peertube/peertube-core-utils'
import { HTMLServerConfig, ServerConfig } from '@peertube/peertube-models' import { HTMLServerConfig, ServerConfig } from '@peertube/peertube-models'
import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage' import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
import { isAndroid, isIOS, isIphone } from '@root-helpers/web-browser' import { isAndroid, isIOS, isIphone } from '@root-helpers/web-browser'
import { Subscription } from 'rxjs' import { Subscription } from 'rxjs'
import { GlobalIconComponent } from '../shared/shared-icons/global-icon.component' import { GlobalIconComponent } from '../shared/shared-icons/global-icon.component'
import { ButtonComponent } from '../shared/shared-main/buttons/button.component' import { ButtonComponent } from '../shared/shared-main/buttons/button.component'
import { SearchTypeaheadComponent } from './search-typeahead.component'
import { HeaderService } from './header.service' import { HeaderService } from './header.service'
import { findAppropriateImage } from '@peertube/peertube-core-utils' import { SearchTypeaheadComponent } from './search-typeahead.component'
import { getDevLocale, isOnDevLocale } from '@app/helpers'
@Component({ @Component({
selector: 'my-header', selector: 'my-header',
@ -32,7 +32,6 @@ import { findAppropriateImage } from '@peertube/peertube-core-utils'
PluginSelectorDirective, PluginSelectorDirective,
SignupLabelComponent, SignupLabelComponent,
LoginLinkComponent, LoginLinkComponent,
LanguageChooserComponent,
QuickSettingsModalComponent, QuickSettingsModalComponent,
GlobalIconComponent, GlobalIconComponent,
RouterLink, RouterLink,
@ -53,10 +52,10 @@ export class HeaderComponent implements OnInit, OnDestroy {
private router = inject(Router) private router = inject(Router)
private menu = inject(MenuService) private menu = inject(MenuService)
private headerService = inject(HeaderService) private headerService = inject(HeaderService)
private localeId = inject(LOCALE_ID)
private static LS_HIDE_MOBILE_MSG = 'hide-mobile-msg' private static LS_HIDE_MOBILE_MSG = 'hide-mobile-msg'
readonly languageChooserModal = viewChild<LanguageChooserComponent>('languageChooserModal')
readonly quickSettingsModal = viewChild<QuickSettingsModalComponent>('quickSettingsModal') readonly quickSettingsModal = viewChild<QuickSettingsModalComponent>('quickSettingsModal')
readonly dropdown = viewChild<NgbDropdown>('dropdown') readonly dropdown = viewChild<NgbDropdown>('dropdown')
@ -65,8 +64,6 @@ export class HeaderComponent implements OnInit, OnDestroy {
hotkeysHelpVisible = false hotkeysHelpVisible = false
currentInterfaceLanguage: string
mobileMsg = false mobileMsg = false
androidAppUrl = '' androidAppUrl = ''
iosAppUrl = '' iosAppUrl = ''
@ -81,8 +78,15 @@ export class HeaderComponent implements OnInit, OnDestroy {
private hotkeysSub: Subscription private hotkeysSub: Subscription
private authSub: Subscription private authSub: Subscription
get language () { get currentInterfaceLanguage () {
return this.languageChooserModal().getCurrentLanguage() const english = 'English'
const locale = isOnDevLocale()
? getDevLocale()
: getCompleteLocale(this.localeId)
if (locale) return I18N_LOCALES[locale as keyof typeof I18N_LOCALES] || english
return english
} }
get requiresApproval () { get requiresApproval () {
@ -121,7 +125,6 @@ export class HeaderComponent implements OnInit, OnDestroy {
ngOnInit () { ngOnInit () {
this.htmlConfig = this.serverService.getHTMLConfig() this.htmlConfig = this.serverService.getHTMLConfig()
this.currentInterfaceLanguage = this.languageChooserModal().getCurrentLanguage()
this.loggedIn = this.authService.isLoggedIn() this.loggedIn = this.authService.isLoggedIn()
this.updateUserState() this.updateUserState()
@ -273,10 +276,6 @@ export class HeaderComponent implements OnInit, OnDestroy {
this.redirectService.redirectToHomepage() this.redirectService.redirectToHomepage()
} }
openLanguageChooser () {
this.languageChooserModal().show()
}
openQuickSettings () { openQuickSettings () {
this.quickSettingsModal().show() this.quickSettingsModal().show()
} }

View file

@ -1,17 +0,0 @@
<ng-template #modal let-hide="close">
<div class="modal-header">
<h4 i18n class="modal-title">Change the language</h4>
<button class="border-0 p-0" title="Close this modal" i18n-title (click)="hide()">
<my-global-icon iconName="cross"></my-global-icon>
</button>
</div>
<a i18n class="peertube-button-link primary-button rounded-0" target="_blank" rel="noreferrer noopener" href="https://github.com/Chocobozzz/PeerTube/blob/develop/support/doc/translation.md">
Help to translate PeerTube!
</a>
<div class="modal-body">
<a *ngFor="let lang of languages" [href]="buildLanguageLink(lang)" [lang]=lang.iso>{{ lang.label }}</a>
</div>
</ng-template>

View file

@ -1,11 +0,0 @@
@use '_variables' as *;
@use '_mixins' as *;
.modal-body {
text-align: center;
a {
display: block;
margin: 15px;
}
}

View file

@ -1,44 +0,0 @@
import { CommonModule } from '@angular/common'
import { Component, ElementRef, LOCALE_ID, inject, viewChild } from '@angular/core'
import { getDevLocale, isOnDevLocale } from '@app/helpers'
import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { getCompleteLocale, getShortLocale, I18N_LOCALES, objectKeysTyped, sortBy } from '@peertube/peertube-core-utils'
@Component({
selector: 'my-language-chooser',
templateUrl: './language-chooser.component.html',
styleUrls: [ './language-chooser.component.scss' ],
imports: [ CommonModule, GlobalIconComponent ]
})
export class LanguageChooserComponent {
private modalService = inject(NgbModal)
private localeId = inject(LOCALE_ID)
readonly modal = viewChild<ElementRef>('modal')
languages: { id: string, label: string, iso: string }[] = []
constructor () {
const l = objectKeysTyped(I18N_LOCALES)
.map(k => ({ id: k, label: I18N_LOCALES[k], iso: getShortLocale(k) }))
this.languages = sortBy(l, 'label')
}
show () {
this.modalService.open(this.modal(), { centered: true })
}
buildLanguageLink (lang: { id: string }) {
return window.location.origin + '/' + lang.id
}
getCurrentLanguage () {
const english = 'English'
const locale = isOnDevLocale() ? getDevLocale() : getCompleteLocale(this.localeId)
if (locale) return I18N_LOCALES[locale as keyof typeof I18N_LOCALES] || english
return english
}
}

View file

@ -9,21 +9,20 @@
<div class="modal-body"> <div class="modal-body">
<my-alert i18n type="primary">These settings apply only to your session on this instance.</my-alert> <my-alert i18n type="primary">These settings apply only to your session on this instance.</my-alert>
<h5 i18n class="section-label mt-4 mb-2">VIDEOS</h5> <h5 i18n class="section-label mt-4 mb-3">INTERFACE</h5>
<my-user-video-settings
*ngIf="!isUserLoggedIn()"
[user]="user" [userInformationLoaded]="userInformationLoaded" [reactiveUpdate]="true" [notifyOnUpdate]="true"
>
</my-user-video-settings>
<h5 i18n class="section-label mt-4 mb-2">INTERFACE</h5>
<my-user-interface-settings <my-user-interface-settings
*ngIf="!isUserLoggedIn()" *ngIf="!isUserLoggedIn()"
[user]="user" [userInformationLoaded]="userInformationLoaded" [reactiveUpdate]="true" [notifyOnUpdate]="true" [user]="user" [userInformationLoaded]="userInformationLoaded" reactiveUpdate="true" notifyOnUpdate="true"
></my-user-interface-settings> ></my-user-interface-settings>
<my-button i18n class="mt-2" theme="secondary" icon="language" (click)="changeLanguage()">Change interface language</my-button>
<h5 i18n class="section-label mt-4 mb-3">VIDEOS</h5>
<my-user-video-settings
*ngIf="!isUserLoggedIn()"
[user]="user" [userInformationLoaded]="userInformationLoaded" reactiveUpdate="true" notifyOnUpdate="true"
>
</my-user-video-settings>
</div> </div>
</ng-template> </ng-template>

View file

@ -1,9 +1,8 @@
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { Component, OnDestroy, OnInit, inject, output, viewChild } from '@angular/core' import { Component, OnDestroy, OnInit, inject, viewChild } from '@angular/core'
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute } from '@angular/router'
import { AuthService, AuthStatus, LocalStorageService, PeerTubeRouterService, User, UserService } from '@app/core' import { AuthService, AuthStatus, LocalStorageService, PeerTubeRouterService, User, UserService } from '@app/core'
import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component' import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
import { ButtonComponent } from '@app/shared/shared-main/buttons/button.component'
import { AlertComponent } from '@app/shared/shared-main/common/alert.component' import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
import { UserInterfaceSettingsComponent } from '@app/shared/shared-user-settings/user-interface-settings.component' import { UserInterfaceSettingsComponent } from '@app/shared/shared-user-settings/user-interface-settings.component'
import { UserVideoSettingsComponent } from '@app/shared/shared-user-settings/user-video-settings.component' import { UserVideoSettingsComponent } from '@app/shared/shared-user-settings/user-video-settings.component'
@ -15,13 +14,17 @@ import { filter } from 'rxjs/operators'
@Component({ @Component({
selector: 'my-quick-settings', selector: 'my-quick-settings',
templateUrl: './quick-settings-modal.component.html', templateUrl: './quick-settings-modal.component.html',
styles: [
`h5 {
font-size: 1rem;
}`
],
imports: [ imports: [
CommonModule, CommonModule,
GlobalIconComponent, GlobalIconComponent,
UserVideoSettingsComponent, UserVideoSettingsComponent,
UserInterfaceSettingsComponent, UserInterfaceSettingsComponent,
AlertComponent, AlertComponent
ButtonComponent
] ]
}) })
export class QuickSettingsModalComponent implements OnInit, OnDestroy { export class QuickSettingsModalComponent implements OnInit, OnDestroy {
@ -36,8 +39,6 @@ export class QuickSettingsModalComponent implements OnInit, OnDestroy {
readonly modal = viewChild<NgbModal>('modal') readonly modal = viewChild<NgbModal>('modal')
readonly openLanguageModal = output()
user: User user: User
userInformationLoaded = new ReplaySubject<boolean>(1) userInformationLoaded = new ReplaySubject<boolean>(1)
@ -89,11 +90,6 @@ export class QuickSettingsModalComponent implements OnInit, OnDestroy {
this.setModalQuery('add') this.setModalQuery('add')
} }
changeLanguage () {
this.openedModal.close()
this.openLanguageModal.emit()
}
private setModalQuery (type: 'add' | 'remove') { private setModalQuery (type: 'add' | 'remove') {
const modal = type === 'add' const modal = type === 'add'
? QuickSettingsModalComponent.QUERY_MODAL_NAME ? QuickSettingsModalComponent.QUERY_MODAL_NAME

View file

@ -0,0 +1,10 @@
import { HttpHandlerFn, HttpRequest } from '@angular/common/http'
import { inject, LOCALE_ID } from '@angular/core'
export function languageInterceptor (req: HttpRequest<unknown>, next: HttpHandlerFn) {
const localeId = inject(LOCALE_ID)
const newReq = req.clone({ headers: req.headers.append('x-peertube-language', localeId) })
return next(newReq)
}

View file

@ -1,10 +1,10 @@
import { DatePipe } from '@angular/common' import { DatePipe } from '@angular/common'
import { AccountService } from './account/account.service' import { AccountService } from './account/account.service'
import { AUTH_INTERCEPTOR_PROVIDER } from './auth/auth-interceptor.service'
import { VideoChannelSyncService } from './channel/video-channel-sync.service' import { VideoChannelSyncService } from './channel/video-channel-sync.service'
import { VideoChannelService } from './channel/video-channel.service' import { VideoChannelService } from './channel/video-channel.service'
import { CustomPageService } from './custom-page/custom-page.service' import { CustomPageService } from './custom-page/custom-page.service'
import { FromNowPipe } from './date/from-now.pipe' import { FromNowPipe } from './date/from-now.pipe'
import { AUTH_INTERCEPTOR_PROVIDER } from './http/auth-interceptor.service'
import { InstanceService } from './instance/instance.service' import { InstanceService } from './instance/instance.service'
import { ActorRedirectGuard } from './router/actor-redirect-guard.service' import { ActorRedirectGuard } from './router/actor-redirect-guard.service'
import { UserHistoryService } from './users/user-history.service' import { UserHistoryService } from './users/user-history.service'

View file

@ -1,4 +1,21 @@
<form (ngSubmit)="updateInterfaceSettings()" [formGroup]="form"> <form (ngSubmit)="updateInterfaceSettings()" [formGroup]="form">
<div class="form-group">
<label i18n for="language">Language</label>
<div class="form-group-description">
<div i18n>Preferred language for the web interface and for emails</div>
<div i18n>
You can help us to translate the platform by consulting <a
class="link-primary"
target="_blank"
rel="noreferrer noopener"
href="https://github.com/Chocobozzz/PeerTube/blob/develop/support/doc/translation.md#how-to"
>this guide</a>
</div>
</div>
<my-select-options inputId="language" formControlName="language" [items]="availableLanguages"></my-select-options>
</div>
<div class="form-group"> <div class="form-group">
<label i18n for="theme">Theme</label> <label i18n for="theme">Theme</label>
@ -6,5 +23,5 @@
<my-select-options inputId="theme" formControlName="theme" [items]="availableThemes"></my-select-options> <my-select-options inputId="theme" formControlName="theme" [items]="availableThemes"></my-select-options>
</div> </div>
<input *ngIf="!reactiveUpdate()" type="submit" class="peertube-button primary-button" i18n-value value="Save interface settings" [disabled]="!form.valid"> <input *ngIf="!reactiveUpdate()" type="submit" class="peertube-button primary-button" [value]="getSubmitValue()" [disabled]="!form.valid">
</form> </form>

View file

@ -1,37 +1,56 @@
import { Subject, Subscription } from 'rxjs'
import { Component, OnDestroy, OnInit, inject, input } from '@angular/core'
import { AuthService, Notifier, ServerService, ThemeService, UserService } from '@app/core'
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { HTMLServerConfig, User, UserUpdateMe } from '@peertube/peertube-models'
import { SelectOptionsItem } from 'src/types'
import { NgIf } from '@angular/common' import { NgIf } from '@angular/common'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { booleanAttribute, Component, inject, input, OnDestroy, OnInit } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { AuthService, Notifier, ServerService, ThemeService, UserService } from '@app/core'
import {
BuildFormArgumentTyped,
FormReactiveErrorsTyped,
FormReactiveMessagesTyped,
FormReactiveService
} from '@app/shared/shared-forms/form-reactive.service'
import { I18N_LOCALES } from '@peertube/peertube-core-utils'
import { HTMLServerConfig, User, UserUpdateMe } from '@peertube/peertube-models'
import { of, Subject, Subscription, switchMap } from 'rxjs'
import { SelectOptionsItem } from 'src/types'
import { SelectOptionsComponent } from '../shared-forms/select/select-options.component' import { SelectOptionsComponent } from '../shared-forms/select/select-options.component'
type Form = {
theme: FormControl<string>
language: FormControl<string>
}
@Component({ @Component({
selector: 'my-user-interface-settings', selector: 'my-user-interface-settings',
templateUrl: './user-interface-settings.component.html', templateUrl: './user-interface-settings.component.html',
styleUrls: [ './user-interface-settings.component.scss' ], styleUrls: [ './user-interface-settings.component.scss' ],
imports: [ FormsModule, ReactiveFormsModule, NgIf, SelectOptionsComponent ] imports: [ FormsModule, ReactiveFormsModule, NgIf, SelectOptionsComponent ]
}) })
export class UserInterfaceSettingsComponent extends FormReactive implements OnInit, OnDestroy { export class UserInterfaceSettingsComponent implements OnInit, OnDestroy {
protected formReactiveService = inject(FormReactiveService) private formReactiveService = inject(FormReactiveService)
private authService = inject(AuthService) private authService = inject(AuthService)
private notifier = inject(Notifier) private notifier = inject(Notifier)
private userService = inject(UserService) private userService = inject(UserService)
private themeService = inject(ThemeService) private themeService = inject(ThemeService)
private serverService = inject(ServerService) private serverService = inject(ServerService)
readonly user = input<User>(undefined) readonly user = input<Pick<User, 'theme' | 'language'>>(undefined)
readonly reactiveUpdate = input(false) readonly reactiveUpdate = input(false, { transform: booleanAttribute })
readonly notifyOnUpdate = input(true) readonly notifyOnUpdate = input(true, { transform: booleanAttribute })
readonly userInformationLoaded = input<Subject<any>>(undefined) readonly userInformationLoaded = input<Subject<any>>(undefined)
form: FormGroup<Form>
formErrors: FormReactiveErrorsTyped<Form> = {}
validationMessages: FormReactiveMessagesTyped<Form> = {}
availableThemes: SelectOptionsItem[] availableThemes: SelectOptionsItem[]
availableLanguages: SelectOptionsItem[]
formValuesWatcher: Subscription formValuesWatcher: Subscription
private serverConfig: HTMLServerConfig private serverConfig: HTMLServerConfig
private initialUserLanguage: string
private updating = false
get instanceName () { get instanceName () {
return this.serverConfig.instance.name return this.serverConfig.instance.name
@ -39,6 +58,7 @@ export class UserInterfaceSettingsComponent extends FormReactive implements OnIn
ngOnInit () { ngOnInit () {
this.serverConfig = this.serverService.getHTMLConfig() this.serverConfig = this.serverService.getHTMLConfig()
this.initialUserLanguage = this.user().language
this.availableThemes = [ this.availableThemes = [
{ id: 'instance-default', label: $localize`${this.instanceName} theme`, description: this.getDefaultInstanceThemeLabel() }, { id: 'instance-default', label: $localize`${this.instanceName} theme`, description: this.getDefaultInstanceThemeLabel() },
@ -48,14 +68,15 @@ export class UserInterfaceSettingsComponent extends FormReactive implements OnIn
...this.themeService.buildAvailableThemes() ...this.themeService.buildAvailableThemes()
] ]
this.buildForm({ this.availableLanguages = Object.entries(I18N_LOCALES).map(([ id, label ]) => ({ label, id }))
theme: null
}) this.buildForm()
this.userInformationLoaded() this.userInformationLoaded()
.subscribe(() => { .subscribe(() => {
this.form.patchValue({ this.form.patchValue({
theme: this.user().theme theme: this.user().theme,
language: this.user().language
}) })
if (this.reactiveUpdate()) { if (this.reactiveUpdate()) {
@ -63,23 +84,57 @@ export class UserInterfaceSettingsComponent extends FormReactive implements OnIn
} }
}) })
} }
private buildForm () {
const obj: BuildFormArgumentTyped<Form> = {
theme: null,
language: null
}
const {
form,
formErrors,
validationMessages
} = this.formReactiveService.buildForm<Form>(obj)
this.form = form
this.formErrors = formErrors
this.validationMessages = validationMessages
}
ngOnDestroy () { ngOnDestroy () {
this.formValuesWatcher?.unsubscribe() this.formValuesWatcher?.unsubscribe()
} }
// ---------------------------------------------------------------------------
updateInterfaceSettings () { updateInterfaceSettings () {
const theme = this.form.value['theme'] if (this.updating) return
this.updating = true
const { theme, language } = this.form.value
const details: UserUpdateMe = { const details: UserUpdateMe = {
theme theme,
language
} }
const changedLanguage = language !== this.initialUserLanguage
const changeLanguageObs = changedLanguage
? this.userService.updateInterfaceLanguage(details.language)
: of(true)
if (this.authService.isLoggedIn()) { if (this.authService.isLoggedIn()) {
this.userService.updateMyProfile(details) this.userService.updateMyProfile(details)
.pipe(switchMap(() => changeLanguageObs))
.subscribe({ .subscribe({
next: () => { next: () => {
if (changedLanguage) {
window.location.reload()
return
}
this.authService.refreshUserInformation() this.authService.refreshUserInformation()
this.updating = false
if (this.notifyOnUpdate()) this.notifier.success($localize`Interface settings updated.`) if (this.notifyOnUpdate()) this.notifier.success($localize`Interface settings updated.`)
}, },
@ -91,7 +146,26 @@ export class UserInterfaceSettingsComponent extends FormReactive implements OnIn
} }
this.userService.updateMyAnonymousProfile(details) this.userService.updateMyAnonymousProfile(details)
if (changedLanguage) {
changeLanguageObs.subscribe({
next: () => {
window.location.reload()
},
error: err => this.notifier.error(err.message)
})
return
}
if (this.notifyOnUpdate()) this.notifier.success($localize`Interface settings updated.`) if (this.notifyOnUpdate()) this.notifier.success($localize`Interface settings updated.`)
this.updating = false
}
getSubmitValue () {
return $localize`Save interface settings`
// return $localize`Save and reload the interface`
} }
private getDefaultInstanceThemeLabel () { private getDefaultInstanceThemeLabel () {

View file

@ -1,8 +1,8 @@
import { NgIf } from '@angular/common' import { NgIf } from '@angular/common'
import { Component, OnDestroy, OnInit, inject, input } from '@angular/core' import { Component, OnDestroy, OnInit, booleanAttribute, inject, input } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { AuthService, Notifier, ServerService, User, UserService } from '@app/core' import { AuthService, Notifier, ServerService, User, UserService } from '@app/core'
import { FormReactiveErrors, FormReactiveService, FormReactiveMessages } from '@app/shared/shared-forms/form-reactive.service' import { FormReactiveErrors, FormReactiveMessages, FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { NSFWFlag, NSFWFlagType, NSFWPolicyType, UserUpdateMe } from '@peertube/peertube-models' import { NSFWFlag, NSFWFlagType, NSFWPolicyType, UserUpdateMe } from '@peertube/peertube-models'
import { pick } from 'lodash-es' import { pick } from 'lodash-es'
import { Subject, Subscription } from 'rxjs' import { Subject, Subscription } from 'rxjs'
@ -49,8 +49,8 @@ export class UserVideoSettingsComponent implements OnInit, OnDestroy {
private serverService = inject(ServerService) private serverService = inject(ServerService)
readonly user = input<User>(null) readonly user = input<User>(null)
readonly reactiveUpdate = input(false) readonly reactiveUpdate = input(false, { transform: booleanAttribute })
readonly notifyOnUpdate = input(true) readonly notifyOnUpdate = input(true, { transform: booleanAttribute })
readonly userInformationLoaded = input<Subject<any>>(undefined) readonly userInformationLoaded = input<Subject<any>>(undefined)
form: FormGroup<Form> form: FormGroup<Form>

View file

@ -1,5 +1,5 @@
import { APP_BASE_HREF, registerLocaleData } from '@angular/common' import { APP_BASE_HREF, registerLocaleData } from '@angular/common'
import { provideHttpClient } from '@angular/common/http' import { provideHttpClient, withInterceptors } from '@angular/common/http'
import { import {
ApplicationRef, ApplicationRef,
enableProdMode, enableProdMode,
@ -15,6 +15,7 @@ import { ServiceWorkerModule } from '@angular/service-worker'
import { PTPrimeTheme } from '@app/core/theme/primeng/primeng-theme' import { PTPrimeTheme } from '@app/core/theme/primeng/primeng-theme'
import localeOc from '@app/helpers/locales/oc' import localeOc from '@app/helpers/locales/oc'
import { getFormProviders } from '@app/shared/shared-forms/shared-form-providers' import { getFormProviders } from '@app/shared/shared-forms/shared-form-providers'
import { languageInterceptor } from '@app/shared/shared-main/http/language-interceptor.service'
import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap' import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
import { LoadingBarModule } from '@ngx-loading-bar/core' import { LoadingBarModule } from '@ngx-loading-bar/core'
import { LoadingBarHttpClientModule } from '@ngx-loading-bar/http-client' import { LoadingBarHttpClientModule } from '@ngx-loading-bar/http-client'
@ -76,7 +77,9 @@ const bootstrap = () =>
ServiceWorkerModule.register('ngsw-worker.js', { enabled: environment.production }) ServiceWorkerModule.register('ngsw-worker.js', { enabled: environment.production })
), ),
provideHttpClient(), provideHttpClient(
withInterceptors([ languageInterceptor ])
),
importProvidersFrom( importProvidersFrom(
LoadingBarHttpClientModule, LoadingBarHttpClientModule,

View file

@ -1,7 +1,7 @@
@use '_icons' as *; @use "_icons" as *;
@use '_variables' as *; @use "_variables" as *;
@use '_mixins' as *; @use "_mixins" as *;
@use '_button-mixins' as *; @use "_button-mixins" as *;
.no-results { .no-results {
height: 40vh; height: 40vh;
@ -68,13 +68,12 @@
.anchor { .anchor {
position: relative; position: relative;
top: -calc(#{pvar(--header-height)} + 20px); top: calc((#{pvar(--header-height)} + 20px) * -1);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
.alert { .alert {
&.pt-alert-primary { &.pt-alert-primary {
color: pvar(--alert-primary-fg); color: pvar(--alert-primary-fg);
background-color: pvar(--alert-primary-bg); background-color: pvar(--alert-primary-bg);

View file

@ -62,7 +62,7 @@ export class PeerTubeEmbed {
constructor (videoWrapperId: string) { constructor (videoWrapperId: string) {
logger.registerServerSending(getBackendUrl()) logger.registerServerSending(getBackendUrl())
this.http = new AuthHTTP(getBackendUrl()) this.http = new AuthHTTP(getBackendUrl(), navigator.language)
this.videoFetcher = new VideoFetcher(this.http) this.videoFetcher = new VideoFetcher(this.http)
this.playlistFetcher = new PlaylistFetcher(this.http) this.playlistFetcher = new PlaylistFetcher(this.http)

View file

@ -12,7 +12,7 @@ export class AuthHTTP {
private headers = new Headers() private headers = new Headers()
constructor (private readonly serverUrl: string) { constructor (private readonly serverUrl: string, private readonly language: string) {
this.userOAuthTokens = OAuthUserTokens.getUserTokens(peertubeLocalStorage) this.userOAuthTokens = OAuthUserTokens.getUserTokens(peertubeLocalStorage)
if (this.userOAuthTokens) this.setHeadersFromTokens() if (this.userOAuthTokens) this.setHeadersFromTokens()
@ -22,6 +22,8 @@ export class AuthHTTP {
let refreshFetchOptions: { headers?: Headers } = {} let refreshFetchOptions: { headers?: Headers } = {}
if (isSameOrigin(this.serverUrl, url)) { if (isSameOrigin(this.serverUrl, url)) {
if (this.language) this.headers.set('x-peertube-language', this.language)
if (videoPassword) this.headers.set('x-peertube-video-password', videoPassword) if (videoPassword) this.headers.set('x-peertube-video-password', videoPassword)
if (videoPassword || optionalAuth) refreshFetchOptions = { headers: this.headers } if (videoPassword || optionalAuth) refreshFetchOptions = { headers: this.headers }

View file

@ -893,6 +893,10 @@ instance:
# Example: '2 vCore, 2GB RAM...' # Example: '2 vCore, 2GB RAM...'
hardware_information: '' # Supports Markdown hardware_information: '' # Supports Markdown
# Default language of your instance, used in emails for example
# The web interface still uses the web browser preferred language
default_language: 'en'
# Describe the languages spoken on your instance, to interact with your users for example # Describe the languages spoken on your instance, to interact with your users for example
# Uncomment or add the languages you want # Uncomment or add the languages you want
# List of supported languages: https://peertube.cpy.re/api/v1/videos/languages # List of supported languages: https://peertube.cpy.re/api/v1/videos/languages

View file

@ -557,7 +557,7 @@ signup:
user: user:
history: history:
videos: videos:
# Enable or disable video history by default for new users. # Enable or disable video history by default for new users
enabled: true enabled: true
# Default value of maximum video bytes the user can upload # Default value of maximum video bytes the user can upload
@ -903,6 +903,10 @@ instance:
# Example: '2 vCore, 2GB RAM...' # Example: '2 vCore, 2GB RAM...'
hardware_information: '' # Supports Markdown hardware_information: '' # Supports Markdown
# Default language of your instance, used in emails for example
# The web interface still uses the web browser preferred language
default_language: 'en'
# Describe the languages spoken on your instance, to interact with your users for example # Describe the languages spoken on your instance, to interact with your users for example
# Uncomment or add the languages you want # Uncomment or add the languages you want
# List of supported languages: https://peertube.cpy.re/api/v1/videos/languages # List of supported languages: https://peertube.cpy.re/api/v1/videos/languages

View file

@ -13,7 +13,8 @@ export default defineConfig([
'packages/types-generator', 'packages/types-generator',
'*.js', '*.js',
'client', 'client',
'dist' 'dist',
'server/.i18next-parser.config.ts'
]), ]),
{ {

View file

@ -146,6 +146,9 @@
"got-ssrf": "^3.0.0", "got-ssrf": "^3.0.0",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"http-problem-details": "^0.1.5", "http-problem-details": "^0.1.5",
"i18next": "^25.3.2",
"i18next-icu": "^2.3.0",
"intl-messageformat": "^10.7.16",
"ioredis": "^5.2.3", "ioredis": "^5.2.3",
"ip-anonymize": "^0.1.0", "ip-anonymize": "^0.1.0",
"ipaddr.js": "2.2.0", "ipaddr.js": "2.2.0",
@ -240,8 +243,11 @@
"eslint": "^9.26.0", "eslint": "^9.26.0",
"eslint-config-love": "^119.0.0", "eslint-config-love": "^119.0.0",
"fast-xml-parser": "^5.2.2", "fast-xml-parser": "^5.2.2",
"handlebars": "^4.7.8",
"i18next-parser": "^9.3.0",
"jpeg-js": "^0.4.4", "jpeg-js": "^0.4.4",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"maildev": "^2.2.1",
"mocha": "^11.1.0", "mocha": "^11.1.0",
"pixelmatch": "^7.1.0", "pixelmatch": "^7.1.0",
"pngjs": "^7.0.0", "pngjs": "^7.0.0",

View file

@ -79,7 +79,7 @@ const I18N_LOCALE_ALIAS = {
'zh': 'zh-Hans-CN' 'zh': 'zh-Hans-CN'
} }
export const POSSIBLE_LOCALES = Object.keys(I18N_LOCALES).concat(Object.keys(I18N_LOCALE_ALIAS)) export const AVAILABLE_LOCALES = Object.keys(I18N_LOCALES).concat(Object.keys(I18N_LOCALE_ALIAS))
export function getDefaultLocale () { export function getDefaultLocale () {
return 'en-US' return 'en-US'
@ -95,13 +95,13 @@ export function peertubeTranslate (str: string, translations?: { [id: string]: s
return translations[str] return translations[str]
} }
const possiblePaths = POSSIBLE_LOCALES.map(l => '/' + l) const possiblePaths = AVAILABLE_LOCALES.map(l => '/' + l)
export function is18nPath (path: string) { export function is18nPath (path: string) {
return possiblePaths.includes(path) return possiblePaths.includes(path)
} }
export function is18nLocale (locale: string) { export function is18nLocale (locale: string) {
return POSSIBLE_LOCALES.includes(locale) return AVAILABLE_LOCALES.includes(locale)
} }
export function getCompleteLocale (locale: string) { export function getCompleteLocale (locale: string) {

View file

@ -42,6 +42,16 @@ export function getDefaultSanitizeOptions () {
} }
} }
export function getMailHtmlSanitizeOptions () {
return {
allowedTags: [ 'a', 'strong' ],
allowedSchemes: getDefaultSanitizedSchemes(),
allowedAttributes: {
a: [ 'href', 'title' ]
}
}
}
export function getTextOnlySanitizeOptions () { export function getTextOnlySanitizeOptions () {
return { return {
allowedTags: [] as string[] allowedTags: [] as string[]
@ -56,7 +66,7 @@ export function getTextOnlySanitizeOptions () {
export function escapeHTML (stringParam: string) { export function escapeHTML (stringParam: string) {
if (!stringParam) return '' if (!stringParam) return ''
const entityMap: { [id: string ]: string } = { const entityMap: { [id: string]: string } = {
'&': '&amp;', '&': '&amp;',
'<': '&lt;', '<': '&lt;',
'>': '&gt;', '>': '&gt;',

View file

@ -15,6 +15,7 @@ export interface UserSettingsExportJSON {
videosHistoryEnabled: boolean videosHistoryEnabled: boolean
videoLanguages: string[] videoLanguages: string[]
language: string
theme: string theme: string

View file

@ -29,6 +29,8 @@ export interface CustomConfig {
businessModel: string businessModel: string
hardwareInformation: string hardwareInformation: string
defaultLanguage: string
languages: string[] languages: string[]
categories: number[] categories: number[]

View file

@ -3,11 +3,17 @@ export interface Debug {
activityPubMessagesWaiting: number activityPubMessagesWaiting: number
} }
export interface SendDebugCommand { export type SendDebugCommand = {
command: 'remove-dandling-resumable-uploads' command:
| 'process-video-views-buffer' | 'remove-dandling-resumable-uploads'
| 'process-video-viewers' | 'process-video-views-buffer'
| 'process-video-channel-sync-latest' | 'process-video-viewers'
| 'process-update-videos-scheduler' | 'process-video-channel-sync-latest'
| 'remove-expired-user-exports' | 'process-update-videos-scheduler'
| 'remove-expired-user-exports'
} | SendDebugTestEmails
export type SendDebugTestEmails = {
command: 'test-emails'
email: string
} }

View file

@ -1,7 +1,12 @@
export type To = { email: string, language: string }
type From = string | { name?: string, address: string } type From = string | { name?: string, address: string }
interface Base extends Partial<SendEmailDefaultMessageOptions> { interface Base {
to: string[] | string to: To[] | To
from?: From
subject?: string
replyTo?: string
} }
interface MailTemplate extends Base { interface MailTemplate extends Base {
@ -30,6 +35,8 @@ interface SendEmailDefaultLocalsOptions {
fg: string fg: string
bg: string bg: string
primary: string primary: string
language: string
logoUrl: string
} }
interface SendEmailDefaultMessageOptions { interface SendEmailDefaultMessageOptions {

View file

@ -137,6 +137,8 @@ export interface ServerConfig {
avatars: ActorImage[] avatars: ActorImage[]
banners: ActorImage[] banners: ActorImage[]
defaultLanguage: string
logo: { logo: {
type: LogoType type: LogoType
width: number width: number

View file

@ -17,6 +17,7 @@ export interface UserUpdateMe {
autoPlayNextVideoPlaylist?: boolean autoPlayNextVideoPlaylist?: boolean
videosHistoryEnabled?: boolean videosHistoryEnabled?: boolean
videoLanguages?: string[] videoLanguages?: string[]
language?: string
email?: string email?: string
emailPublic?: boolean emailPublic?: boolean

View file

@ -31,6 +31,7 @@ export interface User {
videosHistoryEnabled: boolean videosHistoryEnabled: boolean
videoLanguages: string[] videoLanguages: string[]
language: string
role: { role: {
id: UserRoleType id: UserRoleType

View file

@ -18,10 +18,11 @@ import { unwrapBody } from '../requests/index.js'
import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
export class UsersCommand extends AbstractCommand { export class UsersCommand extends AbstractCommand {
askResetPassword (
askResetPassword (options: OverrideCommandOptions & { options: OverrideCommandOptions & {
email: string email: string
}) { }
) {
const { email } = options const { email } = options
const path = '/api/v1/users/ask-reset-password' const path = '/api/v1/users/ask-reset-password'
@ -35,11 +36,13 @@ export class UsersCommand extends AbstractCommand {
}) })
} }
resetPassword (options: OverrideCommandOptions & { resetPassword (
userId: number options: OverrideCommandOptions & {
verificationString: string userId: number
password: string verificationString: string
}) { password: string
}
) {
const { userId, verificationString, password } = options const { userId, verificationString, password } = options
const path = '/api/v1/users/' + userId + '/reset-password' const path = '/api/v1/users/' + userId + '/reset-password'
@ -55,9 +58,11 @@ export class UsersCommand extends AbstractCommand {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
askSendVerifyEmail (options: OverrideCommandOptions & { askSendVerifyEmail (
email: string options: OverrideCommandOptions & {
}) { email: string
}
) {
const { email } = options const { email } = options
const path = '/api/v1/users/ask-send-verify-email' const path = '/api/v1/users/ask-send-verify-email'
@ -71,11 +76,13 @@ export class UsersCommand extends AbstractCommand {
}) })
} }
verifyEmail (options: OverrideCommandOptions & { verifyEmail (
userId: number options: OverrideCommandOptions & {
verificationString: string userId: number
isPendingEmail?: boolean // default false verificationString: string
}) { isPendingEmail?: boolean // default false
}
) {
const { userId, verificationString, isPendingEmail = false } = options const { userId, verificationString, isPendingEmail = false } = options
const path = '/api/v1/users/' + userId + '/verify-email' const path = '/api/v1/users/' + userId + '/verify-email'
@ -94,10 +101,12 @@ export class UsersCommand extends AbstractCommand {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
banUser (options: OverrideCommandOptions & { banUser (
userId: number options: OverrideCommandOptions & {
reason?: string userId: number
}) { reason?: string
}
) {
const { userId, reason } = options const { userId, reason } = options
const path = '/api/v1/users' + '/' + userId + '/block' const path = '/api/v1/users' + '/' + userId + '/block'
@ -111,9 +120,11 @@ export class UsersCommand extends AbstractCommand {
}) })
} }
unbanUser (options: OverrideCommandOptions & { unbanUser (
userId: number options: OverrideCommandOptions & {
}) { userId: number
}
) {
const { userId } = options const { userId } = options
const path = '/api/v1/users' + '/' + userId + '/unblock' const path = '/api/v1/users' + '/' + userId + '/unblock'
@ -154,15 +165,17 @@ export class UsersCommand extends AbstractCommand {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
create (options: OverrideCommandOptions & { create (
username: string options: OverrideCommandOptions & {
password?: string username: string
videoQuota?: number password?: string
videoQuotaDaily?: number videoQuota?: number
role?: UserRoleType videoQuotaDaily?: number
adminFlags?: UserAdminFlagType role?: UserRoleType
email?: string adminFlags?: UserAdminFlagType
}) { email?: string
}
) {
const { const {
username, username,
adminFlags, adminFlags,
@ -243,9 +256,11 @@ export class UsersCommand extends AbstractCommand {
}) })
} }
getMyRating (options: OverrideCommandOptions & { getMyRating (
videoId: number | string options: OverrideCommandOptions & {
}) { videoId: number | string
}
) {
const { videoId } = options const { videoId } = options
const path = '/api/v1/users/me/videos/' + videoId + '/rating' const path = '/api/v1/users/me/videos/' + videoId + '/rating'
@ -285,9 +300,11 @@ export class UsersCommand extends AbstractCommand {
}) })
} }
updateMyAvatar (options: OverrideCommandOptions & { updateMyAvatar (
fixture: string options: OverrideCommandOptions & {
}) { fixture: string
}
) {
const { fixture } = options const { fixture } = options
const path = '/api/v1/users/me/avatar/pick' const path = '/api/v1/users/me/avatar/pick'
@ -305,10 +322,12 @@ export class UsersCommand extends AbstractCommand {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
get (options: OverrideCommandOptions & { get (
userId: number options: OverrideCommandOptions & {
withStats?: boolean // default false userId: number
}) { withStats?: boolean // default false
}
) {
const { userId, withStats } = options const { userId, withStats } = options
const path = '/api/v1/users/' + userId const path = '/api/v1/users/' + userId
@ -341,9 +360,11 @@ export class UsersCommand extends AbstractCommand {
}) })
} }
remove (options: OverrideCommandOptions & { remove (
userId: number options: OverrideCommandOptions & {
}) { userId: number
}
) {
const { userId } = options const { userId } = options
const path = '/api/v1/users/' + userId const path = '/api/v1/users/' + userId
@ -356,17 +377,19 @@ export class UsersCommand extends AbstractCommand {
}) })
} }
update (options: OverrideCommandOptions & { update (
userId: number options: OverrideCommandOptions & {
email?: string userId: number
emailVerified?: boolean email?: string
videoQuota?: number emailVerified?: boolean
videoQuotaDaily?: number videoQuota?: number
password?: string videoQuotaDaily?: number
adminFlags?: UserAdminFlagType password?: string
pluginAuth?: string adminFlags?: UserAdminFlagType
role?: UserRoleType pluginAuth?: string
}) { role?: UserRoleType
}
) {
const path = '/api/v1/users/' + options.userId const path = '/api/v1/users/' + options.userId
const toSend: UserUpdate = {} const toSend: UserUpdate = {}
@ -388,4 +411,24 @@ export class UsersCommand extends AbstractCommand {
defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
}) })
} }
// ---------------------------------------------------------------------------
updateInterfaceLanguage (
options: OverrideCommandOptions & {
language: string
}
) {
const { language } = options
const path = '/api/v1/client-config/update-interface-language'
return this.postBodyRequest({
...options,
path,
fields: { language },
implicitToken: false,
defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
})
}
} }

View file

@ -1,6 +1,5 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { MockSmtpServer } from '@tests/shared/mock-servers/index.js'
import { HttpStatusCode } from '@peertube/peertube-models' import { HttpStatusCode } from '@peertube/peertube-models'
import { import {
cleanupTests, cleanupTests,
@ -10,6 +9,7 @@ import {
killallServers, killallServers,
PeerTubeServer PeerTubeServer
} from '@peertube/peertube-server-commands' } from '@peertube/peertube-server-commands'
import { MockSmtpServer } from '@tests/shared/mock-servers/index.js'
describe('Test contact form API validators', function () { describe('Test contact form API validators', function () {
let server: PeerTubeServer let server: PeerTubeServer
@ -79,7 +79,7 @@ describe('Test contact form API validators', function () {
}) })
after(async function () { after(async function () {
MockSmtpServer.Instance.kill() await MockSmtpServer.Instance.kill()
await cleanupTests([ server ]) await cleanupTests([ server ])
}) })

View file

@ -253,6 +253,12 @@ describe('Test my user API validators', function () {
} }
}) })
it('Should fail with an invalid language attribute', async function () {
const fields = { language: 'toto' }
await makePutBodyRequest({ url: server.url, path: path + 'me', token: userToken, fields })
})
it('Should fail with an invalid theme', async function () { it('Should fail with an invalid theme', async function () {
const fields = { theme: 'invalid' } const fields = { theme: 'invalid' }
await makePutBodyRequest({ url: server.url, path: path + 'me', token: userToken, fields }) await makePutBodyRequest({ url: server.url, path: path + 'me', token: userToken, fields })
@ -293,7 +299,8 @@ describe('Test my user API validators', function () {
theme: 'default', theme: 'default',
noInstanceConfigWarningModal: true, noInstanceConfigWarningModal: true,
noWelcomeModal: true, noWelcomeModal: true,
noAccountSetupWarningModal: true noAccountSetupWarningModal: true,
language: 'fr'
} }
await makePutBodyRequest({ await makePutBodyRequest({
@ -545,6 +552,22 @@ describe('Test my user API validators', function () {
}) })
}) })
describe('Client config', function () {
it('Should fail with an invalid language', async function () {
await server.users.updateInterfaceLanguage({
language: 'hello',
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
})
it('Should succeed to update language with the correct params', async function () {
await server.users.updateInterfaceLanguage({
language: 'fr',
expectedStatus: HttpStatusCode.NO_CONTENT_204
})
})
})
describe('When deleting our account', function () { describe('When deleting our account', function () {
it('Should fail with with the root account', async function () { it('Should fail with with the root account', async function () {
await server.users.deleteMe({ expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) await server.users.deleteMe({ expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
@ -552,7 +575,7 @@ describe('Test my user API validators', function () {
}) })
after(async function () { after(async function () {
MockSmtpServer.Instance.kill() await MockSmtpServer.Instance.kill()
await cleanupTests([ server ]) await cleanupTests([ server ])
}) })

View file

@ -479,7 +479,7 @@ describe('Test users admin API validators', function () {
}) })
after(async function () { after(async function () {
MockSmtpServer.Instance.kill() await MockSmtpServer.Instance.kill()
await cleanupTests([ server ]) await cleanupTests([ server ])
}) })

View file

@ -60,7 +60,6 @@ describe('Test admin notifications', function () {
}) })
describe('Latest PeerTube version notification', function () { describe('Latest PeerTube version notification', function () {
it('Should not send a notification to admins if there is no new version', async function () { it('Should not send a notification to admins if there is no new version', async function () {
this.timeout(30000) this.timeout(30000)
@ -104,7 +103,6 @@ describe('Test admin notifications', function () {
}) })
describe('Latest plugin version notification', function () { describe('Latest plugin version notification', function () {
it('Should not send a notification to admins if there is no new plugin version', async function () { it('Should not send a notification to admins if there is no new plugin version', async function () {
this.timeout(30000) this.timeout(30000)
@ -146,7 +144,7 @@ describe('Test admin notifications', function () {
}) })
after(async function () { after(async function () {
MockSmtpServer.Instance.kill() await MockSmtpServer.Instance.kill()
await sqlCommand.cleanup() await sqlCommand.cleanup()
await cleanupTests([ server ]) await cleanupTests([ server ])

View file

@ -3,11 +3,7 @@
import { UserNotification } from '@peertube/peertube-models' import { UserNotification } from '@peertube/peertube-models'
import { PeerTubeServer, cleanupTests, waitJobs } from '@peertube/peertube-server-commands' import { PeerTubeServer, cleanupTests, waitJobs } from '@peertube/peertube-server-commands'
import { MockSmtpServer } from '@tests/shared/mock-servers/mock-email.js' import { MockSmtpServer } from '@tests/shared/mock-servers/mock-email.js'
import { import { CheckerBaseParams, checkMyVideoTranscriptionGenerated, prepareNotificationsTest } from '@tests/shared/notifications.js'
CheckerBaseParams,
checkMyVideoTranscriptionGenerated,
prepareNotificationsTest
} from '@tests/shared/notifications.js'
import { join } from 'path' import { join } from 'path'
describe('Test caption notifications', function () { describe('Test caption notifications', function () {
@ -74,7 +70,7 @@ describe('Test caption notifications', function () {
}) })
after(async function () { after(async function () {
MockSmtpServer.Instance.kill() await MockSmtpServer.Instance.kill()
await cleanupTests(servers) await cleanupTests(servers)
}) })

View file

@ -15,7 +15,7 @@ describe('Test comments notifications', function () {
const commentText = '**hello** <a href="https://joinpeertube.org">world</a>, <h1>what do you think about peertube?</h1>' const commentText = '**hello** <a href="https://joinpeertube.org">world</a>, <h1>what do you think about peertube?</h1>'
const expectedHtml = '<strong>hello</strong> <a href="https://joinpeertube.org" target="_blank" rel="noopener noreferrer">world</a>' + const expectedHtml = '<strong>hello</strong> <a href="https://joinpeertube.org" target="_blank" rel="noopener noreferrer">world</a>' +
', </p>what do you think about peertube?' ', </p>what do you think about peertube?'
before(async function () { before(async function () {
this.timeout(120000) this.timeout(120000)
@ -392,7 +392,7 @@ describe('Test comments notifications', function () {
}) })
after(async function () { after(async function () {
MockSmtpServer.Instance.kill() await MockSmtpServer.Instance.kill()
await cleanupTests(servers) await cleanupTests(servers)
}) })

View file

@ -1,5 +1,5 @@
import './admin-notifications.js' import './admin-notifications.js'
import './captions-notifications.js' import './caption-notifications.js'
import './comments-notifications.js' import './comments-notifications.js'
import './moderation-notifications.js' import './moderation-notifications.js'
import './notifications-api.js' import './notifications-api.js'

View file

@ -427,7 +427,6 @@ describe('Test moderation notifications', function () {
let videoName: string let videoName: string
before(async function () { before(async function () {
adminBaseParamsServer1 = { adminBaseParamsServer1 = {
server: servers[0], server: servers[0],
emails, emails,
@ -583,7 +582,7 @@ describe('Test moderation notifications', function () {
}) })
after(async function () { after(async function () {
MockSmtpServer.Instance.kill() await MockSmtpServer.Instance.kill()
await cleanupTests(servers) await cleanupTests(servers)
}) })

View file

@ -36,7 +36,6 @@ describe('Test notifications API', function () {
}) })
describe('Notification list & count', function () { describe('Notification list & count', function () {
it('Should correctly list notifications', async function () { it('Should correctly list notifications', async function () {
const { data, total } = await server.notifications.list({ token: userToken, start: 0, count: 2 }) const { data, total } = await server.notifications.list({ token: userToken, start: 0, count: 2 })
@ -74,7 +73,6 @@ describe('Test notifications API', function () {
}) })
describe('Mark as read', function () { describe('Mark as read', function () {
it('Should mark as read some notifications', async function () { it('Should mark as read some notifications', async function () {
const { data } = await server.notifications.list({ token: userToken, start: 2, count: 3 }) const { data } = await server.notifications.list({ token: userToken, start: 2, count: 3 })
const ids = data.map(n => n.id) const ids = data.map(n => n.id)
@ -227,7 +225,7 @@ describe('Test notifications API', function () {
}) })
after(async function () { after(async function () {
MockSmtpServer.Instance.kill() await MockSmtpServer.Instance.kill()
await cleanupTests([ server ]) await cleanupTests([ server ])
}) })

View file

@ -35,7 +35,6 @@ describe('Test registrations notifications', function () {
}) })
describe('New direct registration for moderators', function () { describe('New direct registration for moderators', function () {
before(async function () { before(async function () {
await server.config.enableSignup(false) await server.config.enableSignup(false)
}) })
@ -55,7 +54,6 @@ describe('Test registrations notifications', function () {
}) })
describe('New registration request for moderators', function () { describe('New registration request for moderators', function () {
before(async function () { before(async function () {
await server.config.enableSignup(true) await server.config.enableSignup(true)
}) })
@ -76,7 +74,7 @@ describe('Test registrations notifications', function () {
}) })
after(async function () { after(async function () {
MockSmtpServer.Instance.kill() await MockSmtpServer.Instance.kill()
await cleanupTests([ server ]) await cleanupTests([ server ])
}) })

View file

@ -396,7 +396,6 @@ describe('Test user notifications', function () {
}) })
describe('My live replay is published', function () { describe('My live replay is published', function () {
let baseParams: CheckerBaseParams let baseParams: CheckerBaseParams
before(() => { before(() => {
@ -640,7 +639,7 @@ describe('Test user notifications', function () {
}) })
after(async function () { after(async function () {
MockSmtpServer.Instance.kill() await MockSmtpServer.Instance.kill()
await cleanupTests(servers) await cleanupTests(servers)
}) })

View file

@ -32,6 +32,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
expect(data.instance.hardwareInformation).to.be.empty expect(data.instance.hardwareInformation).to.be.empty
expect(data.instance.serverCountry).to.be.empty expect(data.instance.serverCountry).to.be.empty
expect(data.instance.support.text).to.be.empty expect(data.instance.support.text).to.be.empty
expect(data.instance.defaultLanguage).to.equal('en')
expect(data.instance.social.externalLink).to.be.empty expect(data.instance.social.externalLink).to.be.empty
expect(data.instance.social.blueskyLink).to.be.empty expect(data.instance.social.blueskyLink).to.be.empty
expect(data.instance.social.mastodonLink).to.be.empty expect(data.instance.social.mastodonLink).to.be.empty
@ -189,6 +190,7 @@ function buildNewCustomConfig (server: PeerTubeServer): CustomConfig {
support: { support: {
text: 'My support text' text: 'My support text'
}, },
defaultLanguage: 'fr',
social: { social: {
externalLink: 'https://joinpeertube.org/', externalLink: 'https://joinpeertube.org/',
mastodonLink: 'https://framapiaf.org/@peertube', mastodonLink: 'https://framapiaf.org/@peertube',
@ -984,9 +986,9 @@ describe('Test config', function () {
expect(body.short_name).to.equal(body.name) expect(body.short_name).to.equal(body.name)
expect(body.description).to.equal('description manifest') expect(body.description).to.equal('description manifest')
const icon = body.icons.find(f => f.sizes === '36x36') const icon = body.icons.find(f => f.sizes === '192x192')
expect(icon).to.exist expect(icon).to.exist
expect(icon.src).to.equal('/client/assets/images/icons/icon-36x36.png') expect(icon.src).to.equal('/client/assets/images/icons/icon-192x192.png')
}) })
it('Should generate the manifest with avatar', async function () { it('Should generate the manifest with avatar', async function () {

View file

@ -94,7 +94,7 @@ describe('Test contact form', function () {
}) })
after(async function () { after(async function () {
MockSmtpServer.Instance.kill() await MockSmtpServer.Instance.kill()
await cleanupTests([ server ]) await cleanupTests([ server ])
}) })

View file

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { HttpStatusCode } from '@peertube/peertube-models' import { HttpStatusCode, VideoCreateResult } from '@peertube/peertube-models'
import { import {
cleanupTests, cleanupTests,
ConfigCommand, ConfigCommand,
@ -12,7 +12,9 @@ import {
import { expectStartWith } from '@tests/shared/checks.js' import { expectStartWith } from '@tests/shared/checks.js'
import { MockSmtpServer } from '@tests/shared/mock-servers/index.js' import { MockSmtpServer } from '@tests/shared/mock-servers/index.js'
import { SQLCommand } from '@tests/shared/sql-command.js' import { SQLCommand } from '@tests/shared/sql-command.js'
import { expect } from 'chai' import { config, expect } from 'chai'
config.truncateThreshold = 0
describe('Test emails', function () { describe('Test emails', function () {
let server: PeerTubeServer let server: PeerTubeServer
@ -256,7 +258,7 @@ describe('Test emails', function () {
expect(email['from'][0]['name']).equal('PeerTube') expect(email['from'][0]['name']).equal('PeerTube')
expect(email['from'][0]['address']).equal('test-admin@127.0.0.1') expect(email['from'][0]['address']).equal('test-admin@127.0.0.1')
expect(email['to'][0]['address']).equal('user_1@example.com') expect(email['to'][0]['address']).equal('user_1@example.com')
expect(email['subject']).contains(' blacklisted') expect(email['subject']).contains(' blocked')
expect(email['text']).contains('my super user video') expect(email['text']).contains('my super user video')
expect(email['text']).contains('my super reason') expect(email['text']).contains('my super reason')
}) })
@ -272,7 +274,7 @@ describe('Test emails', function () {
expect(email['from'][0]['name']).equal('PeerTube') expect(email['from'][0]['name']).equal('PeerTube')
expect(email['from'][0]['address']).equal('test-admin@127.0.0.1') expect(email['from'][0]['address']).equal('test-admin@127.0.0.1')
expect(email['to'][0]['address']).equal('user_1@example.com') expect(email['to'][0]['address']).equal('user_1@example.com')
expect(email['subject']).contains(' unblacklisted') expect(email['subject']).contains(' unblocked')
expect(email['text']).contains('my super user video') expect(email['text']).contains('my super user video')
}) })
@ -412,8 +414,71 @@ describe('Test emails', function () {
}) })
}) })
describe('Email translations', function () {
let video: VideoCreateResult
before(async function () {
video = await server.videos.quickUpload({ name: 'video' })
await server.config.updateExistingConfig({
newConfig: {
instance: {
defaultLanguage: 'fr'
}
}
})
})
it('Should translate emails according to the instance language', async function () {
await server.contactForm.send({
fromEmail: 'toto@example.com',
body: 'my super message',
subject: 'my subject',
fromName: 'Super toto'
})
await waitJobs(server)
const email = emails[emails.length - 1]
expect(email['subject']).to.contain('Formulaire de contact')
expect(email['text']).to.contain('Super toto vous a envoyé un message')
})
it('Should translate emails according to the instance language if not provided by the user', async function () {
await server.blacklist.add({ videoId: video.uuid })
await waitJobs(server)
const email = emails[emails.length - 1]
expect(email['subject']).to.contain('été bloquée')
expect(email['text']).to.contain('été bloquée')
})
it('Should translate emails according to the user language if provided', async function () {
await server.users.updateMe({ language: 'en' })
await server.blacklist.remove({ videoId: video.uuid })
await waitJobs(server)
const email = emails[emails.length - 1]
expect(email['subject']).to.contain('has been unblocked')
expect(email['text']).to.contain('has been unblocked')
})
it('Should update user language and translate emails accordingly', async function () {
await server.users.updateMe({ language: 'fr' })
await server.blacklist.add({ videoId: video.uuid })
await waitJobs(server)
const email = emails[emails.length - 1]
expect(email['subject']).to.contain('été bloquée')
expect(email['text']).to.contain('été bloquée')
})
})
after(async function () { after(async function () {
MockSmtpServer.Instance.kill() await MockSmtpServer.Instance.kill()
await cleanupTests([ server ]) await cleanupTests([ server ])
}) })

View file

@ -27,6 +27,13 @@ describe('Test registrations', function () {
await setAccessTokensToServers([ server ]) await setAccessTokensToServers([ server ])
await server.config.enableSignup(false) await server.config.enableSignup(false)
await server.config.updateExistingConfig({
newConfig: {
instance: {
defaultLanguage: 'fr'
}
}
})
}) })
describe('Direct registrations of a new user', function () { describe('Direct registrations of a new user', function () {
@ -210,8 +217,8 @@ describe('Test registrations', function () {
const email = emails.find(e => e['to'][0]['address'] === 'user4@example.com') const email = emails.find(e => e['to'][0]['address'] === 'user4@example.com')
expect(email).to.exist expect(email).to.exist
expect(email['subject']).to.contain('been rejected') expect(email['subject']).to.contain('été rejetée')
expect(email['text']).to.contain('been rejected') expect(email['text']).to.contain('été rejetée')
expect(email['text']).to.contain('I do not want id 4 on this instance') expect(email['text']).to.contain('I do not want id 4 on this instance')
}) })
@ -229,8 +236,8 @@ describe('Test registrations', function () {
const email = emails.find(e => e['to'][0]['address'] === 'user2@example.com') const email = emails.find(e => e['to'][0]['address'] === 'user2@example.com')
expect(email).to.exist expect(email).to.exist
expect(email['subject']).to.contain('been accepted') expect(email['subject']).to.contain('été acceptée')
expect(email['text']).to.contain('been accepted') expect(email['text']).to.contain('été acceptée')
expect(email['text']).to.contain('Welcome id 2') expect(email['text']).to.contain('Welcome id 2')
} }
@ -238,8 +245,8 @@ describe('Test registrations', function () {
const email = emails.find(e => e['to'][0]['address'] === 'user3@example.com') const email = emails.find(e => e['to'][0]['address'] === 'user3@example.com')
expect(email).to.exist expect(email).to.exist
expect(email['subject']).to.contain('been accepted') expect(email['subject']).to.contain('été acceptée')
expect(email['text']).to.contain('been accepted') expect(email['text']).to.contain('été acceptée')
expect(email['text']).to.contain('Welcome id 3') expect(email['text']).to.contain('Welcome id 3')
} }
}) })
@ -259,6 +266,7 @@ describe('Test registrations', function () {
expect(me.videoChannels[0].displayName).to.equal('Main user2 channel') expect(me.videoChannels[0].displayName).to.equal('Main user2 channel')
expect(me.role.id).to.equal(UserRole.USER) expect(me.role.id).to.equal(UserRole.USER)
expect(me.email).to.equal('user2@example.com') expect(me.email).to.equal('user2@example.com')
expect(me.language).to.equal('fr')
}) })
it('Should have created the appropriate attributes for user 3', async function () { it('Should have created the appropriate attributes for user 3', async function () {
@ -271,6 +279,7 @@ describe('Test registrations', function () {
expect(me.videoChannels[0].displayName).to.equal('my user 3 channel') expect(me.videoChannels[0].displayName).to.equal('my user 3 channel')
expect(me.role.id).to.equal(UserRole.USER) expect(me.role.id).to.equal(UserRole.USER)
expect(me.email).to.equal('user3@example.com') expect(me.email).to.equal('user3@example.com')
expect(me.language).to.equal('fr')
}) })
it('Should list these accepted/rejected registration requests', async function () { it('Should list these accepted/rejected registration requests', async function () {
@ -408,7 +417,7 @@ describe('Test registrations', function () {
}) })
after(async function () { after(async function () {
MockSmtpServer.Instance.kill() await MockSmtpServer.Instance.kill()
await cleanupTests([ server ]) await cleanupTests([ server ])
}) })

View file

@ -86,7 +86,6 @@ function runTest (withObjectStorage: boolean) {
objectStorage = withObjectStorage objectStorage = withObjectStorage
? new ObjectStorageCommand() ? new ObjectStorageCommand()
: undefined : undefined
;({ ;({
rootId, rootId,
noahId, noahId,
@ -906,7 +905,7 @@ function runTest (withObjectStorage: boolean) {
}) })
after(async function () { after(async function () {
MockSmtpServer.Instance.kill() await MockSmtpServer.Instance.kill()
await cleanupTests([ server, remoteServer ]) await cleanupTests([ server, remoteServer ])
}) })

View file

@ -170,6 +170,7 @@ function runTest (withObjectStorage: boolean) {
const me = await remoteServer.users.getMyInfo({ token: remoteNoahToken }) const me = await remoteServer.users.getMyInfo({ token: remoteNoahToken })
expect(me.p2pEnabled).to.be.false expect(me.p2pEnabled).to.be.false
expect(me.language).to.equal('fr')
const settings = me.notificationSettings const settings = me.notificationSettings
@ -593,11 +594,11 @@ function runTest (withObjectStorage: boolean) {
it('Should have received an email on finished import', async function () { it('Should have received an email on finished import', async function () {
const email = emails.reverse().find(e => { const email = emails.reverse().find(e => {
return e['to'][0]['address'] === 'noah_remote@example.com' && return e['to'][0]['address'] === 'noah_remote@example.com' &&
e['subject'].includes('archive import has finished') e['subject'].includes('importation de votre archive est terminée')
}) })
expect(email).to.exist expect(email).to.exist
expect(email['text']).to.contain('as considered duplicate: 5') // 5 videos are considered as duplicates expect(email['text']).to.contain('considéré comme doublon : 5') // 5 videos are considered as duplicates
}) })
it('Should auto blacklist imported videos if enabled by the administrator', async function () { it('Should auto blacklist imported videos if enabled by the administrator', async function () {
@ -715,7 +716,7 @@ function runTest (withObjectStorage: boolean) {
}) })
after(async function () { after(async function () {
MockSmtpServer.Instance.kill() await MockSmtpServer.Instance.kill()
await cleanupTests([ server, remoteServer, blockedServer ]) await cleanupTests([ server, remoteServer, blockedServer ])
}) })

View file

@ -158,7 +158,7 @@ describe('Test users email verification', function () {
}) })
after(async function () { after(async function () {
MockSmtpServer.Instance.kill() await MockSmtpServer.Instance.kill()
await cleanupTests([ server ]) await cleanupTests([ server ])
}) })

View file

@ -1,14 +1,9 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { expect } from 'chai'
import { testAvatarSize } from '@tests/shared/checks.js'
import { AbuseState, HttpStatusCode, UserAdminFlag, UserRole, VideoPlaylistType } from '@peertube/peertube-models' import { AbuseState, HttpStatusCode, UserAdminFlag, UserRole, VideoPlaylistType } from '@peertube/peertube-models'
import { import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@peertube/peertube-server-commands'
cleanupTests, import { testAvatarSize } from '@tests/shared/checks.js'
createSingleServer, import { expect } from 'chai'
PeerTubeServer,
setAccessTokensToServers
} from '@peertube/peertube-server-commands'
describe('Test users', function () { describe('Test users', function () {
let server: PeerTubeServer let server: PeerTubeServer
@ -60,6 +55,7 @@ describe('Test users', function () {
expect(user.id).to.be.a('number') expect(user.id).to.be.a('number')
expect(user.account.displayName).to.equal('user_1') expect(user.account.displayName).to.equal('user_1')
expect(user.account.description).to.be.null expect(user.account.description).to.be.null
expect(user.language).to.equal('en')
} }
expect(userMe.adminFlags).to.equal(UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST) expect(userMe.adminFlags).to.equal(UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST)
@ -334,6 +330,43 @@ describe('Test users', function () {
expect(user.noInstanceConfigWarningModal).to.be.true expect(user.noInstanceConfigWarningModal).to.be.true
expect(user.noAccountSetupWarningModal).to.be.true expect(user.noAccountSetupWarningModal).to.be.true
}) })
it('Should update instance config and automatically update user language', async function () {
{
const user = await server.users.getMyInfo({ token: userToken })
expect(user.videoLanguages).to.be.null
expect(user.language).to.equal('en')
}
{
await server.config.updateExistingConfig({
newConfig: {
instance: {
defaultLanguage: 'es'
}
}
})
}
{
const user = await server.users.getMyInfo({ token: userToken })
expect(user.language).to.equal('es')
}
})
it('Should be able to update my languages', async function () {
await server.users.updateMe({
token: userToken,
language: 'fr',
videoLanguages: [ 'fr', 'en' ]
})
{
const user = await server.users.getMyInfo({ token: userToken })
expect(user.language).to.equal('fr')
expect(user.videoLanguages).to.deep.equal([ 'fr', 'en' ])
}
})
}) })
describe('Updating another user', function () { describe('Updating another user', function () {
@ -527,6 +560,32 @@ describe('Test users', function () {
}) })
}) })
describe('Client config', function () {
it('Send a cookie on interface language change', async function () {
const res = await server.users.updateInterfaceLanguage({
language: 'fr',
expectedStatus: HttpStatusCode.NO_CONTENT_204
})
const setCookie = res.headers['set-cookie']
expect(setCookie).to.exist
expect(setCookie[0]).to.include('clientLanguage=fr;')
})
it('Should clear cookies if language is null', async function () {
const res = await server.users.updateInterfaceLanguage({
language: null,
expectedStatus: HttpStatusCode.NO_CONTENT_204
})
const setCookie = res.headers['set-cookie']
expect(setCookie).to.exist
expect(setCookie[0]).to.include('clientLanguage=;')
})
})
after(async function () { after(async function () {
await cleanupTests([ server ]) await cleanupTests([ server ])
}) })

View file

@ -956,7 +956,7 @@ describe('Test plugin filter hooks', function () {
}) })
after(async function () { after(async function () {
MockSmtpServer.Instance.kill() await MockSmtpServer.Instance.kill()
await cleanupTests(servers) await cleanupTests(servers)
}) })

View file

@ -262,7 +262,7 @@ export async function prepareImportExportTests (options: {
}) })
// My settings // My settings
await server.users.updateMe({ token: noahToken, description: 'super noah description', p2pEnabled: false }) await server.users.updateMe({ token: noahToken, description: 'super noah description', p2pEnabled: false, language: 'fr' })
// My notification settings // My notification settings
await server.notifications.updateMySettings({ await server.notifications.updateMySettings({

View file

@ -1,17 +1,24 @@
import MailDev from '@peertube/maildev'
import { randomInt } from '@peertube/peertube-core-utils' import { randomInt } from '@peertube/peertube-core-utils'
import { parallelTests } from '@peertube/peertube-node-utils' import { parallelTests } from '@peertube/peertube-node-utils'
import MailDev from 'maildev'
class MockSmtpServer { class MockSmtpServer {
private static instance: MockSmtpServer private static instance: MockSmtpServer
private started = false private started = false
private maildev: any private maildev: any
private emails: object[] private emails: object[]
private relayingEmail: Promise<void>
private constructor () { } private
private constructor () {}
collectEmails (emailsCollection: object[]) { collectEmails (emailsCollection: object[]) {
const outgoingHost = process.env.MAILDEV_RELAY_HOST
const outgoingPort = process.env.MAILDEV_RELAY_PORT
? parseInt(process.env.MAILDEV_RELAY_PORT)
: undefined
return new Promise<number>((res, rej) => { return new Promise<number>((res, rej) => {
const port = parallelTests() ? randomInt(1025, 2000) : 1025 const port = parallelTests() ? randomInt(1025, 2000) : 1025
this.emails = emailsCollection this.emails = emailsCollection
@ -24,11 +31,26 @@ class MockSmtpServer {
ip: '127.0.0.1', ip: '127.0.0.1',
smtp: port, smtp: port,
disableWeb: true, disableWeb: true,
silent: true silent: true,
outgoingHost,
outgoingPort
}) })
this.maildev.on('new', email => { this.maildev.on('new', email => {
this.emails.push(email) this.emails.push(email)
if (outgoingHost || outgoingPort) {
this.relayingEmail = new Promise(resolve => {
this.maildev.relayMail(email, function (err) {
if (err) return console.log(err)
console.log('Email has been relayed!')
this.relayingEmail = undefined
resolve()
})
})
}
}) })
this.maildev.listen(err => { this.maildev.listen(err => {
@ -41,9 +63,13 @@ class MockSmtpServer {
}) })
} }
kill () { async kill () {
if (!this.maildev) return if (!this.maildev) return
if (this.relayingEmail) {
await this.relayingEmail
}
this.maildev.close() this.maildev.close()
this.maildev = null this.maildev = null

View file

@ -77,11 +77,13 @@ async function waitUntilNotification (options: {
await waitJobs([ server ]) await waitJobs([ server ])
} }
async function checkNewVideoFromSubscription (options: CheckerBaseParams & { async function checkNewVideoFromSubscription (
videoName: string options: CheckerBaseParams & {
shortUUID: string videoName: string
checkType: CheckerType shortUUID: string
}) { checkType: CheckerType
}
) {
const { videoName, shortUUID } = options const { videoName, shortUUID } = options
const notificationType = UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION const notificationType = UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION
@ -107,11 +109,13 @@ async function checkNewVideoFromSubscription (options: CheckerBaseParams & {
await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
} }
async function checkNewLiveFromSubscription (options: CheckerBaseParams & { async function checkNewLiveFromSubscription (
videoName: string options: CheckerBaseParams & {
shortUUID: string videoName: string
checkType: CheckerType shortUUID: string
}) { checkType: CheckerType
}
) {
const { videoName, shortUUID } = options const { videoName, shortUUID } = options
const notificationType = UserNotificationType.NEW_LIVE_FROM_SUBSCRIPTION const notificationType = UserNotificationType.NEW_LIVE_FROM_SUBSCRIPTION
@ -137,11 +141,13 @@ async function checkNewLiveFromSubscription (options: CheckerBaseParams & {
await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
} }
async function checkMyVideoIsPublished (options: CheckerBaseParams & { async function checkMyVideoIsPublished (
videoName: string options: CheckerBaseParams & {
shortUUID: string videoName: string
checkType: CheckerType shortUUID: string
}) { checkType: CheckerType
}
) {
const { videoName, shortUUID } = options const { videoName, shortUUID } = options
const notificationType = UserNotificationType.MY_VIDEO_PUBLISHED const notificationType = UserNotificationType.MY_VIDEO_PUBLISHED
@ -165,11 +171,13 @@ async function checkMyVideoIsPublished (options: CheckerBaseParams & {
await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
} }
async function checkVideoStudioEditionIsFinished (options: CheckerBaseParams & { async function checkVideoStudioEditionIsFinished (
videoName: string options: CheckerBaseParams & {
shortUUID: string videoName: string
checkType: CheckerType shortUUID: string
}) { checkType: CheckerType
}
) {
const { videoName, shortUUID } = options const { videoName, shortUUID } = options
const notificationType = UserNotificationType.MY_VIDEO_STUDIO_EDITION_FINISHED const notificationType = UserNotificationType.MY_VIDEO_STUDIO_EDITION_FINISHED
@ -193,13 +201,15 @@ async function checkVideoStudioEditionIsFinished (options: CheckerBaseParams & {
await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
} }
async function checkMyVideoImportIsFinished (options: CheckerBaseParams & { async function checkMyVideoImportIsFinished (
videoName: string options: CheckerBaseParams & {
shortUUID: string videoName: string
url: string shortUUID: string
success: boolean url: string
checkType: CheckerType success: boolean
}) { checkType: CheckerType
}
) {
const { videoName, shortUUID, url, success } = options const { videoName, shortUUID, url, success } = options
const notificationType = success ? UserNotificationType.MY_VIDEO_IMPORT_SUCCESS : UserNotificationType.MY_VIDEO_IMPORT_ERROR const notificationType = success ? UserNotificationType.MY_VIDEO_IMPORT_SUCCESS : UserNotificationType.MY_VIDEO_IMPORT_ERROR
@ -219,9 +229,11 @@ async function checkMyVideoImportIsFinished (options: CheckerBaseParams & {
function emailNotificationFinder (email: object) { function emailNotificationFinder (email: object) {
const text: string = email['text'] const text: string = email['text']
const toFind = success ? ' finished' : ' error' const toFind = success
? /\bfinished\b/
: /\berror\b/
return text.includes(url) && text.includes(toFind) return text.includes(url) && !!text.match(toFind)
} }
await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
@ -229,10 +241,12 @@ async function checkMyVideoImportIsFinished (options: CheckerBaseParams & {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function checkUserRegistered (options: CheckerBaseParams & { async function checkUserRegistered (
username: string options: CheckerBaseParams & {
checkType: CheckerType username: string
}) { checkType: CheckerType
}
) {
const { username } = options const { username } = options
const notificationType = UserNotificationType.NEW_USER_REGISTRATION const notificationType = UserNotificationType.NEW_USER_REGISTRATION
@ -257,11 +271,13 @@ async function checkUserRegistered (options: CheckerBaseParams & {
await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
} }
async function checkRegistrationRequest (options: CheckerBaseParams & { async function checkRegistrationRequest (
username: string options: CheckerBaseParams & {
registrationReason: string username: string
checkType: CheckerType registrationReason: string
}) { checkType: CheckerType
}
) {
const { username, registrationReason } = options const { username, registrationReason } = options
const notificationType = UserNotificationType.NEW_USER_REGISTRATION_REQUEST const notificationType = UserNotificationType.NEW_USER_REGISTRATION_REQUEST
@ -287,13 +303,15 @@ async function checkRegistrationRequest (options: CheckerBaseParams & {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function checkNewActorFollow (options: CheckerBaseParams & { async function checkNewActorFollow (
followType: 'channel' | 'account' options: CheckerBaseParams & {
followerName: string followType: 'channel' | 'account'
followerDisplayName: string followerName: string
followingDisplayName: string followerDisplayName: string
checkType: CheckerType followingDisplayName: string
}) { checkType: CheckerType
}
) {
const { followType, followerName, followerDisplayName, followingDisplayName } = options const { followType, followerName, followerDisplayName, followingDisplayName } = options
const notificationType = UserNotificationType.NEW_FOLLOW const notificationType = UserNotificationType.NEW_FOLLOW
@ -327,10 +345,12 @@ async function checkNewActorFollow (options: CheckerBaseParams & {
await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
} }
async function checkNewInstanceFollower (options: CheckerBaseParams & { async function checkNewInstanceFollower (
followerHost: string options: CheckerBaseParams & {
checkType: CheckerType followerHost: string
}) { checkType: CheckerType
}
) {
const { followerHost } = options const { followerHost } = options
const notificationType = UserNotificationType.NEW_INSTANCE_FOLLOWER const notificationType = UserNotificationType.NEW_INSTANCE_FOLLOWER
@ -354,17 +374,19 @@ async function checkNewInstanceFollower (options: CheckerBaseParams & {
function emailNotificationFinder (email: object) { function emailNotificationFinder (email: object) {
const text: string = email['text'] const text: string = email['text']
return text.includes('instance has a new follower') && text.includes(followerHost) return text.includes('PeerTube has a new follower') && text.includes(followerHost)
} }
await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
} }
async function checkAutoInstanceFollowing (options: CheckerBaseParams & { async function checkAutoInstanceFollowing (
followerHost: string options: CheckerBaseParams & {
followingHost: string followerHost: string
checkType: CheckerType followingHost: string
}) { checkType: CheckerType
}
) {
const { followerHost, followingHost } = options const { followerHost, followingHost } = options
const notificationType = UserNotificationType.AUTO_INSTANCE_FOLLOWING const notificationType = UserNotificationType.AUTO_INSTANCE_FOLLOWING
@ -391,19 +413,21 @@ async function checkAutoInstanceFollowing (options: CheckerBaseParams & {
function emailNotificationFinder (email: object) { function emailNotificationFinder (email: object) {
const text: string = email['text'] const text: string = email['text']
return text.includes(' automatically followed a new instance') && text.includes(followingHost) return text.match(/\bautomatically followed\b/) && text.includes(followingHost)
} }
await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
} }
async function checkCommentMention (options: CheckerBaseParams & { async function checkCommentMention (
shortUUID: string options: CheckerBaseParams & {
commentId: number shortUUID: string
threadId: number commentId: number
byAccountDisplayName: string threadId: number
checkType: CheckerType byAccountDisplayName: string
}) { checkType: CheckerType
}
) {
const { shortUUID, commentId, threadId, byAccountDisplayName } = options const { shortUUID, commentId, threadId, byAccountDisplayName } = options
const notificationType = UserNotificationType.COMMENT_MENTION const notificationType = UserNotificationType.COMMENT_MENTION
@ -425,7 +449,7 @@ async function checkCommentMention (options: CheckerBaseParams & {
function emailNotificationFinder (email: object) { function emailNotificationFinder (email: object) {
const text: string = email['text'] const text: string = email['text']
return text.includes(' mentioned ') && text.includes(shortUUID) && text.includes(byAccountDisplayName) return text.match(/\bmentioned\b/) && text.includes(shortUUID) && text.includes(byAccountDisplayName)
} }
await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
@ -433,13 +457,15 @@ async function checkCommentMention (options: CheckerBaseParams & {
let lastEmailCount = 0 let lastEmailCount = 0
async function checkNewCommentOnMyVideo (options: CheckerBaseParams & { async function checkNewCommentOnMyVideo (
shortUUID: string options: CheckerBaseParams & {
commentId: number shortUUID: string
threadId: number commentId: number
checkType: CheckerType threadId: number
approval?: boolean // default false checkType: CheckerType
}) { approval?: boolean // default false
}
) {
const { server, shortUUID, commentId, threadId, checkType, emails, approval = false } = options const { server, shortUUID, commentId, threadId, checkType, emails, approval = false } = options
const notificationType = UserNotificationType.NEW_COMMENT_ON_MY_VIDEO const notificationType = UserNotificationType.NEW_COMMENT_ON_MY_VIDEO
@ -468,7 +494,7 @@ async function checkNewCommentOnMyVideo (options: CheckerBaseParams & {
const text = email['text'] const text = email['text']
return text.includes(commentUrl) && return text.includes(commentUrl) &&
(approval && text.includes('requires approval')) || (approval && text.includes('requires approval')) ||
(!approval && !text.includes('requires approval')) (!approval && !text.includes('requires approval'))
} }
@ -481,11 +507,13 @@ async function checkNewCommentOnMyVideo (options: CheckerBaseParams & {
} }
} }
async function checkNewVideoAbuseForModerators (options: CheckerBaseParams & { async function checkNewVideoAbuseForModerators (
shortUUID: string options: CheckerBaseParams & {
videoName: string shortUUID: string
checkType: CheckerType videoName: string
}) { checkType: CheckerType
}
) {
const { shortUUID, videoName } = options const { shortUUID, videoName } = options
const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS
@ -511,12 +539,14 @@ async function checkNewVideoAbuseForModerators (options: CheckerBaseParams & {
await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
} }
async function checkNewAbuseMessage (options: CheckerBaseParams & { async function checkNewAbuseMessage (
abuseId: number options: CheckerBaseParams & {
message: string abuseId: number
toEmail: string message: string
checkType: CheckerType toEmail: string
}) { checkType: CheckerType
}
) {
const { abuseId, message, toEmail } = options const { abuseId, message, toEmail } = options
const notificationType = UserNotificationType.ABUSE_NEW_MESSAGE const notificationType = UserNotificationType.ABUSE_NEW_MESSAGE
@ -543,11 +573,13 @@ async function checkNewAbuseMessage (options: CheckerBaseParams & {
await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
} }
async function checkAbuseStateChange (options: CheckerBaseParams & { async function checkAbuseStateChange (
abuseId: number options: CheckerBaseParams & {
state: AbuseStateType abuseId: number
checkType: CheckerType state: AbuseStateType
}) { checkType: CheckerType
}
) {
const { abuseId, state } = options const { abuseId, state } = options
const notificationType = UserNotificationType.ABUSE_STATE_CHANGE const notificationType = UserNotificationType.ABUSE_STATE_CHANGE
@ -578,11 +610,13 @@ async function checkAbuseStateChange (options: CheckerBaseParams & {
await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
} }
async function checkNewCommentAbuseForModerators (options: CheckerBaseParams & { async function checkNewCommentAbuseForModerators (
shortUUID: string options: CheckerBaseParams & {
videoName: string shortUUID: string
checkType: CheckerType videoName: string
}) { checkType: CheckerType
}
) {
const { shortUUID, videoName } = options const { shortUUID, videoName } = options
const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS
@ -608,10 +642,12 @@ async function checkNewCommentAbuseForModerators (options: CheckerBaseParams & {
await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
} }
async function checkNewAccountAbuseForModerators (options: CheckerBaseParams & { async function checkNewAccountAbuseForModerators (
displayName: string options: CheckerBaseParams & {
checkType: CheckerType displayName: string
}) { checkType: CheckerType
}
) {
const { displayName } = options const { displayName } = options
const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS
@ -637,11 +673,13 @@ async function checkNewAccountAbuseForModerators (options: CheckerBaseParams & {
await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
} }
async function checkVideoAutoBlacklistForModerators (options: CheckerBaseParams & { async function checkVideoAutoBlacklistForModerators (
shortUUID: string options: CheckerBaseParams & {
videoName: string shortUUID: string
checkType: CheckerType videoName: string
}) { checkType: CheckerType
}
) {
const { shortUUID, videoName } = options const { shortUUID, videoName } = options
const notificationType = UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS const notificationType = UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS
@ -667,11 +705,13 @@ async function checkVideoAutoBlacklistForModerators (options: CheckerBaseParams
await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
} }
async function checkNewBlacklistOnMyVideo (options: CheckerBaseParams & { async function checkNewBlacklistOnMyVideo (
shortUUID: string options: CheckerBaseParams & {
videoName: string shortUUID: string
blacklistType: 'blacklist' | 'unblacklist' videoName: string
}) { blacklistType: 'blacklist' | 'unblacklist'
}
) {
const { videoName, shortUUID, blacklistType } = options const { videoName, shortUUID, blacklistType } = options
const notificationType = blacklistType === 'blacklist' const notificationType = blacklistType === 'blacklist'
? UserNotificationType.BLACKLIST_ON_MY_VIDEO ? UserNotificationType.BLACKLIST_ON_MY_VIDEO
@ -687,21 +727,24 @@ async function checkNewBlacklistOnMyVideo (options: CheckerBaseParams & {
} }
function emailNotificationFinder (email: object) { function emailNotificationFinder (email: object) {
const text = email['text'] const text: string = email['text']
const blacklistText = blacklistType === 'blacklist'
? 'blacklisted'
: 'unblacklisted'
return text.includes(shortUUID) && text.includes(blacklistText) const blacklistReg = blacklistType === 'blacklist'
? /\bblocked\b/
: /\bunblocked\b/
return text.includes(shortUUID) && !!text.match(blacklistReg)
} }
await checkNotification({ ...options, notificationChecker, emailNotificationFinder, checkType: 'presence' }) await checkNotification({ ...options, notificationChecker, emailNotificationFinder, checkType: 'presence' })
} }
async function checkNewPeerTubeVersion (options: CheckerBaseParams & { async function checkNewPeerTubeVersion (
latestVersion: string options: CheckerBaseParams & {
checkType: CheckerType latestVersion: string
}) { checkType: CheckerType
}
) {
const { latestVersion } = options const { latestVersion } = options
const notificationType = UserNotificationType.NEW_PEERTUBE_VERSION const notificationType = UserNotificationType.NEW_PEERTUBE_VERSION
@ -728,11 +771,13 @@ async function checkNewPeerTubeVersion (options: CheckerBaseParams & {
await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
} }
async function checkNewPluginVersion (options: CheckerBaseParams & { async function checkNewPluginVersion (
pluginType: PluginType_Type options: CheckerBaseParams & {
pluginName: string pluginType: PluginType_Type
checkType: CheckerType pluginName: string
}) { checkType: CheckerType
}
) {
const { pluginName, pluginType } = options const { pluginName, pluginType } = options
const notificationType = UserNotificationType.NEW_PLUGIN_VERSION const notificationType = UserNotificationType.NEW_PLUGIN_VERSION
@ -759,15 +804,17 @@ async function checkNewPluginVersion (options: CheckerBaseParams & {
await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
} }
async function checkMyVideoTranscriptionGenerated (options: CheckerBaseParams & { async function checkMyVideoTranscriptionGenerated (
videoName: string options: CheckerBaseParams & {
shortUUID: string videoName: string
language: { shortUUID: string
id: string language: {
label: string id: string
label: string
}
checkType: CheckerType
} }
checkType: CheckerType ) {
}) {
const { videoName, shortUUID, language } = options const { videoName, shortUUID, language } = options
const notificationType = UserNotificationType.MY_VIDEO_TRANSCRIPTION_GENERATED const notificationType = UserNotificationType.MY_VIDEO_TRANSCRIPTION_GENERATED
@ -872,11 +919,8 @@ async function prepareNotificationsTest (serversCount = 3, overrideConfigArg: an
export { export {
type CheckerType, type CheckerType,
type CheckerBaseParams, type CheckerBaseParams,
getAllNotificationsSettings, getAllNotificationsSettings,
waitUntilNotification, waitUntilNotification,
checkMyVideoImportIsFinished, checkMyVideoImportIsFinished,
checkUserRegistered, checkUserRegistered,
checkAutoInstanceFollowing, checkAutoInstanceFollowing,
@ -904,11 +948,13 @@ export {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function checkNotification (options: CheckerBaseParams & { async function checkNotification (
notificationChecker: (notification: UserNotification, checkType: CheckerType) => void options: CheckerBaseParams & {
emailNotificationFinder: (email: object) => boolean notificationChecker: (notification: UserNotification, checkType: CheckerType) => void
checkType: CheckerType emailNotificationFinder: (email: object) => boolean
}) { checkType: CheckerType
}
) {
const { server, token, checkType, notificationChecker, emailNotificationFinder, socketNotifications, emails } = options const { server, token, checkType, notificationChecker, emailNotificationFinder, socketNotifications, emails } = options
const check = options.check || { web: true, mail: true } const check = options.check || { web: true, mail: true }

View file

@ -10,6 +10,7 @@ npm run tsc -- -b --verbose server/tsconfig.json
npm run resolve-tspaths:server npm run resolve-tspaths:server
cp -r "./server/core/static" "./server/core/assets" ./dist/core cp -r "./server/core/static" "./server/core/assets" ./dist/core
cp -r "./server/locales" ./dist
cp "./server/scripts/upgrade.sh" "./dist/scripts" cp "./server/scripts/upgrade.sh" "./dist/scripts"
mkdir -p ./client/dist && cp -r ./client/src/assets ./client/dist mkdir -p ./client/dist && cp -r ./client/src/assets ./client/dist

View file

@ -19,6 +19,7 @@ mkdir -p "./dist/core/lib"
npm run tsc -- -b -v --incremental server/tsconfig.json npm run tsc -- -b -v --incremental server/tsconfig.json
npm run resolve-tspaths:server npm run resolve-tspaths:server
cp -r ./server/core/static ./server/core/assets ./dist/core cp -r "./server/core/static" "./server/core/assets" ./dist/core
cp -r "./server/locales" ./dist
./node_modules/.bin/tsc-watch --build --preserveWatchOutput --verbose --onSuccess 'sh -c "npm run resolve-tspaths:server && NODE_ENV=dev node dist/server"' server/tsconfig.json ./node_modules/.bin/tsc-watch --build --preserveWatchOutput --verbose --onSuccess 'sh -c "npm run resolve-tspaths:server && NODE_ENV=dev node dist/server"' server/tsconfig.json

View file

@ -25,3 +25,7 @@ node ./node_modules/.bin/xliffmerge -p ./.xliffmerge.json $locales
# Add our strings too # Add our strings too
cd ../ cd ../
npm run i18n:create-custom-files npm run i18n:create-custom-files
# Generate server translations
node ./node_modules/.bin/i18next -c server/.i18next-parser.config.ts server/core/**/*.{ts,hbs}

View file

@ -0,0 +1,145 @@
import { I18N_LOCALES } from '../packages/core-utils/dist/i18n/i18n.js'
import { UserConfig } from 'i18next-parser'
export default {
contextSeparator: '_',
// Key separator used in your translation keys
createOldCatalogs: false,
// Save the \_old files
defaultNamespace: 'translation',
// Default namespace used in your i18next config
defaultValue: (_locale, _namespace, key, _value) => {
return key as string
},
// Default value to give to keys with no value
// You may also specify a function accepting the locale, namespace, key, and value as arguments
indentation: 2,
// Indentation of the catalog files
keepRemoved: false,
// Keep keys from the catalog that are no longer in code
// You may either specify a boolean to keep or discard all removed keys.
// You may also specify an array of patterns: the keys from the catalog that are no long in the code but match one of the patterns will be kept.
// The patterns are applied to the full key including the namespace, the parent keys and the separators.
keySeparator: false,
// Key separator used in your translation keys
// If you want to use plain english keys, separators such as `.` and `:` will conflict. You might want to set `keySeparator: false` and `namespaceSeparator: false`. That way, `t('Status: Loading...')` will not think that there are a namespace and three separator dots for instance.
// see below for more details
lexers: {
hbs: [
{
lexer: 'HandlebarsLexer',
functions: [ 't' ]
}
],
handlebars: [ 'HandlebarsLexer' ],
htm: [ 'HTMLLexer' ],
html: [ 'HTMLLexer' ],
mjs: [ 'JavascriptLexer' ],
js: [ 'JavascriptLexer' ], // if you're writing jsx inside .js files, change this to JsxLexer
ts: [
{
lexer: 'JavascriptLexer',
functions: [ 't', 'tu' ]
}
],
jsx: [ 'JsxLexer' ],
tsx: [ 'JsxLexer' ],
default: [ 'JavascriptLexer' ]
},
lineEnding: 'auto',
// Control the line ending. See options at https://github.com/ryanve/eol
locales: Object.keys(I18N_LOCALES),
// An array of the locales in your applications
namespaceSeparator: false,
// Namespace separator used in your translation keys
// If you want to use plain english keys, separators such as `.` and `:` will conflict. You might want to set `keySeparator: false` and `namespaceSeparator: false`. That way, `t('Status: Loading...')` will not think that there are a namespace and three separator dots for instance.
output: 'server/locales/$LOCALE/$NAMESPACE.json',
// Supports $LOCALE and $NAMESPACE injection
// Supports JSON (.json) and YAML (.yml) file formats
// Where to write the locale files relative to process.cwd()
pluralSeparator: '_',
// Plural separator used in your translation keys
// If you want to use plain english keys, separators such as `_` might conflict. You might want to set `pluralSeparator` to a different string that does not occur in your keys.
// If you don't want to generate keys for plurals (for example, in case you are using ICU format), set `pluralSeparator: false`.
input: undefined,
// An array of globs that describe where to look for source files
// relative to the location of the configuration file
sort: false,
// Whether or not to sort the catalog. Can also be a [compareFunction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#parameters)
verbose: false,
// Display info about the parsing including some stats
failOnWarnings: false,
// Exit with an exit code of 1 on warnings
failOnUpdate: false,
// Exit with an exit code of 1 when translations are updated (for CI purpose)
customValueTemplate: null,
// If you wish to customize the value output the value as an object, you can set your own format.
//
// - ${defaultValue} is the default value you set in your translation function.
// - ${filePaths} will be expanded to an array that contains the absolute
// file paths where the translations originated in, in case e.g., you need
// to provide translators with context
//
// Any other custom property will be automatically extracted from the 2nd
// argument of your `t()` function or tOptions in <Trans tOptions={...} />
//
// Example:
// For `t('my-key', {maxLength: 150, defaultValue: 'Hello'})` in
// /path/to/your/file.js,
//
// Using the following customValueTemplate:
//
// customValueTemplate: {
// message: "${defaultValue}",
// description: "${maxLength}",
// paths: "${filePaths}",
// }
//
// Will result in the following item being extracted:
//
// "my-key": {
// "message": "Hello",
// "description": 150,
// "paths": ["/path/to/your/file.js"]
// }
resetDefaultValueLocale: null,
// The locale to compare with default values to determine whether a default value has been changed.
// If this is set and a default value differs from a translation in the specified locale, all entries
// for that key across locales are reset to the default value, and existing translations are moved to
// the `_old` file.
i18nextOptions: null,
// If you wish to customize options in internally used i18next instance, you can define an object with any
// configuration property supported by i18next (https://www.i18next.com/overview/configuration-options).
// { compatibilityJSON: 'v3' } can be used to generate v3 compatible plurals.
yamlOptions: null
// If you wish to customize options for yaml output, you can define an object here.
// Configuration options are here (https://github.com/nodeca/js-yaml#dump-object---options-).
// Example:
// {
// lineWidth: -1,
// }
} satisfies UserConfig

View file

@ -0,0 +1,10 @@
{{! New message on abuse report }}
{{#> base title=(t "New message on abuse report")}}
<p>
{{{t "A new message by {messageAccountName} was posted on <a href=\"{abuseUrl}\">abuse report #{abuseId}</a>" messageAccountName=messageAccountName abuseUrl=abuseUrl abuseId=abuseId}}}
</p>
<blockquote>{{messageText}}</blockquote>
<br style="display: none;">
{{/base}}

View file

@ -1,11 +0,0 @@
extends ../common/greetings
include ../common/mixins.pug
block title
| New message on abuse report
block content
p
| A new message by #{messageAccountName} was posted on #[a(href=abuseUrl) abuse report ##{abuseId}] on #{instanceName}
blockquote #{messageText}
br(style="display: none;")

View file

@ -0,0 +1,10 @@
{{! Abuse report state changed }}
{{#> base title=(t "Abuse report state changed")}}
<p>
{{#if isAccepted}}
{{{t "<a href=\"{abuseUrl}\">Your abuse report #{abuseId}</a> on {instanceName} has been accepted." abuseUrl=abuseUrl abuseId=abuseId instanceName=instanceName}}}
{{else}}
{{{t "<a href=\"{abuseUrl}\">Your abuse report #{abuseId}</a> on {instanceName} has been rejected." abuseUrl=abuseUrl abuseId=abuseId instanceName=instanceName}}}
{{/if}}
</p>
{{/base}}

View file

@ -1,9 +0,0 @@
extends ../common/greetings
include ../common/mixins.pug
block title
| Abuse report state changed
block content
p
| #[a(href=abuseUrl) Your abuse report ##{abuseId}] on #{instanceName} has been #{isAccepted ? 'accepted' : 'rejected'}

View file

@ -0,0 +1,18 @@
{{! An account is pending moderation }}
{{#> base title=(t "An account is pending moderation")}}
<p>
{{#if isLocal}}
{{{t "{instanceName} received an abuse report for the account: <a href=\"{accountUrl}\">{accountDisplayName}</a>" accountUrl=accountUrl accountDisplayName=accountDisplayName instanceName=instanceName}}}
{{else}}
{{{t "{instanceName} received an abuse report for the remote account: <a href=\"{accountUrl}\">{accountDisplayName}</a>" accountUrl=accountUrl accountDisplayName=accountDisplayName instanceName=instanceName}}}
{{/if}}
</p>
<p>
{{t "The reporter, {reporter}, cited the following reason(s):" reporter=reporter}}
</p>
<blockquote>{{reason}}</blockquote>
<br style="display: none;">
{{/base}}

View file

@ -1,14 +0,0 @@
extends ../common/greetings
include ../common/mixins.pug
block title
| An account is pending moderation
block content
p
| #[a(href=WEBSERVER.URL) #{instanceName}] received an abuse report for the #{isLocal ? '' : 'remote '}account
a(href=accountUrl) #{accountDisplayName}
p The reporter, #{reporter}, cited the following reason(s):
blockquote #{reason}
br(style="display: none;")

View file

@ -1,219 +0,0 @@
//-
The email background color is defined in three places:
1. body tag: for most email clients
2. center tag: for Gmail and Inbox mobile apps and web versions of Gmail, GSuite, Inbox, Yahoo, AOL, Libero, Comcast, freenet, Mail.ru, Orange.fr
3. mso conditional: For Windows 10 Mail
doctype html
head
// This template is heavily adapted from the Cerberus Fluid template. Kudos to them!
meta(charset='utf-8')
//- utf-8 works for most cases
meta(name='viewport' content='width=device-width')
//- Forcing initial-scale shouldn't be necessary
meta(http-equiv='X-UA-Compatible' content='IE=edge')
//- Use the latest (edge) version of IE rendering engine
meta(name='x-apple-disable-message-reformatting')
//- Disable auto-scale in iOS 10 Mail entirely
meta(name='format-detection' content='telephone=no,address=no,email=no,date=no,url=no')
//- Tell iOS not to automatically link certain text strings.
meta(name='color-scheme' content='light')
meta(name='supported-color-schemes' content='light')
//- The title tag shows in email notifications, like Android 4.4.
title #{subject}
//- What it does: Makes background images in 72ppi Outlook render at correct size.
//if gte mso 9
xml
o:officedocumentsettings
o:allowpng
o:pixelsperinch 96
//- CSS Reset : BEGIN
style.
/* What it does: Tells the email client that only light styles are provided but the client can transform them to dark. A duplicate of meta color-scheme meta tag above. */
:root {
color-scheme: light;
supported-color-schemes: light;
}
/* What it does: Remove spaces around the email design added by some email clients. */
/* Beware: It can remove the padding / margin and add a background color to the compose a reply window. */
html,
body {
margin: 0 auto !important;
padding: 0 !important;
height: 100% !important;
width: 100% !important;
}
/* What it does: Stops email clients resizing small text. */
* {
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
/* What it does: Centers email on Android 4.4 */
div[style*="margin: 16px 0"] {
margin: 0 !important;
}
/* What it does: forces Samsung Android mail clients to use the entire viewport */
#MessageViewBody, #MessageWebViewDiv{
width: 100% !important;
}
/* What it does: Stops Outlook from adding extra spacing to tables. */
table,
td {
mso-table-lspace: 0pt !important;
mso-table-rspace: 0pt !important;
}
/* What it does: Fixes webkit padding issue. */
table {
border-spacing: 0 !important;
border-collapse: collapse !important;
table-layout: fixed !important;
margin: 0 auto !important;
}
/* What it does: Uses a better rendering method when resizing images in IE. */
img {
-ms-interpolation-mode:bicubic;
}
a {
color: #{fg};
}
a:not(.no-color) {
font-weight: 600;
text-decoration: underline;
text-decoration-color: #{primary};
text-underline-offset: 0.25em;
text-decoration-thickness: 0.15em;
}
/* What it does: A work-around for email clients meddling in triggered links. */
a[x-apple-data-detectors], /* iOS */
.unstyle-auto-detected-links a,
.aBn {
border-bottom: 0 !important;
cursor: default !important;
color: inherit !important;
text-decoration: none !important;
font-size: inherit !important;
font-family: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
}
/* What it does: Prevents Gmail from displaying a download button on large, non-linked images. */
.a6S {
display: none !important;
opacity: 0.01 !important;
}
/* What it does: Prevents Gmail from changing the text color in conversation threads. */
.im {
color: inherit !important;
}
/* If the above doesn't work, add a .g-img class to any image in question. */
img.g-img + div {
display: none !important;
}
/* What it does: Removes right gutter in Gmail iOS app: https://github.com/TedGoas/Cerberus/issues/89 */
/* Create one of these media queries for each additional viewport size you'd like to fix */
/* iPhone 4, 4S, 5, 5S, 5C, and 5SE */
@media only screen and (min-device-width: 320px) and (max-device-width: 374px) {
u ~ div .email-container {
min-width: 320px !important;
}
}
/* iPhone 6, 6S, 7, 8, and X */
@media only screen and (min-device-width: 375px) and (max-device-width: 413px) {
u ~ div .email-container {
min-width: 375px !important;
}
}
/* iPhone 6+, 7+, and 8+ */
@media only screen and (min-device-width: 414px) {
u ~ div .email-container {
min-width: 414px !important;
}
}
//- CSS Reset : END
//- CSS for PeerTube : START
style.
blockquote {
margin-left: 0;
padding-left: 10px;
border-left: 2px solid #{primary};
}
//- CSS for PeerTube : END
body(width="100%" style="margin: 0; padding: 0 !important; mso-line-height-rule: exactly; color: #{fg}; background-color: #{bg};")
center(role='article' aria-roledescription='email' lang='en' style='width: 100%; background-color: #{bg};')
//if mso | IE
table(role='presentation' border='0' cellpadding='0' cellspacing='0' width='100%' style='background-color: #fff;')
tr
td
//- Visually Hidden Preheader Text : BEGIN
div(style='max-height:0; overflow:hidden; mso-hide:all;' aria-hidden='true')
block preheader
//- Visually Hidden Preheader Text : END
//- Create white space after the desired preview text so email clients dont pull other distracting text into the inbox preview. Extend as necessary.
//- Preview Text Spacing Hack : BEGIN
div(style='display: none; font-size: 1px; line-height: 1px; max-height: 0px; max-width: 0px; opacity: 0; overflow: hidden; mso-hide: all; font-family: sans-serif;')
| &zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;
//- Preview Text Spacing Hack : END
//-
Set the email width. Defined in two places:
1. max-width for all clients except Desktop Windows Outlook, allowing the email to squish on narrow but never go wider than 600px.
2. MSO tags for Desktop Windows Outlook enforce a 600px width.
.email-container(style='max-width: 600px; margin: 0 auto;')
//if mso
table(align='center' role='presentation' cellspacing='0' cellpadding='0' border='0' width='600')
tr
td
//- Email Body : BEGIN
table(align='center' role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%' style='margin: auto;')
//- 1 Column Text + Button : BEGIN
tr
td
table(role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%')
tr
td(style='padding: 20px; font-family: sans-serif; font-size: 15px; line-height: 1.5')
table(role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%")
tr
td(width="40px")
img(src=`${WEBSERVER.URL}/client/assets/images/icons/icon-192x192.png` width="auto" height="30px" alt="" border="0" style="height: 30px; font-family: sans-serif; font-size: 15px; line-height: 15px;")
td
h1(style='margin: 10px 0 10px 0; font-family: sans-serif; font-size: 25px; line-height: 30px; font-weight: normal;')
block title
if title
| #{title}
else
| Something requires your attention
p(style='margin: 0;')
block body
if action
tr
td(style='padding: 0 20px;')
//- Button : BEGIN
table(align='center' role='presentation' cellspacing='0' cellpadding='0' border='0' style='margin: auto;')
tr
td(style=`border-radius: 4px; background: ${primary};`)
a.no-color(href=action.url style=`background: ${primary}; font-family: sans-serif; font-size: 15px; line-height: 15px; text-decoration: none; padding: 13px 17px; display: block; border-radius: 4px; font-weight: bold;`) #{action.text}
//- Button : END
//- 1 Column Text + Button : END
//- Clear Spacer : BEGIN
tr
td(aria-hidden='true' height='20' style='font-size: 0px; line-height: 0px;')
br
//- Clear Spacer : END
//- Email Body : END
//- Email Footer : BEGIN
unless hideNotificationPreferencesLink
table(align='center' role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%' style='margin: auto;')
tr
td(style='padding: 20px; padding-bottom: 0px; font-family: sans-serif; font-size: 12px; text-align: center;')
webversion
a.no-color(href=`${WEBSERVER.URL}/my-account/notifications` style='font-weight: bold;') View in your notifications
br
tr
td(style='padding: 20px; padding-top: 10px; font-family: sans-serif; font-size: 12px; text-align: center;')
unsubscribe
a.no-color(href=`${WEBSERVER.URL}/my-account/settings#notifications`) Manage your notification preferences in your profile
br
//- Email Footer : END
//if mso
//if mso | IE

View file

@ -1,11 +0,0 @@
extends base
block body
if username
p Hi #{username},
block content
if signature
p
| #{signature}

View file

@ -0,0 +1,5 @@
{{#> base}}
<p>
{{text}}
</p>
{{/base}}

View file

@ -1,4 +0,0 @@
extends greetings
block content
p !{text}

View file

@ -1,7 +0,0 @@
mixin channel(channel)
- var handle = `${channel.name}@${channel.host}`
| #[a(href=`${WEBSERVER.URL}/video-channels/${handle}` title=handle) #{channel.displayName}]
mixin account(account)
- var handle = `${account.name}@${account.host}`
| #[a(href=`${WEBSERVER.URL}/accounts/${handle}` title=handle) #{account.displayName}]

View file

@ -0,0 +1,11 @@
{{#> base title=(t "Someone just used the contact form")}}
<p>
{{{t "{fromName} sent you a message via the contact form on <a href=\"{webserverUrl}\">{instanceName}</a>: " fromName=fromName webserverUrl=WEBSERVER.URL instanceName=instanceName}}}
</p>
<blockquote style="white-space: pre-wrap">{{body}}</blockquote>
<p>
{{{t "You can contact them at <a href=\"mailto:{fromEmail}\">{fromEmail}</a>, or simply reply to this email to get in touch." fromEmail=fromEmail}}}
</p>
{{/base}}

View file

@ -1,9 +0,0 @@
extends ../common/greetings
block title
| Someone just used the contact form
block content
p #{fromName} sent you a message via the contact form on #[a(href=WEBSERVER.URL) #{instanceName}]:
blockquote(style='white-space: pre-wrap') #{body}
p You can contact them at #[a(href=`mailto:${fromEmail}`) #{fromEmail}], or simply reply to this email to get in touch.

View file

@ -0,0 +1,9 @@
{{#> base title=(t "New follower on your channel")}}
<p>
{{#if accountFollowType}}
{{{t "Your account <a href=\"{followingUrl}\">{followingName}</a> has a new subscriber: <a href=\"{followerUrl}\">{followerName}</a>." followingUrl=followingUrl followingName=followingName followerUrl=followerUrl followerName=followerName}}}
{{else}}
{{{t "Your channel <a href=\"{followingUrl}\">{followingName}</a> has a new subscriber: <a href=\"{followerUrl}\">{followerName}</a>." followingUrl=followingUrl followingName=followingName followerUrl=followerUrl followerName=followerName}}}
{{/if}}
</p>
{{/base}}

View file

@ -1,9 +0,0 @@
extends ../common/greetings
block title
| New follower on your channel
block content
p.
Your #{followType} #[a(href=followingUrl) #{followingName}] has a new subscriber:
#[a(href=followerUrl) #{followerName}].

View file

@ -0,0 +1,15 @@
{{#> base title=(t "Your account has been blocked")}}
<p>
{{#if reason}}
{{{t "Your account <strong>{username}</strong> has been blocked by {instanceName} moderators for the following reason:" username=username instanceName=instanceName}}}
{{else}}
{{{t "Your account <strong>{username}</strong> has been blocked by {instanceName} moderators." username=username instanceName=instanceName}}}
{{/if}}
</p>
{{#if reason}}
<blockquote>{{reason}}</blockquote>
{{/if}}
<br style="display: none;">
{{/base}}

View file

@ -0,0 +1,5 @@
{{#> base title=(t "Your account has been unblocked")}}
<p>
{{{t "Your account <strong>{username}</strong> has been unblocked by {instanceName} moderators." username=username instanceName=instanceName}}}
</p>
{{/base}}

View file

@ -0,0 +1,268 @@
{{!
The email background color is defined in three places:
1. body tag: for most email clients
2. center tag: for Gmail and Inbox mobile apps and web versions of Gmail, GSuite, Inbox, Yahoo, AOL, Libero, Comcast, freenet, Mail.ru, Orange.fr
3. mso conditional: For Windows 10 Mail
}}
<!DOCTYPE html>
<html>
<head>
<!-- This template is heavily adapted from the Cerberus Fluid template. Kudos to them! -->
<meta charset="utf-8">
<!-- utf-8 works for most cases -->
<meta name="viewport" content="width=device-width">
<!-- Forcing initial-scale shouldn't be necessary -->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!-- Use the latest (edge) version of IE rendering engine -->
<meta name="x-apple-disable-message-reformatting">
<!-- Disable auto-scale in iOS 10 Mail entirely -->
<meta name="format-detection" content="telephone=no,address=no,email=no,date=no,url=no">
<!-- Tell iOS not to automatically link certain text strings. -->
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<!-- The title tag shows in email notifications, like Android 4.4. -->
<title>{{subject}}</title>
<!-- What it does: Makes background images in 72ppi Outlook render at correct size. -->
<!--[if gte mso 9]>
<xml>
<o:officedocumentsettings>
<o:allowpng>
<o:pixelsperinch>96</o:pixelsperinch>
</o:allowpng>
</o:officedocumentsettings>
</xml>
<![endif]-->
<!-- CSS Reset : BEGIN -->
<style>
/* What it does: Tells the email client that only light styles are provided but the client can transform them to dark. A duplicate of meta color-scheme meta tag above. */
:root {
color-scheme: light;
supported-color-schemes: light;
}
/* What it does: Remove spaces around the email design added by some email clients. */
/* Beware: It can remove the padding / margin and add a background color to the compose a reply window. */
html,
body {
margin: 0 auto !important;
padding: 0 !important;
height: 100% !important;
width: 100% !important;
}
/* What it does: Stops email clients resizing small text. */
* {
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
/* What it does: Centers email on Android 4.4 */
div[style*="margin: 16px 0"] {
margin: 0 !important;
}
/* What it does: forces Samsung Android mail clients to use the entire viewport */
#MessageViewBody, #MessageWebViewDiv{
width: 100% !important;
}
/* What it does: Stops Outlook from adding extra spacing to tables. */
table,
td {
mso-table-lspace: 0pt !important;
mso-table-rspace: 0pt !important;
}
/* What it does: Fixes webkit padding issue. */
table {
border-spacing: 0 !important;
border-collapse: collapse !important;
table-layout: fixed !important;
margin: 0 auto !important;
}
/* What it does: Uses a better rendering method when resizing images in IE. */
img {
-ms-interpolation-mode:bicubic;
}
a {
color: {{fg}};
}
a:not(.no-color) {
font-weight: 600;
text-decoration: underline;
text-decoration-color: {{primary}};
text-underline-offset: 0.25em;
text-decoration-thickness: 0.15em;
}
/* What it does: A work-around for email clients meddling in triggered links. */
a[x-apple-data-detectors], /* iOS */
.unstyle-auto-detected-links a,
.aBn {
border-bottom: 0 !important;
cursor: default !important;
color: inherit !important;
text-decoration: none !important;
font-size: inherit !important;
font-family: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
}
/* What it does: Prevents Gmail from displaying a download button on large, non-linked images. */
.a6S {
display: none !important;
opacity: 0.01 !important;
}
/* What it does: Prevents Gmail from changing the text color in conversation threads. */
.im {
color: inherit !important;
}
/* If the above doesn't work, add a .g-img class to any image in question. */
img.g-img + div {
display: none !important;
}
/* What it does: Removes right gutter in Gmail iOS app: https://github.com/TedGoas/Cerberus/issues/89 */
/* Create one of these media queries for each additional viewport size you'd like to fix */
/* iPhone 4, 4S, 5, 5S, 5C, and 5SE */
@media only screen and (min-device-width: 320px) and (max-device-width: 374px) {
u ~ div .email-container {
min-width: 320px !important;
}
}
/* iPhone 6, 6S, 7, 8, and X */
@media only screen and (min-device-width: 375px) and (max-device-width: 413px) {
u ~ div .email-container {
min-width: 375px !important;
}
}
/* iPhone 6+, 7+, and 8+ */
@media only screen and (min-device-width: 414px) {
u ~ div .email-container {
min-width: 414px !important;
}
}
</style>
<!-- CSS Reset : END -->
<!-- CSS for PeerTube : START -->
<style>
blockquote {
margin-left: 0;
padding-left: 10px;
border-left: 2px solid {{primary}};
}
</style>
<!-- CSS for PeerTube : END -->
</head>
<body width="100%" style="margin: 0; padding: 0 !important; mso-line-height-rule: exactly; color: {{fg}}; background-color: {{bg}};">
<center role="article" aria-roledescription="email" lang="en" style="width: 100%; background-color: {{bg}};">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #fff;">
<tr>
<td>
<![endif]-->
<!-- Create white space after the desired preview text so email clients don't pull other distracting text into the inbox preview. Extend as necessary. -->
<!-- Preview Text Spacing Hack : BEGIN -->
<div style="display: none; font-size: 1px; line-height: 1px; max-height: 0px; max-width: 0px; opacity: 0; overflow: hidden; mso-hide: all; font-family: sans-serif;">
&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;
</div>
<!-- Preview Text Spacing Hack : END -->
<!--
Set the email width. Defined in two places:
1. max-width for all clients except Desktop Windows Outlook, allowing the email to squish on narrow but never go wider than 600px.
2. MSO tags for Desktop Windows Outlook enforce a 600px width.
-->
<div class="email-container" style="max-width: 600px; margin: 0 auto;">
<!--[if mso]>
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="600">
<tr>
<td>
<![endif]-->
<!-- Email Body : BEGIN -->
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: auto;">
<!-- 1 Column Text + Button : BEGIN -->
<tr>
<td>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px; font-family: sans-serif; font-size: 15px; line-height: 1.5">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td width="40px">
<img src="{{logoUrl}}" width="auto" height="30px" alt="" border="0" style="height: 30px; font-family: sans-serif; font-size: 15px; line-height: 15px;">
</td>
<td>
<h1 style="margin: 10px 0 10px 0; font-family: sans-serif; font-size: 25px; line-height: 30px; font-weight: normal;">
{{#if title}}
{{title}}
{{else}}
{{subject}}
{{/if}}
</h1>
</td>
</tr>
</table>
<p style="margin: 0;">
{{#if username}}
<p>{{t "Hi {username}," username=username}}</p>
{{/if}}
{{> @partial-block}}
{{#if signature}}
<p>{{signature}}</p>
{{/if}}
</p>
</td>
</tr>
{{#if action}}
<tr>
<td style="padding: 0 20px;">
{{> button actionUrl=action.url actionText=action.text}}
</td>
</tr>
{{/if}}
</table>
</td>
</tr>
<!-- 1 Column Text + Button : END -->
<!-- Clear Spacer : BEGIN -->
<tr>
<td aria-hidden="true" height="20" style="font-size: 0px; line-height: 0px;">
<br>
</td>
</tr>
<!-- Clear Spacer : END -->
</table>
<!-- Email Body : END -->
<!-- Email Footer : BEGIN -->
{{#unless hideNotificationPreferencesLink}}
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: auto;">
<tr>
<td style="padding: 20px; padding-bottom: 0px; font-family: sans-serif; font-size: 12px; text-align: center;">
<a class="no-color" href="{{WEBSERVER.URL}}/my-account/notifications" style="font-weight: bold;">
{{t "View in your notifications" }}
</a>
<br>
</td>
</tr>
<tr>
<td style="padding: 20px; padding-top: 10px; font-family: sans-serif; font-size: 12px; text-align: center;">
<a class="no-color" href="{{WEBSERVER.URL}}/my-account/settings#notifications">
{{t "Manage your notification preferences in your profile"}}
</a>
<br>
</td>
</tr>
</table>
{{/unless}}
<!-- Email Footer : END -->
<!--[if mso]>
</td>
</tr>
</table>
<![endif]-->
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</center>
</body>
</html>

View file

@ -0,0 +1,7 @@
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: auto;">
<tr>
<td style="border-radius: 4px; background: {{primary}};">
<a class="no-color" href="{{actionUrl}}" style="background: {{primary}}; font-family: sans-serif; font-size: 15px; line-height: 15px; text-decoration: none; padding: 13px 17px; display: block; border-radius: 4px; font-weight: bold;">{{actionText}}</a>
</td>
</tr>
</table>

View file

@ -0,0 +1,11 @@
{{#> base title=(t "Password creation for your account")}}
<p>
{{{t "Welcome to <a href=\"{webserverUrl}\">{instanceName}</a>!" webserverUrl=WEBSERVER.URL instanceName=instanceName}}}
</p>
<p>{{t "Your username is: {username}." username=username}}</p>
<p>{{t "Please click on the link below to set your password (this link will expire within seven days):"}}</p>
{{> button actionUrl=createPasswordUrl actionText=(t "Create my password")}}
{{/base}}

View file

@ -1,10 +0,0 @@
extends ../common/greetings
block title
| Password creation for your account
block content
p.
Welcome to #[a(href=WEBSERVER.URL) #{instanceName}]. Your username is: #{username}.
Please set your password by following #[a(href=createPasswordUrl) this link]: #[a(href=createPasswordUrl) #{createPasswordUrl}]
(this link will expire within seven days).

View file

@ -0,0 +1,15 @@
{{#> base title=(t "Password reset for your account")}}
<p>
{{t "A reset password procedure for your account has been requested on {instanceName}." username=username instanceName=instanceName}}
</p>
<p>
{{t "Please click on the link below to reset it (the link will expire within 1 hour):"}}
</p>
{{> button actionUrl=resetPasswordUrl actionText=(t "Reset my password")}}
<p>
{{t "If you are not the person who initiated this request, please let us know by replying to this email."}}
</p>
{{/base}}

View file

@ -1,12 +0,0 @@
extends ../common/greetings
block title
| Password reset for your account
block content
p.
A reset password procedure for your account #{username} has been requested on #[a(href=WEBSERVER.URL) #{instanceName}].
Please follow #[a(href=resetPasswordUrl) this link] to reset it: #[a(href=resetPasswordUrl) #{resetPasswordUrl}]
(the link will expire within 1 hour).
p.
If you are not the person who initiated this request, please ignore this email.

View file

@ -0,0 +1,7 @@
{{#> base title=(t "New PeerTube version available")}}
<p>
{{t "A new version of PeerTube is available: {latestVersion}." latestVersion=latestVersion}}
{{{t "You can check the latest news on <a href=\"https://joinpeertube.org/news\">JoinPeerTube</a>."}}}
</p>
{{/base}}

View file

@ -1,9 +0,0 @@
extends ../common/greetings
block title
| New PeerTube version available
block content
p
| A new version of PeerTube is available: #{latestVersion}.
| You can check the latest news on #[a(href="https://joinpeertube.org/news") JoinPeerTube].

View file

@ -0,0 +1,14 @@
{{! New plugin version available }}
{{#> base title=(t "New plugin version available")}}
<p>
{{#if isPlugin}}
{{t "A new version of the plugin {pluginName} is available: {latestVersion}." pluginName=pluginName latestVersion=latestVersion}}
{{else}}
{{t "A new version of the theme {pluginName} is available: {latestVersion}." pluginName=pluginName latestVersion=latestVersion}}
{{/if}}
</p>
<p>
{{{t "You might want to upgrade it on <a href=\"{pluginUrl}\">your admin interface</a>." pluginUrl=pluginUrl}}}
</p>
{{/base}}

View file

@ -1,9 +0,0 @@
extends ../common/greetings
block title
| New plugin version available
block content
p
| A new version of the plugin/theme #{pluginName} is available: #{latestVersion}.
| You might want to upgrade it on #[a(href=pluginUrl) the PeerTube admin interface].

View file

@ -0,0 +1,10 @@
{{! Your export archive has been created }}
{{#> base title=(t "Your export archive has been created")}}
<p>
{{t "Your export archive has been created."}}
</p>
<p>
{{{t "You can download it in <a href=\"{exportsUrl}\">your account export page</a>." exportsUrl=exportsUrl}}}
</p>
{{/base}}

View file

@ -1,9 +0,0 @@
extends ../common/greetings
include ../common/mixins.pug
block title
| Your export archive has been created
block content
p
| Your export archive has been created. You can download it in #[a(href=exportsUrl) your account export page].

View file

@ -0,0 +1,11 @@
{{#> base title=(t "Failed to create your export archive")}}
<p>
{{t "We are sorry but the generation of your export archive has failed:"}}
</p>
<blockquote>{{errorMessage}}</blockquote>
<p>
{{t "Please contact your administrator if the problem occurs again."}}
</p>
{{/base}}

Some files were not shown because too many files have changed in this diff Show more