1
0
Fork 0
mirror of https://github.com/Chocobozzz/PeerTube.git synced 2025-10-03 01:39:37 +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:
* Run [mailslurper](http://mailslurper.com/)
* Run PeerTube using mailslurper SMTP port: `NODE_CONFIG='{ "smtp": { "hostname": "localhost", "port": 2500, "tls": false } }' NODE_ENV=dev node dist/server`
* Run [MailDev](https://github.com/maildev/maildev) using Docker
* 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

View file

@ -73,6 +73,18 @@
></my-markdown-textarea>
</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">
<label i18n for="instanceCategories">Main instance categories</label>

View file

@ -1,5 +1,5 @@
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 { ActivatedRoute, RouterLink } from '@angular/router'
import { CanComponentDeactivate, ServerService } from '@app/core'
@ -17,8 +17,11 @@ import {
} from '@app/shared/form-validators/form-validator.model'
import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.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 { getCompleteLocale, I18N_LOCALES } from '@peertube/peertube-core-utils'
import { ActorImage, CustomConfig, NSFWPolicyType, VideoConstant } from '@peertube/peertube-models'
import merge from 'lodash-es/merge'
import { Subscription } from 'rxjs'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { AdminConfigService } from '../../../shared/shared-admin/admin-config.service'
@ -44,6 +47,7 @@ type Form = {
shortDescription: FormControl<string>
description: FormControl<string>
categories: FormControl<number[]>
defaultLanguage: FormControl<string>
languages: FormControl<string[]>
serverCountry: FormControl<string>
@ -87,7 +91,8 @@ type Form = {
PeertubeCheckboxComponent,
PeerTubeTemplateDirective,
HelpComponent,
AdminSaveBarComponent
AdminSaveBarComponent,
SelectOptionsComponent
]
})
export class AdminConfigInformationComponent implements OnInit, OnDestroy, CanComponentDeactivate {
@ -126,6 +131,8 @@ export class AdminConfigInformationComponent implements OnInit, OnDestroy, CanCo
}
]
defaultLanguageItems: SelectOptionsItem[] = []
private customConfig: CustomConfig
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.categoryItems = data.categories.map(l => ({ label: l.label, id: l.id }))
this.defaultLanguageItems = Object.entries(I18N_LOCALES).map(([ id, label ]) => ({ label, id }))
this.buildForm()
@ -185,6 +193,8 @@ export class AdminConfigInformationComponent implements OnInit, OnDestroy, CanCo
hardwareInformation: null,
defaultLanguage: null,
categories: 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 {
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>
<ul>
<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>P2P system could not work correctly</li>
</ul>
<p i18n>If this is not your correct public IP, please consider fixing it because:</p>
<ul>
<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>P2P system could not work correctly</li>
</ul>
<p i18n>To fix it:<p>
<ul>
<li i18n>Check the <code>trust_proxy</code> configuration key</li>
</ul>
<p i18n>To fix it:<p>
<ul>
<li i18n>Check the <code>trust_proxy</code> configuration key</li>
</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 '_mixins' as *;
@use "_variables" as *;
@use "_mixins" as *;
code {
font-size: 14px;
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 { Notifier } from '@app/core'
import { FormsModule } from '@angular/forms'
import { Notifier, ServerService } from '@app/core'
import { Debug } from '@peertube/peertube-models'
import { DebugService } from './debug.service'
@Component({
templateUrl: './debug.component.html',
styleUrls: [ './debug.component.scss' ],
imports: []
imports: [ CommonModule, FormsModule ]
})
export class DebugComponent implements OnInit {
private debugService = inject(DebugService)
private notifier = inject(Notifier)
private server = inject(ServerService)
debug: Debug
testEmail: string
ngOnInit (): void {
this.load()
}
isEmailDisabled () {
return this.server.getHTMLConfig().email.enabled === false
}
load () {
this.debugService.getDebug()
.subscribe({
@ -26,4 +34,17 @@ export class DebugComponent implements OnInit {
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 { Injectable, inject } from '@angular/core'
import { RestExtractor } from '@app/core'
import { Debug } from '@peertube/peertube-models'
import { Debug, SendDebugCommand } from '@peertube/peertube-models'
import { environment } from '../../../../environments/environment'
@Injectable()
@ -19,4 +19,16 @@ export class DebugService {
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="title-col">
<div class="anchor" id="interface-settings"></div> <!-- interface settings anchor -->
<h2 i18n>INTERFACE</h2>
</div>

View file

@ -1,7 +1,7 @@
import { DOCUMENT, getLocaleDirection, NgClass, NgIf, PlatformLocation } from '@angular/common'
import { AfterViewInit, Component, inject, LOCALE_ID, OnDestroy, OnInit, viewChild } from '@angular/core'
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 {
AuthService,
Hotkey,
@ -83,7 +83,6 @@ export class AppComponent implements OnInit, AfterViewInit, OnDestroy {
private scrollService = inject(ScrollService)
private userLocalStorage = inject(UserLocalStorageService)
private peertubeModal = inject(PeertubeModalService)
private route = inject(ActivatedRoute)
menu = inject(MenuService)

View file

@ -1,5 +1,5 @@
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 { EmptyComponent } from './empty.component'
import { HomepageRedirectComponent } from './homepage-redirect.component'
@ -241,7 +241,7 @@ const routes: Routes = [
]
// Avoid 404 when changing language
for (const locale of POSSIBLE_LOCALES) {
for (const locale of AVAILABLE_LOCALES) {
routes.push({
path: locale,
component: HomepageRedirectComponent

View file

@ -76,6 +76,8 @@ export class User implements UserServerModel {
twoFactorEnabled: boolean
language: string
createdAt: Date
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 { 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 { RestExtractor } from '../rest'
import { UserLocalStorageService } from './user-local-storage.service'
@ -14,9 +15,11 @@ export class UserService {
private authHttp = inject(HttpClient)
private authService = inject(AuthService)
private restExtractor = inject(RestExtractor)
private localeId = inject(LOCALE_ID)
private userLocalStorageService = inject(UserLocalStorageService)
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 signupInThisSession = false
@ -60,7 +63,11 @@ export class UserService {
}
getAnonymousUser () {
return new User(this.userLocalStorageService.getUserInfo())
return new User({
...this.userLocalStorageService.getUserInfo(),
language: getCompleteLocale(this.localeId)
})
}
getAnonymousOrLoggedUser () {
@ -188,4 +195,12 @@ export class UserService {
.get<string[]>(url, { params })
.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>
<a
*ngIf="user.account" ngbDropdownItem class="dropdown-item" routerLink="/my-account"
#manageAccount
>
<a *ngIf="user.account" ngbDropdownItem class="dropdown-item" routerLink="/my-account">
<my-global-icon iconName="cog" aria-hidden="true"></my-global-icon> <ng-container i18n>Manage my account</ng-container>
</a>
<div class="dropdown-divider"></div>
<button
<a
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>
<span i18n>Interface:</span>
<span class="ms-auto ps-2 muted">{{ currentInterfaceLanguage }}</span>
</button>
</a>
<button *ngIf="!isInMobileView" ngbDropdownItem class="dropdown-item" (click)="openHotkeysCheatSheet()">
<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>
</div>
<my-language-chooser #languageChooserModal></my-language-chooser>
<my-quick-settings #quickSettingsModal (openLanguageModal)="languageChooserModal.show()"></my-quick-settings>
<my-quick-settings #quickSettingsModal></my-quick-settings>

View file

@ -1,9 +1,8 @@
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 { AuthService, AuthStatus, AuthUser, HotkeysService, MenuService, RedirectService, ScreenService, ServerService } from '@app/core'
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 { ActorAvatarComponent } from '@app/shared/shared-actor-image/actor-avatar.component'
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 { SignupLabelComponent } from '@app/shared/shared-main/users/signup-label.component'
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 { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
import { isAndroid, isIOS, isIphone } from '@root-helpers/web-browser'
import { Subscription } from 'rxjs'
import { GlobalIconComponent } from '../shared/shared-icons/global-icon.component'
import { ButtonComponent } from '../shared/shared-main/buttons/button.component'
import { SearchTypeaheadComponent } from './search-typeahead.component'
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({
selector: 'my-header',
@ -32,7 +32,6 @@ import { findAppropriateImage } from '@peertube/peertube-core-utils'
PluginSelectorDirective,
SignupLabelComponent,
LoginLinkComponent,
LanguageChooserComponent,
QuickSettingsModalComponent,
GlobalIconComponent,
RouterLink,
@ -53,10 +52,10 @@ export class HeaderComponent implements OnInit, OnDestroy {
private router = inject(Router)
private menu = inject(MenuService)
private headerService = inject(HeaderService)
private localeId = inject(LOCALE_ID)
private static LS_HIDE_MOBILE_MSG = 'hide-mobile-msg'
readonly languageChooserModal = viewChild<LanguageChooserComponent>('languageChooserModal')
readonly quickSettingsModal = viewChild<QuickSettingsModalComponent>('quickSettingsModal')
readonly dropdown = viewChild<NgbDropdown>('dropdown')
@ -65,8 +64,6 @@ export class HeaderComponent implements OnInit, OnDestroy {
hotkeysHelpVisible = false
currentInterfaceLanguage: string
mobileMsg = false
androidAppUrl = ''
iosAppUrl = ''
@ -81,8 +78,15 @@ export class HeaderComponent implements OnInit, OnDestroy {
private hotkeysSub: Subscription
private authSub: Subscription
get language () {
return this.languageChooserModal().getCurrentLanguage()
get currentInterfaceLanguage () {
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 () {
@ -121,7 +125,6 @@ export class HeaderComponent implements OnInit, OnDestroy {
ngOnInit () {
this.htmlConfig = this.serverService.getHTMLConfig()
this.currentInterfaceLanguage = this.languageChooserModal().getCurrentLanguage()
this.loggedIn = this.authService.isLoggedIn()
this.updateUserState()
@ -273,10 +276,6 @@ export class HeaderComponent implements OnInit, OnDestroy {
this.redirectService.redirectToHomepage()
}
openLanguageChooser () {
this.languageChooserModal().show()
}
openQuickSettings () {
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">
<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>
<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>
<h5 i18n class="section-label mt-4 mb-3">INTERFACE</h5>
<my-user-interface-settings
*ngIf="!isUserLoggedIn()"
[user]="user" [userInformationLoaded]="userInformationLoaded" [reactiveUpdate]="true" [notifyOnUpdate]="true"
[user]="user" [userInformationLoaded]="userInformationLoaded" reactiveUpdate="true" notifyOnUpdate="true"
></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>
</ng-template>

View file

@ -1,9 +1,8 @@
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 { AuthService, AuthStatus, LocalStorageService, PeerTubeRouterService, User, UserService } from '@app/core'
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 { UserInterfaceSettingsComponent } from '@app/shared/shared-user-settings/user-interface-settings.component'
import { UserVideoSettingsComponent } from '@app/shared/shared-user-settings/user-video-settings.component'
@ -15,13 +14,17 @@ import { filter } from 'rxjs/operators'
@Component({
selector: 'my-quick-settings',
templateUrl: './quick-settings-modal.component.html',
styles: [
`h5 {
font-size: 1rem;
}`
],
imports: [
CommonModule,
GlobalIconComponent,
UserVideoSettingsComponent,
UserInterfaceSettingsComponent,
AlertComponent,
ButtonComponent
AlertComponent
]
})
export class QuickSettingsModalComponent implements OnInit, OnDestroy {
@ -36,8 +39,6 @@ export class QuickSettingsModalComponent implements OnInit, OnDestroy {
readonly modal = viewChild<NgbModal>('modal')
readonly openLanguageModal = output()
user: User
userInformationLoaded = new ReplaySubject<boolean>(1)
@ -89,11 +90,6 @@ export class QuickSettingsModalComponent implements OnInit, OnDestroy {
this.setModalQuery('add')
}
changeLanguage () {
this.openedModal.close()
this.openLanguageModal.emit()
}
private setModalQuery (type: 'add' | 'remove') {
const modal = type === 'add'
? 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 { AccountService } from './account/account.service'
import { AUTH_INTERCEPTOR_PROVIDER } from './auth/auth-interceptor.service'
import { VideoChannelSyncService } from './channel/video-channel-sync.service'
import { VideoChannelService } from './channel/video-channel.service'
import { CustomPageService } from './custom-page/custom-page.service'
import { FromNowPipe } from './date/from-now.pipe'
import { AUTH_INTERCEPTOR_PROVIDER } from './http/auth-interceptor.service'
import { InstanceService } from './instance/instance.service'
import { ActorRedirectGuard } from './router/actor-redirect-guard.service'
import { UserHistoryService } from './users/user-history.service'

View file

@ -1,4 +1,21 @@
<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">
<label i18n for="theme">Theme</label>
@ -6,5 +23,5 @@
<my-select-options inputId="theme" formControlName="theme" [items]="availableThemes"></my-select-options>
</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>

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 { 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'
type Form = {
theme: FormControl<string>
language: FormControl<string>
}
@Component({
selector: 'my-user-interface-settings',
templateUrl: './user-interface-settings.component.html',
styleUrls: [ './user-interface-settings.component.scss' ],
imports: [ FormsModule, ReactiveFormsModule, NgIf, SelectOptionsComponent ]
})
export class UserInterfaceSettingsComponent extends FormReactive implements OnInit, OnDestroy {
protected formReactiveService = inject(FormReactiveService)
export class UserInterfaceSettingsComponent implements OnInit, OnDestroy {
private formReactiveService = inject(FormReactiveService)
private authService = inject(AuthService)
private notifier = inject(Notifier)
private userService = inject(UserService)
private themeService = inject(ThemeService)
private serverService = inject(ServerService)
readonly user = input<User>(undefined)
readonly reactiveUpdate = input(false)
readonly notifyOnUpdate = input(true)
readonly user = input<Pick<User, 'theme' | 'language'>>(undefined)
readonly reactiveUpdate = input(false, { transform: booleanAttribute })
readonly notifyOnUpdate = input(true, { transform: booleanAttribute })
readonly userInformationLoaded = input<Subject<any>>(undefined)
form: FormGroup<Form>
formErrors: FormReactiveErrorsTyped<Form> = {}
validationMessages: FormReactiveMessagesTyped<Form> = {}
availableThemes: SelectOptionsItem[]
availableLanguages: SelectOptionsItem[]
formValuesWatcher: Subscription
private serverConfig: HTMLServerConfig
private initialUserLanguage: string
private updating = false
get instanceName () {
return this.serverConfig.instance.name
@ -39,6 +58,7 @@ export class UserInterfaceSettingsComponent extends FormReactive implements OnIn
ngOnInit () {
this.serverConfig = this.serverService.getHTMLConfig()
this.initialUserLanguage = this.user().language
this.availableThemes = [
{ 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.buildForm({
theme: null
})
this.availableLanguages = Object.entries(I18N_LOCALES).map(([ id, label ]) => ({ label, id }))
this.buildForm()
this.userInformationLoaded()
.subscribe(() => {
this.form.patchValue({
theme: this.user().theme
theme: this.user().theme,
language: this.user().language
})
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 () {
this.formValuesWatcher?.unsubscribe()
}
// ---------------------------------------------------------------------------
updateInterfaceSettings () {
const theme = this.form.value['theme']
if (this.updating) return
this.updating = true
const { theme, language } = this.form.value
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()) {
this.userService.updateMyProfile(details)
.pipe(switchMap(() => changeLanguageObs))
.subscribe({
next: () => {
if (changedLanguage) {
window.location.reload()
return
}
this.authService.refreshUserInformation()
this.updating = false
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)
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.`)
this.updating = false
}
getSubmitValue () {
return $localize`Save interface settings`
// return $localize`Save and reload the interface`
}
private getDefaultInstanceThemeLabel () {

View file

@ -1,8 +1,8 @@
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 { 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 { pick } from 'lodash-es'
import { Subject, Subscription } from 'rxjs'
@ -49,8 +49,8 @@ export class UserVideoSettingsComponent implements OnInit, OnDestroy {
private serverService = inject(ServerService)
readonly user = input<User>(null)
readonly reactiveUpdate = input(false)
readonly notifyOnUpdate = input(true)
readonly reactiveUpdate = input(false, { transform: booleanAttribute })
readonly notifyOnUpdate = input(true, { transform: booleanAttribute })
readonly userInformationLoaded = input<Subject<any>>(undefined)
form: FormGroup<Form>

View file

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

View file

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

View file

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

View file

@ -12,7 +12,7 @@ export class AuthHTTP {
private headers = new Headers()
constructor (private readonly serverUrl: string) {
constructor (private readonly serverUrl: string, private readonly language: string) {
this.userOAuthTokens = OAuthUserTokens.getUserTokens(peertubeLocalStorage)
if (this.userOAuthTokens) this.setHeadersFromTokens()
@ -22,6 +22,8 @@ export class AuthHTTP {
let refreshFetchOptions: { headers?: Headers } = {}
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 || optionalAuth) refreshFetchOptions = { headers: this.headers }

View file

@ -893,6 +893,10 @@ instance:
# Example: '2 vCore, 2GB RAM...'
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
# Uncomment or add the languages you want
# List of supported languages: https://peertube.cpy.re/api/v1/videos/languages

View file

@ -557,7 +557,7 @@ signup:
user:
history:
videos:
# Enable or disable video history by default for new users.
# Enable or disable video history by default for new users
enabled: true
# Default value of maximum video bytes the user can upload
@ -903,6 +903,10 @@ instance:
# Example: '2 vCore, 2GB RAM...'
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
# Uncomment or add the languages you want
# List of supported languages: https://peertube.cpy.re/api/v1/videos/languages

View file

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

View file

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

View file

@ -79,7 +79,7 @@ const I18N_LOCALE_ALIAS = {
'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 () {
return 'en-US'
@ -95,13 +95,13 @@ export function peertubeTranslate (str: string, translations?: { [id: string]: s
return translations[str]
}
const possiblePaths = POSSIBLE_LOCALES.map(l => '/' + l)
const possiblePaths = AVAILABLE_LOCALES.map(l => '/' + l)
export function is18nPath (path: string) {
return possiblePaths.includes(path)
}
export function is18nLocale (locale: string) {
return POSSIBLE_LOCALES.includes(locale)
return AVAILABLE_LOCALES.includes(locale)
}
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 () {
return {
allowedTags: [] as string[]
@ -56,7 +66,7 @@ export function getTextOnlySanitizeOptions () {
export function escapeHTML (stringParam: string) {
if (!stringParam) return ''
const entityMap: { [id: string ]: string } = {
const entityMap: { [id: string]: string } = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',

View file

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

View file

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

View file

@ -3,11 +3,17 @@ export interface Debug {
activityPubMessagesWaiting: number
}
export interface SendDebugCommand {
command: 'remove-dandling-resumable-uploads'
| 'process-video-views-buffer'
| 'process-video-viewers'
| 'process-video-channel-sync-latest'
| 'process-update-videos-scheduler'
| 'remove-expired-user-exports'
export type SendDebugCommand = {
command:
| 'remove-dandling-resumable-uploads'
| 'process-video-views-buffer'
| 'process-video-viewers'
| 'process-video-channel-sync-latest'
| '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 }
interface Base extends Partial<SendEmailDefaultMessageOptions> {
to: string[] | string
interface Base {
to: To[] | To
from?: From
subject?: string
replyTo?: string
}
interface MailTemplate extends Base {
@ -30,6 +35,8 @@ interface SendEmailDefaultLocalsOptions {
fg: string
bg: string
primary: string
language: string
logoUrl: string
}
interface SendEmailDefaultMessageOptions {

View file

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

View file

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

View file

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

View file

@ -18,10 +18,11 @@ import { unwrapBody } from '../requests/index.js'
import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
export class UsersCommand extends AbstractCommand {
askResetPassword (options: OverrideCommandOptions & {
email: string
}) {
askResetPassword (
options: OverrideCommandOptions & {
email: string
}
) {
const { email } = options
const path = '/api/v1/users/ask-reset-password'
@ -35,11 +36,13 @@ export class UsersCommand extends AbstractCommand {
})
}
resetPassword (options: OverrideCommandOptions & {
userId: number
verificationString: string
password: string
}) {
resetPassword (
options: OverrideCommandOptions & {
userId: number
verificationString: string
password: string
}
) {
const { userId, verificationString, password } = options
const path = '/api/v1/users/' + userId + '/reset-password'
@ -55,9 +58,11 @@ export class UsersCommand extends AbstractCommand {
// ---------------------------------------------------------------------------
askSendVerifyEmail (options: OverrideCommandOptions & {
email: string
}) {
askSendVerifyEmail (
options: OverrideCommandOptions & {
email: string
}
) {
const { email } = options
const path = '/api/v1/users/ask-send-verify-email'
@ -71,11 +76,13 @@ export class UsersCommand extends AbstractCommand {
})
}
verifyEmail (options: OverrideCommandOptions & {
userId: number
verificationString: string
isPendingEmail?: boolean // default false
}) {
verifyEmail (
options: OverrideCommandOptions & {
userId: number
verificationString: string
isPendingEmail?: boolean // default false
}
) {
const { userId, verificationString, isPendingEmail = false } = options
const path = '/api/v1/users/' + userId + '/verify-email'
@ -94,10 +101,12 @@ export class UsersCommand extends AbstractCommand {
// ---------------------------------------------------------------------------
banUser (options: OverrideCommandOptions & {
userId: number
reason?: string
}) {
banUser (
options: OverrideCommandOptions & {
userId: number
reason?: string
}
) {
const { userId, reason } = options
const path = '/api/v1/users' + '/' + userId + '/block'
@ -111,9 +120,11 @@ export class UsersCommand extends AbstractCommand {
})
}
unbanUser (options: OverrideCommandOptions & {
userId: number
}) {
unbanUser (
options: OverrideCommandOptions & {
userId: number
}
) {
const { userId } = options
const path = '/api/v1/users' + '/' + userId + '/unblock'
@ -154,15 +165,17 @@ export class UsersCommand extends AbstractCommand {
// ---------------------------------------------------------------------------
create (options: OverrideCommandOptions & {
username: string
password?: string
videoQuota?: number
videoQuotaDaily?: number
role?: UserRoleType
adminFlags?: UserAdminFlagType
email?: string
}) {
create (
options: OverrideCommandOptions & {
username: string
password?: string
videoQuota?: number
videoQuotaDaily?: number
role?: UserRoleType
adminFlags?: UserAdminFlagType
email?: string
}
) {
const {
username,
adminFlags,
@ -243,9 +256,11 @@ export class UsersCommand extends AbstractCommand {
})
}
getMyRating (options: OverrideCommandOptions & {
videoId: number | string
}) {
getMyRating (
options: OverrideCommandOptions & {
videoId: number | string
}
) {
const { videoId } = options
const path = '/api/v1/users/me/videos/' + videoId + '/rating'
@ -285,9 +300,11 @@ export class UsersCommand extends AbstractCommand {
})
}
updateMyAvatar (options: OverrideCommandOptions & {
fixture: string
}) {
updateMyAvatar (
options: OverrideCommandOptions & {
fixture: string
}
) {
const { fixture } = options
const path = '/api/v1/users/me/avatar/pick'
@ -305,10 +322,12 @@ export class UsersCommand extends AbstractCommand {
// ---------------------------------------------------------------------------
get (options: OverrideCommandOptions & {
userId: number
withStats?: boolean // default false
}) {
get (
options: OverrideCommandOptions & {
userId: number
withStats?: boolean // default false
}
) {
const { userId, withStats } = options
const path = '/api/v1/users/' + userId
@ -341,9 +360,11 @@ export class UsersCommand extends AbstractCommand {
})
}
remove (options: OverrideCommandOptions & {
userId: number
}) {
remove (
options: OverrideCommandOptions & {
userId: number
}
) {
const { userId } = options
const path = '/api/v1/users/' + userId
@ -356,17 +377,19 @@ export class UsersCommand extends AbstractCommand {
})
}
update (options: OverrideCommandOptions & {
userId: number
email?: string
emailVerified?: boolean
videoQuota?: number
videoQuotaDaily?: number
password?: string
adminFlags?: UserAdminFlagType
pluginAuth?: string
role?: UserRoleType
}) {
update (
options: OverrideCommandOptions & {
userId: number
email?: string
emailVerified?: boolean
videoQuota?: number
videoQuotaDaily?: number
password?: string
adminFlags?: UserAdminFlagType
pluginAuth?: string
role?: UserRoleType
}
) {
const path = '/api/v1/users/' + options.userId
const toSend: UserUpdate = {}
@ -388,4 +411,24 @@ export class UsersCommand extends AbstractCommand {
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 */
import { MockSmtpServer } from '@tests/shared/mock-servers/index.js'
import { HttpStatusCode } from '@peertube/peertube-models'
import {
cleanupTests,
@ -10,6 +9,7 @@ import {
killallServers,
PeerTubeServer
} from '@peertube/peertube-server-commands'
import { MockSmtpServer } from '@tests/shared/mock-servers/index.js'
describe('Test contact form API validators', function () {
let server: PeerTubeServer
@ -79,7 +79,7 @@ describe('Test contact form API validators', function () {
})
after(async function () {
MockSmtpServer.Instance.kill()
await MockSmtpServer.Instance.kill()
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 () {
const fields = { theme: 'invalid' }
await makePutBodyRequest({ url: server.url, path: path + 'me', token: userToken, fields })
@ -293,7 +299,8 @@ describe('Test my user API validators', function () {
theme: 'default',
noInstanceConfigWarningModal: true,
noWelcomeModal: true,
noAccountSetupWarningModal: true
noAccountSetupWarningModal: true,
language: 'fr'
}
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 () {
it('Should fail with with the root account', async function () {
await server.users.deleteMe({ expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
@ -552,7 +575,7 @@ describe('Test my user API validators', function () {
})
after(async function () {
MockSmtpServer.Instance.kill()
await MockSmtpServer.Instance.kill()
await cleanupTests([ server ])
})

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -396,7 +396,6 @@ describe('Test user notifications', function () {
})
describe('My live replay is published', function () {
let baseParams: CheckerBaseParams
before(() => {
@ -640,7 +639,7 @@ describe('Test user notifications', function () {
})
after(async function () {
MockSmtpServer.Instance.kill()
await MockSmtpServer.Instance.kill()
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.serverCountry).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.blueskyLink).to.be.empty
expect(data.instance.social.mastodonLink).to.be.empty
@ -189,6 +190,7 @@ function buildNewCustomConfig (server: PeerTubeServer): CustomConfig {
support: {
text: 'My support text'
},
defaultLanguage: 'fr',
social: {
externalLink: 'https://joinpeertube.org/',
mastodonLink: 'https://framapiaf.org/@peertube',
@ -984,9 +986,9 @@ describe('Test config', function () {
expect(body.short_name).to.equal(body.name)
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.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 () {

View file

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

View file

@ -1,6 +1,6 @@
/* 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 {
cleanupTests,
ConfigCommand,
@ -12,7 +12,9 @@ import {
import { expectStartWith } from '@tests/shared/checks.js'
import { MockSmtpServer } from '@tests/shared/mock-servers/index.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 () {
let server: PeerTubeServer
@ -256,7 +258,7 @@ describe('Test emails', function () {
expect(email['from'][0]['name']).equal('PeerTube')
expect(email['from'][0]['address']).equal('test-admin@127.0.0.1')
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 reason')
})
@ -272,7 +274,7 @@ describe('Test emails', function () {
expect(email['from'][0]['name']).equal('PeerTube')
expect(email['from'][0]['address']).equal('test-admin@127.0.0.1')
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')
})
@ -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 () {
MockSmtpServer.Instance.kill()
await MockSmtpServer.Instance.kill()
await cleanupTests([ server ])
})

View file

@ -27,6 +27,13 @@ describe('Test registrations', function () {
await setAccessTokensToServers([ server ])
await server.config.enableSignup(false)
await server.config.updateExistingConfig({
newConfig: {
instance: {
defaultLanguage: 'fr'
}
}
})
})
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')
expect(email).to.exist
expect(email['subject']).to.contain('been rejected')
expect(email['text']).to.contain('been rejected')
expect(email['subject']).to.contain('été rejetée')
expect(email['text']).to.contain('été rejetée')
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')
expect(email).to.exist
expect(email['subject']).to.contain('been accepted')
expect(email['text']).to.contain('been accepted')
expect(email['subject']).to.contain('été acceptée')
expect(email['text']).to.contain('été acceptée')
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')
expect(email).to.exist
expect(email['subject']).to.contain('been accepted')
expect(email['text']).to.contain('been accepted')
expect(email['subject']).to.contain('été acceptée')
expect(email['text']).to.contain('été acceptée')
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.role.id).to.equal(UserRole.USER)
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 () {
@ -271,6 +279,7 @@ describe('Test registrations', function () {
expect(me.videoChannels[0].displayName).to.equal('my user 3 channel')
expect(me.role.id).to.equal(UserRole.USER)
expect(me.email).to.equal('user3@example.com')
expect(me.language).to.equal('fr')
})
it('Should list these accepted/rejected registration requests', async function () {
@ -408,7 +417,7 @@ describe('Test registrations', function () {
})
after(async function () {
MockSmtpServer.Instance.kill()
await MockSmtpServer.Instance.kill()
await cleanupTests([ server ])
})

View file

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

View file

@ -170,6 +170,7 @@ function runTest (withObjectStorage: boolean) {
const me = await remoteServer.users.getMyInfo({ token: remoteNoahToken })
expect(me.p2pEnabled).to.be.false
expect(me.language).to.equal('fr')
const settings = me.notificationSettings
@ -593,11 +594,11 @@ function runTest (withObjectStorage: boolean) {
it('Should have received an email on finished import', async function () {
const email = emails.reverse().find(e => {
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['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 () {
@ -715,7 +716,7 @@ function runTest (withObjectStorage: boolean) {
})
after(async function () {
MockSmtpServer.Instance.kill()
await MockSmtpServer.Instance.kill()
await cleanupTests([ server, remoteServer, blockedServer ])
})

View file

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

View file

@ -1,14 +1,9 @@
/* 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 {
cleanupTests,
createSingleServer,
PeerTubeServer,
setAccessTokensToServers
} from '@peertube/peertube-server-commands'
import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@peertube/peertube-server-commands'
import { testAvatarSize } from '@tests/shared/checks.js'
import { expect } from 'chai'
describe('Test users', function () {
let server: PeerTubeServer
@ -60,6 +55,7 @@ describe('Test users', function () {
expect(user.id).to.be.a('number')
expect(user.account.displayName).to.equal('user_1')
expect(user.account.description).to.be.null
expect(user.language).to.equal('en')
}
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.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 () {
@ -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 () {
await cleanupTests([ server ])
})

View file

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

View file

@ -262,7 +262,7 @@ export async function prepareImportExportTests (options: {
})
// 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
await server.notifications.updateMySettings({

View file

@ -1,17 +1,24 @@
import MailDev from '@peertube/maildev'
import { randomInt } from '@peertube/peertube-core-utils'
import { parallelTests } from '@peertube/peertube-node-utils'
import MailDev from 'maildev'
class MockSmtpServer {
private static instance: MockSmtpServer
private started = false
private maildev: any
private emails: object[]
private relayingEmail: Promise<void>
private constructor () { }
private
private constructor () {}
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) => {
const port = parallelTests() ? randomInt(1025, 2000) : 1025
this.emails = emailsCollection
@ -24,11 +31,26 @@ class MockSmtpServer {
ip: '127.0.0.1',
smtp: port,
disableWeb: true,
silent: true
silent: true,
outgoingHost,
outgoingPort
})
this.maildev.on('new', 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 => {
@ -41,9 +63,13 @@ class MockSmtpServer {
})
}
kill () {
async kill () {
if (!this.maildev) return
if (this.relayingEmail) {
await this.relayingEmail
}
this.maildev.close()
this.maildev = null

View file

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

View file

@ -25,3 +25,7 @@ node ./node_modules/.bin/xliffmerge -p ./.xliffmerge.json $locales
# Add our strings too
cd ../
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