Init commit

This commit is contained in:
ShreejitPanchal
2026-05-19 19:56:02 +08:00
commit 6601501eff
4047 changed files with 2185372 additions and 0 deletions

4
client/.browserslistrc Normal file
View File

@@ -0,0 +1,4 @@
last 1 Chrome version
last 2 Edge major versions
Firefox ESR
ios_saf >= 14

View File

@@ -0,0 +1,104 @@
---
description: 'Angular-specific coding standards and best practices'
applyTo: 'src/app/**/*.ts, src/app/**/*.html, src/app/**/*.scss, src/app/**/*.css'
---
# Angular Development Instructions
Instructions for generating high-quality Angular applications with TypeScript, using Angular Signals for state management, adhering to Angular best practices as outlined at https://angular.dev.
## Project Context
- Latest Angular version (use standalone components by default)
- TypeScript for type safety
- Angular CLI for project setup and scaffolding
- Follow Angular Style Guide (https://angular.dev/style-guide)
- Use Angular Material or other modern UI libraries for consistent styling (if specified)
## Development Standards
### Architecture
- Use standalone components unless modules are explicitly required
- Organize code by feature modules or domains for scalability
- Implement lazy loading for feature modules to optimize performance
- Use Angular's built-in dependency injection system effectively
- Structure components with a clear separation of concerns (smart vs. presentational components)
### TypeScript
- Enable strict mode in `tsconfig.json` for type safety
- Define clear interfaces and types for components, services, and models
- Use type guards and union types for robust type checking
- Implement proper error handling with RxJS operators (e.g., `catchError`)
- Use typed forms (e.g., `FormGroup`, `FormControl`) for reactive forms
### Component Design
- Follow Angular's component lifecycle hooks best practices
- When using Angular >= 19, Use `input()` `output()`, `viewChild()`, `viewChildren()`, `contentChild()` and `viewChildren()` functions instead of decorators; otherwise use decorators
- Leverage Angular's change detection strategy (default or `OnPush` for performance)
- Keep templates clean and logic in component classes or services
- Use Angular directives and pipes for reusable functionality
### Styling
- Use Angular's component-level CSS encapsulation (default: ViewEncapsulation.Emulated)
- Prefer SCSS for styling with consistent theming
- Implement responsive design using CSS Grid, Flexbox, or Angular CDK Layout utilities
- Follow Angular Material's theming guidelines if used
- Maintain accessibility (a11y) with ARIA attributes and semantic HTML
### State Management
- Use Angular Signals for reactive state management in components and services
- Leverage `signal()`, `computed()`, and `effect()` for reactive state updates
- Use writable signals for mutable state and computed signals for derived state
- Handle loading and error states with signals and proper UI feedback
- Use Angular's `AsyncPipe` to handle observables in templates when combining signals with RxJS
### Data Fetching
- Use Angular's `HttpClient` for API calls with proper typing
- Implement RxJS operators for data transformation and error handling
- Use Angular's `inject()` function for dependency injection in standalone components
- Implement caching strategies (e.g., `shareReplay` for observables)
- Store API response data in signals for reactive updates
- Handle API errors with global interceptors for consistent error handling
### Security
- Sanitize user inputs using Angular's built-in sanitization
- Implement route guards for authentication and authorization
- Use Angular's `HttpInterceptor` for CSRF protection and API authentication headers
- Validate form inputs with Angular's reactive forms and custom validators
- Follow Angular's security best practices (e.g., avoid direct DOM manipulation)
### Performance
- Enable production builds with `ng build --prod` for optimization
- Use lazy loading for routes to reduce initial bundle size
- Optimize change detection with `OnPush` strategy and signals for fine-grained reactivity
- Use trackBy in `ngFor` loops to improve rendering performance
- Implement server-side rendering (SSR) or static site generation (SSG) with Angular Universal (if specified)
### Testing
- Write unit tests for components, services, and pipes using Jasmine and Karma
- Use Angular's `TestBed` for component testing with mocked dependencies
- Test signal-based state updates using Angular's testing utilities
- Write end-to-end tests with Cypress or Playwright (if specified)
- Mock HTTP requests using `HttpClientTestingModule`
- Ensure high test coverage for critical functionality
## Implementation Process
1. Plan project structure and feature modules
2. Define TypeScript interfaces and models
3. Scaffold components, services, and pipes using Angular CLI
4. Implement data services and API integrations with signal-based state
5. Build reusable components with clear inputs and outputs
6. Add reactive forms and validation
7. Apply styling with SCSS and responsive design
8. Implement lazy-loaded routes and guards
9. Add error handling and loading states using signals
10. Write unit and end-to-end tests
11. Optimize performance and bundle size
## Additional Guidelines
- Follow Angular's naming conventions (e.g., `feature.component.ts`, `feature.service.ts`)
- Use Angular CLI commands for generating boilerplate code
- Document components and services with clear JSDoc comments
- Ensure accessibility compliance (WCAG 2.1) where applicable
- Use Angular's built-in i18n for internationalization (if specified)
- Keep code DRY by creating reusable utilities and shared modules
- Use signals consistently for state management to ensure reactive updates

View File

@@ -0,0 +1,112 @@
# PeerTube Client Development Instructions for Coding Agents
## Client Overview
This is the Angular frontend for PeerTube, a decentralized video hosting platform. The client is built with Angular 20+, TypeScript, and SCSS. It communicates with the PeerTube server API and provides the web interface for users, administrators, and content creators.
**Key Technologies:**
- Angular 20+ with standalone components
- TypeScript 5+
- SCSS for styling
- RxJS for reactive programming
- PrimeNg and Bootstrap for UI components
- WebdriverIO for E2E testing
- Angular CLI
## Client Build and Development Commands
### Prerequisites (for client development)
- Node.js 20+
- yarn 1
- Running PeerTube server (see ../server instructions)
### Essential Client Commands
```bash
# From the client directory:
cd /client
# 1. Install dependencies (ALWAYS first)
yarn install --pure-lockfile
# 2. Development server with hot reload
npm run dev
# 3. Build for production
npm run build
```
### Client Testing Commands
```bash
# From client directory:
npm run lint # ESLint for client code
```
### Common Client Issues and Solutions
**Angular Build Failures:**
- Always run `yarn install --pure-lockfile` after pulling changes
- Clear `node_modules` and reinstall if dependency errors occur
- Build may fail on memory issues: `NODE_OPTIONS="--max-old-space-size=4096" npm run build`
- Check TypeScript errors carefully - Angular is strict about types
**Development Server Issues:**
- Default port is 3000, ensure it's not in use
- Hot reload may fail on file permission issues
- Clear browser cache if changes don't appear
## Client Architecture and File Structure
### Client Directory Structure
```
/src/
/app/
+admin/ # Admin interface components
+my-account/ # User account management pages
+my-library/ # User's videos, playlists, subscriptions
+search/ # Search functionality and results
+shared/ # Shared Angular components, services, pipes
+standalone/ # Standalone Angular components
+videos/ # Video-related components (watch, upload, etc.)
/core/ # Core services (auth, server, notifications)
/helpers/ # Utility functions and helpers
/menu/ # Navigation menu components
/assets/ # Static assets (images, icons, etc.)
/environments/ # Environment configurations
/locale/ # Internationalization files
/sass/ # Global SCSS styles
```
### Key Client Configuration Files
- `angular.json` - Angular CLI workspace configuration
- `tsconfig.json` - TypeScript configuration for client
- `e2e/wdio*.conf.js` - WebdriverIO E2E test configurations
- `src/environments/` - Environment-specific configurations
### Shared Code with Server (`../shared/`)
The client imports TypeScript models and utilities from the shared directory:
- `../shared/models/` - Data models (Video, User, Channel, etc.). Import these in client code: `import { Video } from '@peertube/peertube-models'`
- `../shared/core-utils/` - Utility functions shared between client/server. Import these in client code: `import { ... } from '@peertube/peertube-core-utils'`
-
## Client Development Workflow
### Making Client Changes
1. **Angular Components:** Create/modify in `/src/app/` following existing patterns
2. **Shared Components:** Reusable components go in `/src/app/shared/`
3. **Services:** Core services in `/src/app/core/`, feature services with components
4. **Styles:** Component styles in `.scss` files, global styles in `/src/sass/`
5. **Assets:** Images, icons in `/src/assets/`
6. **Routing:** Routes defined in feature modules or `app-routing.module.ts`
## Trust These Instructions
These instructions are comprehensive and tested specifically for client development. Only search for additional information if:
1. Commands fail despite following instructions exactly
2. New error messages appear that aren't documented here
3. You need specific Angular implementation details not covered above
For server-side questions, refer to the server instructions in `../.github/copilot-instructions.md`.

19
client/.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
/.angular/cache
/dist/
/node_modules
/compiled
/stats.json
/dll
/.awcache
/src/locale/pending_target/
/src/locale/target/iso639_*.xml
/src/locale/target/player_*.xml
/src/locale/target/server_*.xml
/e2e/local.log
/e2e/browserstack.err
/e2e/screenshots
/src/standalone/player/build
/src/standalone/player/dist
/src/standalone/embed-player-api/build
/src/standalone/embed-player-api/dist
/e2e/logs

75
client/.stylelintrc.json Normal file
View File

@@ -0,0 +1,75 @@
{
"extends": "stylelint-config-sass-guidelines",
"plugins": [
"stylelint-order"
],
"rules": {
"declaration-empty-line-before": [
"always",
{
"except": [
"first-nested"
],
"ignore": [ "after-declaration", "after-comment" ]
}
],
"at-rule-empty-line-before": [
"always",
{
"except": [
"first-nested",
"blockless-after-blockless"
],
"ignore": [ "after-comment" ],
"ignoreAtRules": [ "else" ]
}
],
"order/order": [
"custom-properties",
"declarations",
{
"type": "at-rule",
"name": "include"
}
],
"scss/selector-no-redundant-nesting-selector": null,
"scss/at-import-no-partial-leading-underscore": null,
"color-hex-length": null,
"selector-pseudo-element-no-unknown": [
true,
{
"ignorePseudoElements": [
"ng-deep"
]
}
],
"max-nesting-depth": [
8,
{
"ignore": [
"blockless-at-rules",
"pseudo-classes"
]
}
],
"selector-max-compound-selectors": 9,
"selector-no-qualifying-type": null,
"scss/at-extend-no-missing-placeholder": null,
"rule-empty-line-before": null,
"selector-max-id": null,
"scss/at-function-pattern": null,
"scss/load-no-partial-leading-underscore": null,
"@stylistic/string-quotes": null,
"@stylistic/color-hex-case": null,
"@stylistic/function-parentheses-space-inside": null,
"property-no-vendor-prefix": [
true,
{
"ignoreProperties": [
"mask-image",
"mask-size"
]
}
]
}
}

9
client/.xliffmerge.json Normal file
View File

@@ -0,0 +1,9 @@
{
"xliffmergeOptions": {
"i18nFormat": "xlf",
"srcDir": "src/locale",
"genDir": "src/locale",
"i18nBaseFile": "angular",
"defaultLanguage": "en-US"
}
}

363
client/angular.json Normal file
View File

@@ -0,0 +1,363 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"PeerTube": {
"root": "",
"sourceRoot": "src",
"projectType": "application",
"i18n": {
"sourceLocale": {
"code": "en",
"baseHref": "/client/en-US/"
},
"locales": {
"ar": {
"translation": "src/locale/angular.ar.xlf",
"baseHref": "/client/ar/"
},
"sk": {
"translation": "src/locale/angular.sk-SK.xlf",
"baseHref": "/client/sk-SK/"
},
"fa": {
"translation": "src/locale/angular.fa-IR.xlf",
"baseHref": "/client/fa-IR/"
},
"hu": {
"translation": "src/locale/angular.hu-HU.xlf",
"baseHref": "/client/hu-HU/"
},
"th": {
"translation": "src/locale/angular.th-TH.xlf",
"baseHref": "/client/th-TH/"
},
"tr": {
"translation": "src/locale/angular.tr-TR.xlf",
"baseHref": "/client/tr-TR/"
},
"fi": {
"translation": "src/locale/angular.fi-FI.xlf",
"baseHref": "/client/fi-FI/"
},
"nl": {
"translation": "src/locale/angular.nl-NL.xlf",
"baseHref": "/client/nl-NL/"
},
"gd": {
"translation": "src/locale/angular.gd.xlf",
"baseHref": "/client/gd/"
},
"el": {
"translation": "src/locale/angular.el-GR.xlf",
"baseHref": "/client/el-GR/"
},
"es": {
"translation": "src/locale/angular.es-ES.xlf",
"baseHref": "/client/es-ES/"
},
"oc": {
"translation": "src/locale/angular.oc.xlf",
"baseHref": "/client/oc/"
},
"pt": {
"translation": "src/locale/angular.pt-BR.xlf",
"baseHref": "/client/pt-BR/"
},
"pt-PT": {
"translation": "src/locale/angular.pt-PT.xlf",
"baseHref": "/client/pt-PT/"
},
"sv": {
"translation": "src/locale/angular.sv-SE.xlf",
"baseHref": "/client/sv-SE/"
},
"pl": {
"translation": "src/locale/angular.pl-PL.xlf",
"baseHref": "/client/pl-PL/"
},
"ru": {
"translation": "src/locale/angular.ru-RU.xlf",
"baseHref": "/client/ru-RU/"
},
"sq": {
"translation": "src/locale/angular.sq.xlf",
"baseHref": "/client/sq/"
},
"hr": {
"translation": "src/locale/angular.hr.xlf",
"baseHref": "/client/hr/"
},
"zh-Hans": {
"translation": "src/locale/angular.zh-Hans-CN.xlf",
"baseHref": "/client/zh-Hans-CN/"
},
"zh-Hant": {
"translation": "src/locale/angular.zh-Hant-TW.xlf",
"baseHref": "/client/zh-Hant-TW/"
},
"fr": {
"translation": "src/locale/angular.fr-FR.xlf",
"baseHref": "/client/fr-FR/"
},
"ja": {
"translation": "src/locale/angular.ja-JP.xlf",
"baseHref": "/client/ja-JP/"
},
"eu": {
"translation": "src/locale/angular.eu-ES.xlf",
"baseHref": "/client/eu-ES/"
},
"ca": {
"translation": "src/locale/angular.ca-ES.xlf",
"baseHref": "/client/ca-ES/"
},
"gl": {
"translation": "src/locale/angular.gl-ES.xlf",
"baseHref": "/client/gl-ES/"
},
"cs": {
"translation": "src/locale/angular.cs-CZ.xlf",
"baseHref": "/client/cs-CZ/"
},
"eo": {
"translation": "src/locale/angular.eo.xlf",
"baseHref": "/client/eo/"
},
"de": {
"translation": "src/locale/angular.de-DE.xlf",
"baseHref": "/client/de-DE/"
},
"it": {
"translation": "src/locale/angular.it-IT.xlf",
"baseHref": "/client/it-IT/"
},
"vi": {
"translation": "src/locale/angular.vi-VN.xlf",
"baseHref": "/client/vi-VN/"
},
"kab": {
"translation": "src/locale/angular.kab.xlf",
"baseHref": "/client/kab/"
},
"nb": {
"translation": "src/locale/angular.nb-NO.xlf",
"baseHref": "/client/nb-NO/"
},
"tok": {
"translation": "src/locale/angular.tok.xlf",
"baseHref": "/client/tok/"
},
"nn": {
"translation": "src/locale/angular.nn.xlf",
"baseHref": "/client/nn/"
},
"is": {
"translation": "src/locale/angular.is.xlf",
"baseHref": "/client/is/"
},
"uk": {
"translation": "src/locale/angular.uk-UA.xlf",
"baseHref": "/client/uk-UA/"
}
}
},
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"i18nMissingTranslation": "ignore",
"localize": true,
"outputPath": {
"base": "dist"
},
"index": "src/index.html",
"tsConfig": "tsconfig.json",
"polyfills": [
"src/polyfills.ts",
"@angular/localize/init"
],
"baseHref": "/",
"stylePreprocessorOptions": {
"includePaths": [
"src/sass/include",
"."
],
"sass": {
"silenceDeprecations": [ "import", "color-functions", "global-builtin" ]
}
},
"assets": [
"src/assets/images"
],
"styles": [
"src/sass/application.scss"
],
"allowedCommonJsDependencies": [
"qrcode",
"chart.js",
"htmlparser2",
"markdown-it-emoji/light",
"linkifyjs/lib/linkify-html",
"linkifyjs/lib/plugins/mention",
"sanitize-html",
"debug",
"@peertube/p2p-media-loader-hlsjs",
"video.js",
"sha.js",
"postcss",
"focus-visible",
"path-browserify",
"deep-merge",
"escape-string-regexp",
"is-plain-object",
"parse-srcset",
"core-js/features/reflect",
"hammerjs",
"jschannel",
"color-bits"
],
"scripts": [],
"extractLicenses": false,
"sourceMap": true,
"optimization": false,
"namedChunks": true,
"browser": "src/main.ts",
"loader": {
".svg": "text"
}
},
"configurations": {
"production": {
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"extractLicenses": true,
"serviceWorker": "src/ngsw-config.json",
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "140kb"
}
],
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
]
},
"ar-locale": {
"localize": [
"ar"
],
"budgets": [
{
"type": "anyComponentStyle",
"maximumWarning": "6kb"
}
],
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.hmr.ts"
}
]
},
"hmr": {
"localize": false,
"budgets": [
{
"type": "anyComponentStyle",
"maximumWarning": "6kb"
}
],
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.hmr.ts"
}
]
}
}
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"proxyConfig": "proxy.config.json",
"buildTarget": "PeerTube:build"
},
"configurations": {
"hmr": {
"buildTarget": "PeerTube:build:hmr"
},
"ar-locale": {
"buildTarget": "PeerTube:build:ar-locale"
}
}
},
"extract-i18n": {
"builder": "@angular/build:extract-i18n"
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"e2e/**/*.ts",
"src/**/*.ts",
"src/**/*.html"
]
}
}
}
}
},
"schematics": {
"@schematics/angular:component": {
"prefix": "my",
"style": "scss",
"skipTests": true,
"flat": true,
"type": "component"
},
"@schematics/angular:directive": {
"prefix": "my",
"type": "directive"
},
"@angular-eslint/schematics:application": {
"setParserOptionsProject": true
},
"@angular-eslint/schematics:library": {
"setParserOptionsProject": true
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
},
"cli": {
"analytics": false
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,12 @@
browser.addCommand('chooseFile', async function (this: WebdriverIO.Element, localFilePath: string) {
try {
const remoteFile = await browser.uploadFile(localFilePath)
return this.addValue(remoteFile)
} catch {
console.log('Cannot upload file, fallback to add value.')
// Firefox does not support upload file, but if we're running the test in local we don't really need it
return this.addValue(localFilePath)
}
}, true)

View File

@@ -0,0 +1,73 @@
import { NSFWPolicyType } from '@peertube/peertube-models'
import { browserSleep, go, setCheckboxEnabled } from '../utils'
export class AdminConfigPage {
async navigateTo (page: 'information' | 'live' | 'general' | 'homepage') {
const url = '/admin/settings/config/' + page
const currentUrl = await browser.getUrl()
if (!currentUrl.endsWith(url)) {
await go(url)
}
await $('a.active[href="' + url + '"]').waitForDisplayed()
}
async updateNSFWSetting (newValue: NSFWPolicyType) {
await this.navigateTo('information')
const elem = $(`#instanceDefaultNSFWPolicy-${newValue} + label`)
await elem.waitForDisplayed()
await elem.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header
await elem.waitForClickable()
return elem.click()
}
async updateHomepage (newValue: string) {
await this.navigateTo('homepage')
return $('#homepageContent').setValue(newValue)
}
async toggleSignup (enabled: boolean) {
await this.navigateTo('general')
return setCheckboxEnabled('signupEnabled', enabled)
}
async toggleSignupApproval (required: boolean) {
await this.navigateTo('general')
return setCheckboxEnabled('signupRequiresApproval', required)
}
async toggleSignupEmailVerification (required: boolean) {
await this.navigateTo('general')
return setCheckboxEnabled('signupRequiresEmailVerification', required)
}
async toggleLive (enabled: boolean) {
await this.navigateTo('live')
return setCheckboxEnabled('liveEnabled', enabled)
}
async save () {
const button = $('my-admin-save-bar .save-button')
try {
await button.waitForClickable()
} catch {
// The config may have not been changed
return
} finally {
await browserSleep(1000) // Wait for the button to be clickable
}
await button.click()
await button.waitForClickable({ reverse: true })
}
}

View File

@@ -0,0 +1,31 @@
import { browserSleep, go } from '../utils'
export class AdminPluginPage {
async navigateToPluginSearch () {
await go('/admin/settings/plugins/search')
await $('my-plugin-search').waitForDisplayed()
}
async search (name: string) {
const input = $('.search-bar input')
await input.waitForDisplayed()
await input.clearValue()
await input.setValue(name)
await browserSleep(1000)
}
async installHelloWorld () {
$('.plugin-name=hello-world').waitForDisplayed()
await $('.card-body my-button[icon=cloud-download]').click()
const submitModalButton = $('.modal-content input[type=submit]')
await submitModalButton.waitForClickable()
await submitModalButton.click()
await $('.card-body my-edit-button').waitForDisplayed()
}
}

View File

@@ -0,0 +1,33 @@
import { browserSleep, findParentElement, go } from '../utils'
export class AdminRegistrationPage {
async navigateToRegistrationsList () {
await go('/admin/moderation/registrations/list')
await $('my-registration-list').waitForDisplayed()
}
async accept (username: string, moderationResponse: string) {
const usernameEl = $('*=' + username)
await usernameEl.waitForDisplayed()
const tr = await findParentElement(usernameEl, async el => await el.getTagName() === 'tr')
await tr.$('.action-cell .dropdown-root').click()
const accept = $('span*=Accept this request')
await accept.waitForClickable()
await accept.click()
const moderationResponseTextarea = $('#moderationResponse')
await moderationResponseTextarea.waitForDisplayed()
await moderationResponseTextarea.setValue(moderationResponse)
const submitButton = $('.modal-footer input[type=submit]')
await submitButton.waitForClickable()
await submitButton.click()
await browserSleep(1000)
}
}

View File

@@ -0,0 +1,24 @@
export class AdminUserPage {
async createUser (options: {
username: string
password: string
}) {
const { username, password } = options
await $('.menu-link[title=Overview]').click()
await $('a*=Create user').click()
await $('#username').waitForDisplayed()
await $('#username').setValue(username)
await $('#password').setValue(password)
await $('#channelName').setValue(`${username}_channel`)
await $('#email').setValue(`${username}@example.com`)
const submit = $('my-user-create .primary-button')
await submit.scrollIntoView()
await submit.waitForClickable()
await submit.click()
await $('.cell-username*=' + username).waitForDisplayed()
}
}

View File

@@ -0,0 +1,45 @@
import { NSFWPolicyType } from '@peertube/peertube-models'
import { getCheckbox } from '../utils'
export class AnonymousSettingsPage {
async openSettings () {
const link = $('my-header .settings-button')
await link.waitForClickable()
await link.click()
await $('my-user-video-settings').waitForDisplayed()
}
async closeSettings () {
const closeModal = $('.modal.show .modal-header > button')
await closeModal.waitForClickable()
await closeModal.click()
await $('.modal.show').waitForDisplayed({ reverse: true })
}
async clickOnP2PCheckbox () {
const p2p = await getCheckbox('p2pEnabled')
await p2p.waitForClickable()
await p2p.click()
}
async updateNSFW (newValue: NSFWPolicyType) {
const nsfw = $(`#nsfwPolicy-${newValue} + label`)
await nsfw.waitForClickable()
await nsfw.click()
await $(`#nsfwPolicy-${newValue}:checked`).waitForExist()
}
async updateViolentFlag (newValue: NSFWPolicyType) {
const nsfw = $(`#nsfwFlagViolent-${newValue} + label`)
await nsfw.waitForClickable()
await nsfw.click()
await $(`#nsfwFlagViolent-${newValue}:checked`).waitForExist()
}
}

View File

@@ -0,0 +1,97 @@
import { browserSleep, go, isAndroid } from '../utils'
export class LoginPage {
constructor (private isMobileDevice: boolean) {
}
async login (options: {
username: string
password: string
displayName?: string
url?: string
}) {
const { username, password, url = '/login', displayName = username } = options
await go(url)
await browser.execute(`window.localStorage.setItem('no_account_setup_warning_modal', 'true')`)
await browser.execute(`window.localStorage.setItem('no_instance_config_warning_modal', 'true')`)
await browser.execute(`window.localStorage.setItem('no_welcome_modal', 'true')`)
await $('input#username').setValue(username)
await $('input#password').setValue(password)
await browserSleep(1000)
const submit = $('.login-form-and-externals > form input[type=submit]')
await submit.click()
// Have to do this on Android, don't really know why
// I think we need to "escape" from the password input, so click twice on the submit button
if (isAndroid()) {
await browserSleep(2000)
await submit.click()
}
await this.ensureIsLoggedInAs(displayName)
}
async getLoginError (username: string, password: string) {
await go('/login')
await $('input#username').setValue(username)
await $('input#password').setValue(password)
await browser.pause(1000)
await $('form input[type=submit]').click()
return $('.alert-danger').getText()
}
loginAsRootUser () {
return this.login({ username: 'root', password: this.getRootPassword() })
}
getRootPassword () {
return 'test' + this.getSuffix()
}
loginOnPeerTube2 () {
if (!process.env.PEERTUBE2_E2E_PASSWORD) {
throw new Error('PEERTUBE2_E2E_PASSWORD env is missing for user e2e on peertube2.cpy.re')
}
return this.login({ username: 'e2e', password: process.env.PEERTUBE2_E2E_PASSWORD, url: 'https://peertube2.cpy.re/login' })
}
async logout () {
const loggedInDropdown = $('.logged-in-container .dropdown-toggle')
await loggedInDropdown.waitForClickable()
await loggedInDropdown.click()
const logout = $('.dropdown-item.logout')
await logout.waitForClickable()
await logout.click()
await browser.waitUntil(() => {
return $$('my-login-link, my-error-page a[href="/login"]').some(e => e.isDisplayed())
})
}
async ensureIsLoggedInAs (displayName: string) {
await this.getLoggedInInfoElem(displayName).waitForExist()
}
private getLoggedInInfoElem (displayName: string) {
return $('.logged-in-info').$('.display-name*=' + displayName)
}
private getSuffix () {
return browser.options.baseUrl
? browser.options.baseUrl.slice(-1)
: '1'
}
}

View File

@@ -0,0 +1,203 @@
import { NSFWPolicyType } from '@peertube/peertube-models'
import { getCheckbox, go, selectCustomSelect } from '../utils'
export class MyAccountPage {
navigateToMyVideos () {
return $('a[href="/my-library/videos"]').click()
}
navigateToMyPlaylists () {
return $('a[href="/my-library/video-playlists"]').click()
}
navigateToMyHistory () {
return $('a[href="/my-library/history/videos"]').click()
}
// ---------------------------------------------------------------------------
// My account settings
// ---------------------------------------------------------------------------
navigateToMySettings () {
return $('a[href="/my-account"]').click()
}
async updateNSFW (newValue: NSFWPolicyType) {
const nsfw = $(`#nsfwPolicy-${newValue} + label`)
await nsfw.waitForDisplayed()
await nsfw.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header
await nsfw.waitForClickable()
await nsfw.click()
await this.submitVideoSettings()
}
async updateViolentFlag (newValue: NSFWPolicyType) {
const nsfw = $(`#nsfwFlagViolent-${newValue} + label`)
await nsfw.waitForDisplayed()
await nsfw.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header
await nsfw.waitForClickable()
await nsfw.click()
await this.submitVideoSettings()
}
async clickOnP2PCheckbox () {
const p2p = await getCheckbox('p2pEnabled')
await p2p.waitForClickable()
await p2p.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header
await p2p.click()
await this.submitVideoSettings()
}
private async submitVideoSettings () {
const submit = $('my-user-video-settings input[type=submit]')
await submit.waitForClickable()
await submit.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header
await submit.click()
}
async updateEmail (email: string, password: string) {
const emailInput = $('my-account-change-email #new-email')
await emailInput.waitForDisplayed()
await emailInput.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header
await emailInput.setValue(email)
const passwordInput = $('my-account-change-email #password')
await passwordInput.waitForDisplayed()
await passwordInput.setValue(password)
const submit = $('my-account-change-email input[type=submit]')
await submit.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header
await submit.waitForClickable()
await submit.click()
}
// ---------------------------------------------------------------------------
// My account videos
// ---------------------------------------------------------------------------
async removeVideo (name: string) {
const container = await this.getVideoRow(name)
await container.$('my-action-dropdown .dropdown-toggle').click()
const deleteItem = () => {
return $$('.dropdown-menu .dropdown-item').find<WebdriverIO.Element>(async v => {
const text = await v.getText()
return text.includes('Delete')
})
}
await (await deleteItem()).waitForClickable()
return (await deleteItem()).click()
}
validRemove () {
return $('input[type=submit]').click()
}
async countVideos (names: string[]) {
const elements = await $$('.video-cell-name .name').filter(async e => {
const t = await e.getText()
return names.some(n => t.includes(n))
})
return elements.length
}
async getVideoRow (name: string) {
let el = $('.name*=' + name)
await el.waitForDisplayed()
while (await el.getTagName() !== 'tr') {
el = el.parentElement()
}
return el
}
// ---------------------------------------------------------------------------
// My account playlists
// ---------------------------------------------------------------------------
async getPlaylistVideosText (name: string) {
const elem = await this.getPlaylist(name)
return elem.$('.miniature-playlist-info-overlay').getText()
}
async clickOnPlaylist (name: string) {
const elem = await this.getPlaylist(name)
return elem.$('.miniature-thumbnail').click()
}
async countTotalPlaylistElements () {
await $('<my-video-playlist-element-miniature>').waitForDisplayed()
return $$('<my-video-playlist-element-miniature>').length
}
playPlaylist () {
return $('.playlist-info .miniature-thumbnail').click()
}
async goOnAssociatedPlaylistEmbed () {
let url = await browser.getUrl()
url = url.replace('/w/p/', '/video-playlists/embed/')
url = url.replace(':3333', ':9001')
return go(url)
}
async updatePlaylistPrivacy (playlistUUID: string, privacy: 'Public' | 'Private' | 'Unlisted') {
go('/my-library/video-playlists/update/' + playlistUUID)
await $('a[href*="/my-library/video-playlists/update/"]').waitForDisplayed()
await selectCustomSelect('videoChannelId', 'Main root channel')
await selectCustomSelect('privacy', privacy)
const submit = $('form input[type=submit]')
await submit.waitForClickable()
await submit.scrollIntoView()
await submit.click()
return browser.waitUntil(async () => {
return (await browser.getUrl()).includes('my-library/video-playlists')
})
}
private async getPlaylist (name: string) {
const playlist = () => {
return $$('my-video-playlist-miniature')
.filter(async e => {
const t = await e.$('img').getAttribute('aria-label')
return t.includes(name)
})
.then(elems => elems[0])
}
await browser.waitUntil(async () => {
const el = await playlist()
return el?.isDisplayed()
})
return playlist()
}
}

View File

@@ -0,0 +1,107 @@
import { browserSleep, isIOS, isMobileDevice, isSafari } from '../utils'
export class PlayerPage {
getWatchVideoPlayerCurrentTime () {
const elem = $('video')
const p = isIOS()
? elem.getAttribute('currentTime')
: elem.getProperty('currentTime')
return p.then(t => parseInt(t + '', 10))
.then(t => Math.ceil(t))
}
waitUntilPlaylistInfo (text: string, maxTime: number) {
return browser.waitUntil(async () => {
// Without this we have issues on iphone
await $('.video-js').click()
return (await $('.video-js .vjs-playlist-info').getText()).includes(text)
}, { timeout: maxTime })
}
waitUntilPlayerWrapper () {
return $('#video-wrapper').waitForExist()
}
waitUntilPlaying () {
return $('.video-js.vjs-playing').waitForDisplayed()
}
async playAndPauseVideo (isAutoplay: boolean, waitUntilSec: number) {
// Autoplay is disabled on mobile and Safari
if (isIOS() || isSafari() || isMobileDevice() || isAutoplay === false) {
await this.playVideo()
}
await $('div.video-js.vjs-has-started').waitForExist()
await browserSleep(2000)
await browser.waitUntil(async () => {
return (await this.getWatchVideoPlayerCurrentTime()) >= waitUntilSec
}, { timeout: Math.max(waitUntilSec * 2 * 1000, 30000) })
// Pause video
await $('div.video-js').click()
}
async playVideo () {
await $('div.video-js.vjs-paused, div.video-js.vjs-playing').waitForExist()
if (await $('div.video-js.vjs-playing').isExisting()) {
if (!isIOS()) return
// On iOS, the web browser may have aborted player autoplay, so check the video is still autoplayed
await browserSleep(5000)
if (await $('div.video-js.vjs-playing').isExisting()) return
}
// Autoplay is disabled on iOS and Safari
if (isIOS() || isSafari() || isMobileDevice()) {
// We can't play the video if it is not muted
await browser.execute(`document.querySelector('video').muted = true`)
}
return this.clickOnPlayButton()
}
getPlayButton () {
return $('.vjs-big-play-button')
}
getNSFWContentText () {
return $('.video-js .nsfw-info').getText()
}
getNSFWDetailsContent () {
return $('.video-js .nsfw-details-content')
}
getMoreNSFWInfoButton () {
return $('.video-js .nsfw-info button')
}
async hasPoster () {
const img = $('.video-js .vjs-poster img')
return await img.isDisplayed() && (await img.getAttribute('src')).startsWith('http')
}
private async clickOnPlayButton () {
await this.getPlayButton().waitForClickable()
await this.getPlayButton().click()
}
async fillEmbedVideoPassword (videoPassword: string) {
const videoPasswordInput = $('input#video-password-input')
const confirmButton = $('button#video-password-submit')
await videoPasswordInput.clearValue()
await videoPasswordInput.setValue(videoPassword)
await confirmButton.waitForClickable()
return confirmButton.click()
}
}

View File

@@ -0,0 +1,87 @@
import { getCheckbox } from '../utils'
export class SignupPage {
getRegisterMenuButton () {
return $('.create-account-button')
}
async clickOnRegisterButton () {
const button = this.getRegisterMenuButton()
await button.waitForClickable()
await button.click()
}
async validateStep () {
const next = $('button[type=submit]')
await next.scrollIntoView({ block: 'center' })
await next.waitForClickable()
await next.click()
}
async checkTerms () {
const terms = await getCheckbox('terms')
await terms.waitForClickable()
return terms.click()
}
async getEndMessage () {
const alert = $('.pt-alert-primary')
await alert.waitForDisplayed()
return alert.getText()
}
async fillRegistrationReason (reason: string) {
await $('#registrationReason').setValue(reason)
}
async fillAccountStep (options: {
username: string
password?: string
displayName?: string
email?: string
}) {
await $('#displayName').setValue(options.displayName || `${options.username} display name`)
await $('#username').setValue(options.username)
await $('#password').setValue(options.password || 'superpassword')
// Fix weird bug on firefox that "cannot scroll into view" when using just `setValue`
await $('#email').scrollIntoView({ block: 'center' })
await $('#email').waitForClickable()
await $('#email').setValue(options.email || `${options.username}@example.com`)
}
async fillChannelStep (options: {
name: string
displayName?: string
}) {
await $('#displayName').setValue(options.displayName || `${options.name} channel display name`)
await $('#name').setValue(options.name)
}
async fullSignup ({ accountInfo, channelInfo }: {
accountInfo: {
username: string
password?: string
displayName?: string
email?: string
}
channelInfo: {
name: string
}
}) {
await this.clickOnRegisterButton()
await this.validateStep()
await this.checkTerms()
await this.validateStep()
await this.fillAccountStep(accountInfo)
await this.validateStep()
await this.fillChannelStep(channelInfo)
await this.validateStep()
await this.getEndMessage()
}
}

View File

@@ -0,0 +1,140 @@
import { browserSleep, findParentElement, go } from '../utils'
export class VideoListPage {
constructor (private isMobileDevice: boolean, private isSafari: boolean) {
}
async goOnVideosList () {
let url: string
// We did not upload a file on a mobile device
if (this.isMobileDevice === true || this.isSafari === true) {
url = 'https://peertube2.cpy.re/videos/browse?scope=local'
} else {
url = '/videos/browse'
}
await go(url)
// Waiting the following element does not work on Safari...
if (this.isSafari) return browserSleep(3000)
await this.waitForList()
}
async goOnBrowseVideos () {
await $('.menu-link*=Home').click()
const browseVideos = $('a*=Browse videos')
await browseVideos.waitForClickable()
await browseVideos.click()
await this.waitForList()
}
async goOnHomepage () {
await go('/home')
await this.waitForList()
}
async goOnRootChannel () {
await go('/c/root_channel/videos')
await this.waitForList()
}
async goOnRootAccount () {
await go('/a/root/videos')
await this.waitForList()
}
async goOnRootAccountChannels () {
await go('/a/root/video-channels')
await this.waitForList()
}
async getNSFWFilterText () {
const el = $('.active-filter*=Sensitive')
await el.waitForDisplayed()
return el.getText()
}
async getVideosListName () {
const elems = $$('.videos .video-miniature .video-name')
const texts = await elems.map(e => e.getText())
return texts.map(t => t.trim())
}
isVideoDisplayed (name: string) {
return $('.video-name=' + name).isDisplayed()
}
async isVideoBlurred (name: string) {
const miniature = await this.getVideoMiniature(name)
const filter = await miniature.$('my-video-thumbnail img').getCSSProperty('filter')
return filter.value !== 'none'
}
async hasVideoWarning (name: string) {
const miniature = await this.getVideoMiniature(name)
return miniature.$('.nsfw-warning').isDisplayed()
}
async expectVideoNSFWTooltip (name: string, summary?: string) {
const miniature = await this.getVideoMiniature(name)
const warning = miniature.$('.nsfw-warning')
await warning.waitForDisplayed()
expect(await warning.getAttribute('aria-label')).toEqual(summary)
}
private async getVideoMiniature (name: string) {
const videoName = $('.video-name=' + name)
await videoName.waitForDisplayed()
return findParentElement(videoName, async el => await el.getTagName() === 'my-video-miniature')
}
async clickOnVideo (videoName: string) {
const video = async () => {
const videos = await $$('.videos .video-miniature .video-name').filter(async e => {
const t = await e.getText()
return t === videoName
})
return videos[0]
}
await browser.waitUntil(async () => {
const elem = await video()
return elem?.isClickable()
})
;(await video()).click()
await browser.waitUntil(async () => (await browser.getUrl()).includes('/w/'))
}
async clickOnFirstVideo () {
const video = () => $('.videos .video-miniature .video-thumbnail')
const videoName = () => $('.videos .video-miniature .video-name')
await video().waitForClickable()
const textToReturn = await videoName().getText()
await video().click()
await browser.waitUntil(async () => (await browser.getUrl()).includes('/w/'))
return textToReturn
}
private waitForList () {
return $('.videos .video-miniature .video-name').waitForDisplayed()
}
}

View File

@@ -0,0 +1,148 @@
import { clickOnRadio, getCheckbox, go, isRadioSelected, selectCustomSelect, setCheckboxEnabled } from '../utils'
export abstract class VideoManage {
async clickOnSave () {
const button = this.getSaveButton()
await button.waitForClickable()
await button.click()
await this.waitForSaved()
}
async clickOnWatch () {
// Simulate the click, because the button opens a new tab
const button = $('.watch-save > my-button[icon=external-link] a')
await button.waitForClickable()
await go(await button.getAttribute('href'))
}
// ---------------------------------------------------------------------------
async setAsNSFW (options: {
violent?: boolean
summary?: string
} = {}) {
await this.goOnPage('Moderation')
const checkbox = await getCheckbox('nsfw')
await checkbox.waitForClickable()
await checkbox.click()
if (options.violent) {
await setCheckboxEnabled('nsfwFlagViolent', true)
}
if (options.summary) {
await $('#nsfwSummary').setValue(options.summary)
}
}
// ---------------------------------------------------------------------------
async setAsPublic () {
await this.goOnPage('Main information')
return selectCustomSelect('privacy', 'Public')
}
async setAsPrivate () {
await this.goOnPage('Main information')
return selectCustomSelect('privacy', 'Private')
}
async setAsPasswordProtected (videoPassword: string) {
await this.goOnPage('Main information')
selectCustomSelect('privacy', 'Password protected')
const videoPasswordInput = $('input#videoPassword')
await videoPasswordInput.waitForClickable()
await videoPasswordInput.clearValue()
return videoPasswordInput.setValue(videoPassword)
}
// ---------------------------------------------------------------------------
async scheduleUpload () {
await this.goOnPage('Main information')
selectCustomSelect('privacy', 'Scheduled')
const input = this.getScheduleInput()
await input.waitForClickable()
await input.click()
const nextMonth = $('.p-datepicker-next-button')
await nextMonth.click()
await $('.p-datepicker-calendar td[aria-label="1"] > span').click()
await $('.p-datepicker-calendar').waitForDisplayed({ reverse: true, timeout: 15000 }) // Can be slow
}
getScheduleInput () {
return $('#schedulePublicationAt input')
}
// ---------------------------------------------------------------------------
async setNormalLive () {
await this.goOnPage('Live settings')
await clickOnRadio('permanentLiveFalse')
}
async setPermanentLive () {
await this.goOnPage('Live settings')
await clickOnRadio('permanentLiveTrue')
}
async getLiveState () {
await this.goOnPage('Live settings')
if (await isRadioSelected('permanentLiveTrue')) return 'permanent'
return 'normal'
}
// ---------------------------------------------------------------------------
async refresh (videoName: string) {
await browser.refresh()
await browser.waitUntil(async () => {
const url = await browser.getUrl()
return url.includes('/videos/manage')
})
await browser.waitUntil(async () => {
return await $('#name').getValue() === videoName
})
}
// ---------------------------------------------------------------------------
protected getSaveButton () {
return $('.save-button > button:not([disabled])')
}
protected waitForSaved () {
return $('.save-button > button[disabled], my-manage-errors').waitForDisplayed()
}
protected async goOnPage (page: 'Main information' | 'Moderation' | 'Live settings') {
const urls = {
'Main information': '',
'Moderation': 'moderation',
'Live settings': 'live'
}
const el = $(`my-video-manage-container .menu a[href*="/${urls[page]}"]`)
await el.waitForClickable()
await el.click()
}
}

View File

@@ -0,0 +1,80 @@
import { join } from 'node:path'
import { VideoManage } from './video-manage'
import { FIXTURE_URLS } from '../utils'
export class VideoPublishPage extends VideoManage {
async navigateTo (tab?: 'Go live') {
const publishButton = $('.publish-button > a')
await publishButton.waitForClickable()
await publishButton.click()
await $('.upload-video-container').waitForDisplayed()
if (tab) {
const el = $(`.nav-link*=${tab}`)
await el.waitForClickable()
await el.click()
}
}
// ---------------------------------------------------------------------------
async uploadVideo (fixtureName: 'video.mp4' | 'video2.mp4' | 'video3.mp4') {
const fileToUpload = join(__dirname, '../../fixtures/' + fixtureName)
const fileInputSelector = '.upload-video-container input[type=file]'
const parentFileInput = '.upload-video-container .button-file'
// Avoid sending keys on non visible element
await browser.execute(`document.querySelector('${fileInputSelector}').style.opacity = 1`)
await browser.execute(`document.querySelector('${parentFileInput}').style.overflow = 'initial'`)
await browser.pause(1000)
const elem = $(fileInputSelector)
await elem.chooseFile(fileToUpload)
// Wait for the upload to finish
await this.getSaveButton().waitForClickable()
}
async importVideo () {
const tab = $('.nav-link*=Import with URL')
await tab.waitForClickable()
await tab.click()
const input = $('#targetUrl')
await input.waitForDisplayed()
await input.setValue(FIXTURE_URLS.IMPORT_URL)
const submit = $('.first-step-block .primary-button:not([disabled])')
await submit.waitForClickable()
await submit.click()
// Wait for the import to finish
await this.getSaveButton().waitForClickable({ timeout: 15000 }) // Can be slow
}
async publishLive () {
await $('#permanentLiveTrue').parentElement().click()
const submit = $('.upload-video-container .primary-button:not([disabled])')
await submit.waitForClickable()
await submit.click()
await this.getSaveButton().waitForClickable()
}
// ---------------------------------------------------------------------------
async validSecondStep (videoName: string) {
await this.goOnPage('Main information')
const nameInput = $('input#name')
await nameInput.scrollIntoView()
await nameInput.clearValue()
await nameInput.setValue(videoName)
await this.clickOnSave()
}
}

View File

@@ -0,0 +1,11 @@
export class VideoSearchPage {
async search (search: string) {
await $('#search-video').setValue(search)
await $('.search-button').click()
await browser.waitUntil(() => {
return $('my-video-miniature').isDisplayed()
})
}
}

View File

@@ -0,0 +1,11 @@
import { VideoManage } from './video-manage'
export class VideoUpdatePage extends VideoManage {
async updateName (videoName: string) {
const nameInput = $('input#name')
await nameInput.waitForDisplayed()
await nameInput.clearValue()
await nameInput.setValue(videoName)
}
}

View File

@@ -0,0 +1,229 @@
import { browserSleep, FIXTURE_URLS, go } from '../utils'
export class VideoWatchPage {
constructor (private isMobileDevice: boolean, private isSafari: boolean) {
}
waitWatchVideoName (videoName: string, maxTime?: number) {
if (this.isSafari) return browserSleep(5000)
// On mobile we display the first node, on desktop the second one
return browser.waitUntil(async () => {
return (await this.getVideoName()) === videoName
}, { timeout: maxTime })
}
getVideoName () {
return this.getVideoNameElement().then(e => e.getText())
}
getPrivacy () {
return $('.attribute-privacy .attribute-value').getText()
}
getLicence () {
return $('.attribute-licence .attribute-value').getText()
}
async isDownloadEnabled () {
try {
await this.clickOnMoreDropdownIcon()
return await $('.dropdown-item .icon-download').isExisting()
} catch {
return $('.action-button-download').isDisplayed()
}
}
areCommentsEnabled () {
return $('my-video-comment-add').isExisting()
}
isPrivacyWarningDisplayed () {
return $('.privacy-concerns-text').isDisplayed()
}
async goOnAssociatedEmbed (passwordProtected = false) {
let url = await browser.getUrl()
url = url.replace('/w/', '/videos/embed/')
url = url.replace(':3333', ':9001')
await go(url)
if (passwordProtected) await this.waitEmbedForVideoPasswordForm()
else await this.waitEmbedForDisplayed()
}
waitEmbedForDisplayed () {
return $('.vjs-big-play-button').waitForDisplayed()
}
waitEmbedForVideoPasswordForm () {
return $('#video-password-input').waitForDisplayed()
}
isEmbedWarningDisplayed () {
return $('.peertube-dock-description').isDisplayed()
}
goOnP2PMediaLoaderEmbed () {
return go(FIXTURE_URLS.HLS_EMBED)
}
goOnP2PMediaLoaderPlaylistEmbed () {
return go(FIXTURE_URLS.HLS_PLAYLIST_EMBED)
}
getModalTitleEl () {
return $('.modal-content .modal-title')
}
confirmModal () {
return $('.modal-content .modal-footer .primary-button').click()
}
private getVideoNameElement () {
return $('.video-info-first-row .video-info-name')
}
// ---------------------------------------------------------------------------
// Video password
// ---------------------------------------------------------------------------
async fillVideoPassword (videoPassword: string) {
const videoPasswordInput = $('input#confirmInput')
await videoPasswordInput.waitForClickable()
await videoPasswordInput.clearValue()
await videoPasswordInput.setValue(videoPassword)
const confirmButton = $('input[value="Confirm"]')
await confirmButton.waitForClickable()
return confirmButton.click()
}
// ---------------------------------------------------------------------------
// Video actions
// ---------------------------------------------------------------------------
async like () {
const likeButton = $('.action-button-like')
const isActivated = (await likeButton.getAttribute('class')).includes('activated')
let count: number
try {
count = parseInt(await $('.action-button-like > .count').getText())
} catch (error) {
count = 0
}
await likeButton.waitForClickable()
await likeButton.click()
if (isActivated) {
if (count === 1) {
return expect(!await $('.action-button-like > .count').isExisting())
} else {
return expect(parseInt(await $('.action-button-like > .count').getText())).toBe(count - 1)
}
} else {
return expect(parseInt(await $('.action-button-like > .count').getText())).toBe(count + 1)
}
}
async clickOnManage () {
await this.clickOnMoreDropdownIcon()
// We need the await expression
return $$('.dropdown-menu.show .dropdown-item').forEach(async item => {
const content = await item.getText()
if (content.includes('Manage')) {
await item.click()
await $('#name').waitForClickable()
return
}
})
}
async clickOnMoreDropdownIcon () {
const dropdown = $('my-video-actions-dropdown .action-button')
await dropdown.scrollIntoView({ block: 'center' })
await dropdown.click()
await $('.dropdown-menu.show .dropdown-item').waitForDisplayed()
}
// ---------------------------------------------------------------------------
// Playlists
// ---------------------------------------------------------------------------
async clickOnSave () {
const button = $('.action-button-save')
await button.scrollIntoView({ block: 'center' })
return button.click()
}
async createPlaylist (name: string) {
const newPlaylistButton = () => $('.new-playlist-button')
await newPlaylistButton().waitForClickable()
await newPlaylistButton().click()
const displayName = () => $('#displayName')
await displayName().waitForDisplayed()
await displayName().setValue(name)
return $('.new-playlist-block input[type=submit]').click()
}
async saveToPlaylist (name: string) {
const playlist = () => $('my-video-add-to-playlist').$(`.playlist=${name}`)
await playlist().waitForDisplayed()
return playlist().click()
}
// ---------------------------------------------------------------------------
// Comments
// ---------------------------------------------------------------------------
async createThread (comment: string) {
const textarea = $('my-video-comment-add textarea')
await textarea.waitForClickable()
await textarea.setValue(comment)
const confirmButton = $('.comment-buttons .primary-button')
await confirmButton.waitForClickable()
await confirmButton.click()
const createdComment = await $('.comment-html p').getText()
return expect(createdComment).toBe(comment)
}
async createReply (comment: string) {
const replyButton = $('button.comment-action-reply')
await replyButton.waitForClickable()
await replyButton.scrollIntoView({ block: 'center' })
await replyButton.click()
const textarea = $('my-video-comment my-video-comment-add textarea')
await textarea.waitForClickable()
await textarea.setValue(comment)
const confirmButton = $('my-video-comment .comment-buttons .primary-button')
await confirmButton.waitForClickable()
await replyButton.scrollIntoView({ block: 'center' })
await confirmButton.click()
const createdComment = $('.is-child .comment-html p')
await createdComment.waitForDisplayed()
return expect(await createdComment.getText()).toBe(comment)
}
}

View File

@@ -0,0 +1,33 @@
import { PlayerPage } from '../po/player.po'
import { VideoWatchPage } from '../po/video-watch.po'
import { FIXTURE_URLS, go, isMobileDevice, isSafari, prepareWebBrowser } from '../utils'
describe('Live all workflow', () => {
let videoWatchPage: VideoWatchPage
let playerPage: PlayerPage
beforeEach(async () => {
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
playerPage = new PlayerPage()
await prepareWebBrowser()
})
it('Should go to the live page', async () => {
await go(FIXTURE_URLS.LIVE_VIDEO)
return videoWatchPage.waitWatchVideoName('E2E - Live')
})
it('Should play the live', async () => {
await playerPage.playAndPauseVideo(false, 45)
expect(await playerPage.getWatchVideoPlayerCurrentTime()).toBeGreaterThanOrEqual(45)
})
it('Should watch the associated live embed', async () => {
await videoWatchPage.goOnAssociatedEmbed()
await playerPage.playAndPauseVideo(false, 45)
expect(await playerPage.getWatchVideoPlayerCurrentTime()).toBeGreaterThanOrEqual(45)
})
})

View File

@@ -0,0 +1,73 @@
import { LoginPage } from '../po/login.po'
import { PlayerPage } from '../po/player.po'
import { VideoWatchPage } from '../po/video-watch.po'
import { FIXTURE_URLS, go, isMobileDevice, isSafari, prepareWebBrowser } from '../utils'
async function checkCorrectlyPlay (playerPage: PlayerPage) {
await playerPage.playAndPauseVideo(false, 2)
expect(await playerPage.getWatchVideoPlayerCurrentTime()).toBeGreaterThanOrEqual(2)
}
describe('Private videos all workflow', () => {
let videoWatchPage: VideoWatchPage
let loginPage: LoginPage
let playerPage: PlayerPage
const internalVideoName = 'Internal E2E test'
const internalHLSOnlyVideoName = 'Internal E2E test - HLS only'
beforeEach(async () => {
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
loginPage = new LoginPage(isMobileDevice())
playerPage = new PlayerPage()
await prepareWebBrowser()
})
it('Should log in', async () => {
return loginPage.loginOnPeerTube2()
})
it('Should play an internal web video', async () => {
await go(FIXTURE_URLS.INTERNAL_WEB_VIDEO)
await videoWatchPage.waitWatchVideoName(internalVideoName)
await checkCorrectlyPlay(playerPage)
})
it('Should play an internal HLS video', async () => {
await go(FIXTURE_URLS.INTERNAL_HLS_VIDEO)
await videoWatchPage.waitWatchVideoName(internalVideoName)
await checkCorrectlyPlay(playerPage)
})
it('Should play an internal HLS only video', async () => {
await go(FIXTURE_URLS.INTERNAL_HLS_ONLY_VIDEO)
await videoWatchPage.waitWatchVideoName(internalHLSOnlyVideoName)
await checkCorrectlyPlay(playerPage)
})
it('Should play an internal Web Video in embed', async () => {
await go(FIXTURE_URLS.INTERNAL_EMBED_WEB_VIDEO)
await videoWatchPage.waitEmbedForDisplayed()
await checkCorrectlyPlay(playerPage)
})
it('Should play an internal HLS video in embed', async () => {
await go(FIXTURE_URLS.INTERNAL_EMBED_HLS_VIDEO)
await videoWatchPage.waitEmbedForDisplayed()
await checkCorrectlyPlay(playerPage)
})
it('Should play an internal HLS only video in embed', async () => {
await go(FIXTURE_URLS.INTERNAL_EMBED_HLS_ONLY_VIDEO)
await videoWatchPage.waitEmbedForDisplayed()
await checkCorrectlyPlay(playerPage)
})
})

View File

@@ -0,0 +1,233 @@
import { LoginPage } from '../po/login.po'
import { MyAccountPage } from '../po/my-account.po'
import { PlayerPage } from '../po/player.po'
import { VideoListPage } from '../po/video-list.po'
import { VideoPublishPage } from '../po/video-publish.po'
import { VideoUpdatePage } from '../po/video-update.po'
import { VideoWatchPage } from '../po/video-watch.po'
import { FIXTURE_URLS, go, isIOS, isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
function isUploadUnsupported () {
if (isMobileDevice() || isSafari()) {
console.log('Skipping because we are on a real device or Safari and BrowserStack does not support file upload.')
return true
}
return false
}
describe('Videos all workflow', () => {
let videoWatchPage: VideoWatchPage
let videoListPage: VideoListPage
let videoPublishPage: VideoPublishPage
let videoUpdatePage: VideoUpdatePage
let myAccountPage: MyAccountPage
let loginPage: LoginPage
let playerPage: PlayerPage
let videoName = Math.random() + ' video'
const video2Name = Math.random() + ' second video'
const playlistName = Math.random() + ' playlist'
let videoWatchUrl: string
before(async () => {
if (isIOS()) {
console.log('iOS detected')
} else if (isMobileDevice()) {
console.log('Android detected.')
} else if (isSafari()) {
console.log('Safari detected.')
}
if (isUploadUnsupported()) return
await waitServerUp()
})
beforeEach(async () => {
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
videoPublishPage = new VideoPublishPage()
videoUpdatePage = new VideoUpdatePage()
myAccountPage = new MyAccountPage()
loginPage = new LoginPage(isMobileDevice())
playerPage = new PlayerPage()
videoListPage = new VideoListPage(isMobileDevice(), isSafari())
await prepareWebBrowser()
})
it('Should log in', async () => {
if (isMobileDevice() || isSafari()) {
console.log('Skipping because we are on a real device or Safari and BrowserStack does not support file upload.')
return
}
return loginPage.loginAsRootUser()
})
it('Should upload a video', async () => {
if (isUploadUnsupported()) return
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video.mp4')
await videoPublishPage.validSecondStep(videoName)
})
it('Should list videos', async () => {
await videoListPage.goOnVideosList()
if (isUploadUnsupported()) return
const videoNames = await videoListPage.getVideosListName()
expect(videoNames).toContain(videoName)
})
it('Should go on video watch page', async () => {
let videoNameToExcept = videoName
if (isMobileDevice() || isSafari()) {
await go(FIXTURE_URLS.WEB_VIDEO)
videoNameToExcept = 'E2E tests'
} else {
await videoListPage.clickOnVideo(videoName)
}
return videoWatchPage.waitWatchVideoName(videoNameToExcept)
})
it('Should play the video', async () => {
videoWatchUrl = await browser.getUrl()
await playerPage.playAndPauseVideo(true, 2)
expect(await playerPage.getWatchVideoPlayerCurrentTime()).toBeGreaterThanOrEqual(2)
})
it('Should watch the associated embed video', async () => {
await videoWatchPage.goOnAssociatedEmbed()
await playerPage.playAndPauseVideo(false, 2)
expect(await playerPage.getWatchVideoPlayerCurrentTime()).toBeGreaterThanOrEqual(2)
})
it('Should watch the p2p media loader embed video', async () => {
await videoWatchPage.goOnP2PMediaLoaderEmbed()
await playerPage.playAndPauseVideo(false, 2)
expect(await playerPage.getWatchVideoPlayerCurrentTime()).toBeGreaterThanOrEqual(2)
})
it('Should update the video', async () => {
if (isUploadUnsupported()) return
await go(videoWatchUrl)
await videoWatchPage.clickOnManage()
videoName += ' updated'
await videoUpdatePage.updateName(videoName)
await videoUpdatePage.clickOnSave()
await videoUpdatePage.clickOnWatch()
const name = await videoWatchPage.getVideoName()
expect(name).toEqual(videoName)
})
it('Should add the video in my playlist', async () => {
if (isUploadUnsupported()) return
await videoWatchPage.clickOnSave()
await videoWatchPage.createPlaylist(playlistName)
await videoWatchPage.saveToPlaylist(playlistName)
await browser.pause(5000)
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video2.mp4')
await videoPublishPage.validSecondStep(video2Name)
await videoPublishPage.clickOnWatch()
await videoWatchPage.clickOnSave()
await videoWatchPage.saveToPlaylist(playlistName)
})
it('Should have the playlist in my account', async () => {
if (isUploadUnsupported()) return
await myAccountPage.navigateToMyPlaylists()
const videosNumberText = await myAccountPage.getPlaylistVideosText(playlistName)
expect(videosNumberText).toEqual('2 videos')
await myAccountPage.clickOnPlaylist(playlistName)
const count = await myAccountPage.countTotalPlaylistElements()
expect(count).toEqual(2)
})
it('Should watch the playlist', async () => {
if (isUploadUnsupported()) return
await myAccountPage.playPlaylist()
await videoWatchPage.waitWatchVideoName(video2Name, 40 * 1000)
})
it('Should watch the Web Video playlist in the embed', async () => {
if (isUploadUnsupported()) return
const accessToken = await browser.execute(`return window.localStorage.getItem('access_token');`)
const refreshToken = await browser.execute(`return window.localStorage.getItem('refresh_token');`)
await myAccountPage.goOnAssociatedPlaylistEmbed()
await playerPage.waitUntilPlayerWrapper()
console.log('Will set %s and %s tokens in local storage.', accessToken, refreshToken)
await browser.execute(`window.localStorage.setItem('access_token', '${accessToken}');`)
await browser.execute(`window.localStorage.setItem('refresh_token', '${refreshToken}');`)
await browser.execute(`window.localStorage.setItem('token_type', 'Bearer');`)
await browser.refresh()
await playerPage.playVideo()
await playerPage.waitUntilPlaylistInfo('2/2', 30 * 1000)
})
it('Should watch the HLS playlist in the embed', async () => {
await videoWatchPage.goOnP2PMediaLoaderPlaylistEmbed()
await playerPage.playVideo()
await playerPage.waitUntilPlaylistInfo('2/2', 30 * 1000)
})
it('Should delete the video 2', async () => {
if (isUploadUnsupported()) return
// Go to the dev website
await go(videoWatchUrl)
await myAccountPage.navigateToMyVideos()
await myAccountPage.removeVideo(video2Name)
await myAccountPage.validRemove()
await browser.waitUntil(async () => {
const count = await myAccountPage.countVideos([ videoName, video2Name ])
return count === 1
})
})
it('Should delete the first video', async () => {
if (isUploadUnsupported()) return
await myAccountPage.removeVideo(videoName)
await myAccountPage.validRemove()
})
})

View File

@@ -0,0 +1,95 @@
import { LoginPage } from '../po/login.po'
import { VideoPublishPage } from '../po/video-publish.po'
import { VideoWatchPage } from '../po/video-watch.po'
import { go, isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
describe('Custom server defaults', () => {
let videoPublishPage: VideoPublishPage
let loginPage: LoginPage
let videoWatchPage: VideoWatchPage
before(async () => {
await waitServerUp()
loginPage = new LoginPage(isMobileDevice())
videoPublishPage = new VideoPublishPage()
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
await prepareWebBrowser({ hidePrivacyConcerns: false })
})
describe('Publish default values', function () {
before(async function () {
await loginPage.loginAsRootUser()
})
it('Should upload a video with custom default values', async function () {
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video.mp4')
await videoPublishPage.validSecondStep('video')
await videoPublishPage.clickOnWatch()
await videoWatchPage.waitWatchVideoName('video')
const videoUrl = await browser.getUrl()
expect(await videoWatchPage.getPrivacy()).toBe('Unlisted')
expect(await videoWatchPage.getLicence()).toBe('Attribution - Non Commercial')
expect(await videoWatchPage.areCommentsEnabled()).toBeFalsy()
// Owners can download their videos
expect(await videoWatchPage.isDownloadEnabled()).toBeTruthy()
// Logout to see if the download enabled is correct for anonymous users
await loginPage.logout()
await browser.url(videoUrl)
await videoWatchPage.waitWatchVideoName('video')
expect(await videoWatchPage.isDownloadEnabled()).toBeFalsy()
})
})
describe('P2P', function () {
let videoUrl: string
async function goOnVideoWatchPage () {
await go(videoUrl)
await videoWatchPage.waitWatchVideoName('video')
}
async function checkP2P (enabled: boolean) {
await goOnVideoWatchPage()
expect(await videoWatchPage.isPrivacyWarningDisplayed()).toEqual(enabled)
await videoWatchPage.goOnAssociatedEmbed()
expect(await videoWatchPage.isEmbedWarningDisplayed()).toEqual(enabled)
}
before(async () => {
await loginPage.loginAsRootUser()
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video2.mp4')
await videoPublishPage.setAsPublic()
await videoPublishPage.validSecondStep('video')
await videoPublishPage.clickOnWatch()
await videoWatchPage.waitWatchVideoName('video')
videoUrl = await browser.getUrl()
})
beforeEach(async function () {
await goOnVideoWatchPage()
})
it('Should have P2P disabled for a logged in user', async function () {
await checkP2P(false)
})
it('Should have P2P disabled for anonymous users', async function () {
await loginPage.logout()
await checkP2P(false)
})
})
})

View File

@@ -0,0 +1,393 @@
import { NSFWPolicyType } from '@peertube/peertube-models'
import { AdminConfigPage } from '../po/admin-config.po'
import { AdminUserPage } from '../po/admin-user.po'
import { AnonymousSettingsPage } from '../po/anonymous-settings.po'
import { LoginPage } from '../po/login.po'
import { MyAccountPage } from '../po/my-account.po'
import { PlayerPage } from '../po/player.po'
import { VideoListPage } from '../po/video-list.po'
import { VideoPublishPage } from '../po/video-publish.po'
import { VideoSearchPage } from '../po/video-search.po'
import { VideoWatchPage } from '../po/video-watch.po'
import { getScreenshotPath, go, isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
describe('NSFW', () => {
let videoListPage: VideoListPage
let videoPublishPage: VideoPublishPage
let adminConfigPage: AdminConfigPage
let loginPage: LoginPage
let adminUserPage: AdminUserPage
let myAccountPage: MyAccountPage
let videoSearchPage: VideoSearchPage
let videoWatchPage: VideoWatchPage
let playerPage: PlayerPage
let anonymousSettingsPage: AnonymousSettingsPage
const seed = Math.random()
const nsfwVideo = seed + ' - nsfw'
const violentVideo = seed + ' - violent'
const normalVideo = seed + ' - normal'
let videoUrl: string
async function checkVideo (options: {
policy: NSFWPolicyType
videoName: string
nsfwTooltip?: string
}) {
const { policy, videoName, nsfwTooltip } = options
if (policy === 'do_not_list') {
expect(await videoListPage.isVideoDisplayed(videoName)).toBeFalsy()
} else if (policy === 'warn') {
expect(await videoListPage.isVideoDisplayed(videoName)).toBeTruthy()
expect(await videoListPage.isVideoBlurred(videoName)).toBeFalsy()
expect(await videoListPage.hasVideoWarning(videoName)).toBeTruthy()
} else if (policy === 'blur') {
expect(await videoListPage.isVideoDisplayed(videoName)).toBeTruthy()
expect(await videoListPage.isVideoBlurred(videoName)).toBeTruthy()
expect(await videoListPage.hasVideoWarning(videoName)).toBeTruthy()
} else { // Display
expect(await videoListPage.isVideoDisplayed(videoName)).toBeTruthy()
expect(await videoListPage.isVideoBlurred(videoName)).toBeFalsy()
expect(await videoListPage.hasVideoWarning(videoName)).toBeFalsy()
}
if (nsfwTooltip) {
await videoListPage.expectVideoNSFWTooltip(videoName, nsfwTooltip)
}
}
async function checkFilterText (policy: NSFWPolicyType) {
const pagesWithFilters = [
videoListPage.goOnRootAccount.bind(videoListPage),
videoListPage.goOnBrowseVideos.bind(videoListPage),
videoListPage.goOnRootChannel.bind(videoListPage)
]
for (const goOnPage of pagesWithFilters) {
await goOnPage()
const filterText = await videoListPage.getNSFWFilterText()
if (policy === 'do_not_list') {
expect(filterText).toContain('hidden')
} else if (policy === 'warn') {
expect(filterText).toContain('warned')
} else if (policy === 'blur') {
expect(filterText).toContain('blurred')
} else {
expect(filterText).toContain('displayed')
}
}
}
async function checkCommonVideoListPages (policy: NSFWPolicyType, videos: string[], nsfwTooltip?: string) {
const pages = [
videoListPage.goOnRootAccount.bind(videoListPage),
videoListPage.goOnBrowseVideos.bind(videoListPage),
videoListPage.goOnRootChannel.bind(videoListPage),
videoListPage.goOnRootAccountChannels.bind(videoListPage),
videoListPage.goOnHomepage.bind(videoListPage)
]
for (const goOnPage of pages) {
await goOnPage()
for (const video of videos) {
await browser.saveScreenshot(getScreenshotPath('before-nsfw-test.png'))
await checkVideo({ policy, videoName: video, nsfwTooltip })
}
}
for (const video of videos) {
await videoSearchPage.search(video)
await browser.saveScreenshot(getScreenshotPath('before-nsfw-test.png'))
await checkVideo({ policy, videoName: video, nsfwTooltip })
}
}
async function updateAdminNSFW (nsfw: NSFWPolicyType) {
await adminConfigPage.updateNSFWSetting(nsfw)
await adminConfigPage.save()
}
async function updateUserNSFW (nsfw: NSFWPolicyType, loggedIn: boolean) {
if (loggedIn) {
await myAccountPage.navigateToMySettings()
await myAccountPage.updateNSFW(nsfw)
return
}
await anonymousSettingsPage.openSettings()
await anonymousSettingsPage.updateNSFW(nsfw)
await anonymousSettingsPage.closeSettings()
}
async function updateUserViolentNSFW (nsfw: NSFWPolicyType, loggedIn: boolean) {
if (loggedIn) {
await myAccountPage.navigateToMySettings()
await myAccountPage.updateViolentFlag(nsfw)
return
}
await anonymousSettingsPage.openSettings()
await anonymousSettingsPage.updateViolentFlag(nsfw)
await anonymousSettingsPage.closeSettings()
}
before(async () => {
await waitServerUp()
})
beforeEach(async () => {
videoListPage = new VideoListPage(isMobileDevice(), isSafari())
adminConfigPage = new AdminConfigPage()
loginPage = new LoginPage(isMobileDevice())
adminUserPage = new AdminUserPage()
videoPublishPage = new VideoPublishPage()
myAccountPage = new MyAccountPage()
videoSearchPage = new VideoSearchPage()
playerPage = new PlayerPage()
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
anonymousSettingsPage = new AnonymousSettingsPage()
await prepareWebBrowser()
})
describe('Preparation', function () {
it('Should login and disable NSFW', async () => {
await loginPage.loginAsRootUser()
await updateUserNSFW('display', true)
})
it('Should set the homepage', async () => {
await adminConfigPage.updateHomepage('<peertube-videos-list data-sort="-publishedAt"></peertube-videos-list>')
await adminConfigPage.save()
})
it('Should create a user', async () => {
await adminUserPage.createUser({
username: 'user_' + seed,
password: 'superpassword'
})
})
it('Should upload NSFW and normal videos', async () => {
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video.mp4')
await videoPublishPage.setAsNSFW()
await videoPublishPage.validSecondStep(nsfwVideo)
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video.mp4')
await videoPublishPage.setAsNSFW({ summary: 'bibi is violent', violent: true })
await videoPublishPage.validSecondStep(violentVideo)
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video2.mp4')
await videoPublishPage.validSecondStep(normalVideo)
})
it('Should logout', async function () {
await loginPage.logout()
})
})
describe('NSFW with an anonymous users using instance default', function () {
it('Should correctly handle do not list', async () => {
await loginPage.loginAsRootUser()
await updateAdminNSFW('do_not_list')
await loginPage.logout()
await checkCommonVideoListPages('do_not_list', [ nsfwVideo, violentVideo ])
await checkCommonVideoListPages('display', [ normalVideo ])
await checkFilterText('do_not_list')
})
it('Should correctly handle blur', async () => {
await loginPage.loginAsRootUser()
await updateAdminNSFW('blur')
await loginPage.logout()
await checkCommonVideoListPages('blur', [ nsfwVideo, violentVideo ])
await checkCommonVideoListPages('display', [ normalVideo ])
await checkFilterText('blur')
})
it('Should not autoplay the video and display a warning on watch/embed page', async function () {
await videoListPage.clickOnVideo(nsfwVideo)
await videoWatchPage.waitWatchVideoName(nsfwVideo)
videoUrl = await browser.getUrl()
const check = async () => {
expect(await playerPage.getPlayButton().isDisplayed()).toBeTruthy()
expect(await playerPage.getNSFWContentText()).toContain('This video contains sensitive content')
expect(await playerPage.getMoreNSFWInfoButton().isDisplayed()).toBeFalsy()
expect(await playerPage.hasPoster()).toBeFalsy()
}
await check()
await videoWatchPage.goOnAssociatedEmbed()
await check()
})
it('Should correctly handle warn', async () => {
await loginPage.loginAsRootUser()
await updateAdminNSFW('warn')
await loginPage.logout()
await checkCommonVideoListPages('warn', [ nsfwVideo, violentVideo ])
await checkCommonVideoListPages('display', [ normalVideo ])
await checkFilterText('warn')
})
it('Should not autoplay the video and display a warning on watch/embed page', async function () {
await videoListPage.clickOnVideo(violentVideo)
await videoWatchPage.waitWatchVideoName(violentVideo)
const check = async () => {
expect(await playerPage.getPlayButton().isDisplayed()).toBeTruthy()
expect(await playerPage.getNSFWContentText()).toContain('This video contains sensitive content')
expect(await playerPage.hasPoster()).toBeTruthy()
const moreButton = playerPage.getMoreNSFWInfoButton()
expect(await moreButton.isDisplayed()).toBeTruthy()
await moreButton.click()
await playerPage.getNSFWDetailsContent().waitForDisplayed()
const moreContent = await playerPage.getNSFWDetailsContent().getText()
expect(moreContent).toContain('Potentially violent content')
expect(moreContent).toContain('bibi is violent')
}
await check()
await videoWatchPage.goOnAssociatedEmbed()
await check()
})
it('Should correctly handle display', async () => {
await loginPage.loginAsRootUser()
await updateAdminNSFW('display')
await loginPage.logout()
await checkCommonVideoListPages('display', [ nsfwVideo, violentVideo, normalVideo ])
await checkFilterText('display')
})
it('Should autoplay the video on watch page', async function () {
await videoListPage.clickOnVideo(nsfwVideo)
await videoWatchPage.waitWatchVideoName(nsfwVideo)
expect(await playerPage.getPlayButton().isDisplayed()).toBeFalsy()
})
})
describe('NSFW settings', function () {
function runSuite (loggedIn: boolean) {
it('Should correctly handle do not list', async () => {
await updateUserNSFW('do_not_list', loggedIn)
await checkCommonVideoListPages('do_not_list', [ nsfwVideo, violentVideo ])
await checkCommonVideoListPages('display', [ normalVideo ])
await checkFilterText('do_not_list')
})
it('Should use a confirm modal when viewing the video and watch the video', async function () {
await go(videoUrl)
const confirmTitle = videoWatchPage.getModalTitleEl()
await confirmTitle.waitForDisplayed()
expect(await confirmTitle.getText()).toContain('Sensitive video')
await videoWatchPage.confirmModal()
await videoWatchPage.waitWatchVideoName(nsfwVideo)
})
it('Should correctly handle blur', async () => {
await updateUserNSFW('blur', loggedIn)
await checkCommonVideoListPages('blur', [ nsfwVideo ], 'This video contains sensitive content')
await checkCommonVideoListPages('blur', [ violentVideo ], 'This video contains sensitive content: violence')
await checkCommonVideoListPages('display', [ normalVideo ])
await checkFilterText('blur')
})
it('Should correctly handle warn', async () => {
await updateUserNSFW('warn', loggedIn)
await checkCommonVideoListPages('warn', [ nsfwVideo ], 'This video contains sensitive content')
await checkCommonVideoListPages('warn', [ violentVideo ], 'This video contains sensitive content: violence')
await checkCommonVideoListPages('display', [ normalVideo ])
await checkFilterText('warn')
})
it('Should correctly handle display', async () => {
await updateUserNSFW('display', loggedIn)
await checkCommonVideoListPages('display', [ nsfwVideo, violentVideo, normalVideo ])
await checkFilterText('display')
})
it('Should update the setting to blur violent video with display NSFW setting', async () => {
await updateUserViolentNSFW('blur', loggedIn)
await checkCommonVideoListPages('display', [ nsfwVideo, normalVideo ])
await checkCommonVideoListPages('blur', [ violentVideo ])
})
it('Should update the setting to hide NSFW videos but warn violent videos', async () => {
await updateUserNSFW('do_not_list', loggedIn)
await updateUserViolentNSFW('warn', loggedIn)
await checkCommonVideoListPages('display', [ normalVideo ])
await checkCommonVideoListPages('warn', [ violentVideo ])
await checkCommonVideoListPages('do_not_list', [ nsfwVideo ])
})
it('Should update the setting to blur NSFW videos and hide violent videos', async () => {
await updateUserNSFW('blur', loggedIn)
await updateUserViolentNSFW('do_not_list', loggedIn)
await checkCommonVideoListPages('display', [ normalVideo ])
await checkCommonVideoListPages('do_not_list', [ violentVideo ])
await checkCommonVideoListPages('blur', [ nsfwVideo ])
})
}
describe('NSFW with an anonymous user', function () {
runSuite(false)
})
describe('NSFW with a logged in users', function () {
before(async () => {
await loginPage.login({ username: 'user_' + seed, password: 'superpassword' })
})
runSuite(true)
after(async () => {
await loginPage.logout()
})
})
})
})

View File

@@ -0,0 +1,141 @@
import { AdminConfigPage } from '../po/admin-config.po'
import { LoginPage } from '../po/login.po'
import { VideoPublishPage } from '../po/video-publish.po'
import { VideoWatchPage } from '../po/video-watch.po'
import { getScreenshotPath, go, isMobileDevice, isSafari, prepareWebBrowser, selectCustomSelect, waitServerUp } from '../utils'
// These tests help to notice crash with invalid translated strings
describe('Page crash', () => {
let videoPublishPage: VideoPublishPage
let loginPage: LoginPage
let videoWatchPage: VideoWatchPage
let adminConfigPage: AdminConfigPage
const languages = [
'العربية',
'Català',
'Čeština',
'Deutsch',
'ελληνικά',
'Esperanto',
'Español',
'Euskara',
'فارسی',
'Suomi',
'Français',
'Gàidhlig',
'Galego',
'Hrvatski',
'Magyar',
'Íslenska',
'Italiano',
'日本語',
'Taqbaylit',
'Norsk bokmål',
'Nederlands',
'Norsk nynorsk',
'Occitan',
'Polski',
'Português (Brasil)',
'Português (Portugal)',
'Pусский',
'Slovenčina',
'Shqip',
'Svenska',
'ไทย',
'Toki Pona',
'Türkçe',
'украї́нська мо́ва',
'Tiếng Việt',
'简体中文(中国)',
'繁體中文(台灣)'
]
before(async () => {
await waitServerUp()
adminConfigPage = new AdminConfigPage()
loginPage = new LoginPage(isMobileDevice())
videoPublishPage = new VideoPublishPage()
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
await prepareWebBrowser()
})
for (const language of languages) {
describe('For language: ' + language, () => {
describe('Logged in user', () => {
before(async () => {
await loginPage.loginAsRootUser()
})
it('Should change the language', async function () {
await go('/')
await $('.settings-button').waitForClickable()
await $('.settings-button').click()
await selectCustomSelect('language', language)
await $('my-user-interface-settings .primary-button').waitForClickable()
await $('my-user-interface-settings .primary-button').click()
})
it('Should upload and watch a video', async function () {
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video3.mp4')
await videoPublishPage.validSecondStep('video')
await videoPublishPage.clickOnWatch()
await videoWatchPage.waitWatchVideoName('video')
})
it('Should set a homepage', async function () {
await adminConfigPage.updateHomepage('My custom homepage content')
await adminConfigPage.save()
// All tests
await go('/home')
await $('*=My custom homepage content').waitForDisplayed()
})
it('Should go on overview page and not crash', async function () {
await $('a[href="/videos/overview"]').waitForClickable()
await $('a[href="/videos/overview"]').click()
await $('my-video-overview').waitForExist()
})
it('Should go on videos from subscriptions page', async function () {
await $('a[href="/videos/subscriptions"]').waitForClickable()
await $('a[href="/videos/subscriptions"]').click()
await $('my-videos-user-subscriptions').waitForExist()
})
})
describe('Anonymous user', () => {
before(async () => {
await adminConfigPage.toggleSignup(true)
await adminConfigPage.save()
await loginPage.logout()
await browser.refresh()
})
it('Should go on signup page', async function () {
await $('.create-account-button').waitForClickable()
await $('.create-account-button').click()
await $('.callout-content > h4').waitForExist()
})
})
after(async () => {
await browser.saveScreenshot(getScreenshotPath(`after-page-crash-test-${language}.png`))
})
})
}
})

View File

@@ -0,0 +1,83 @@
import { AnonymousSettingsPage } from '../po/anonymous-settings.po'
import { LoginPage } from '../po/login.po'
import { MyAccountPage } from '../po/my-account.po'
import { VideoPublishPage } from '../po/video-publish.po'
import { VideoWatchPage } from '../po/video-watch.po'
import { go, isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
describe('Player settings', () => {
let videoPublishPage: VideoPublishPage
let loginPage: LoginPage
let videoWatchPage: VideoWatchPage
let myAccountPage: MyAccountPage
let anonymousSettingsPage: AnonymousSettingsPage
before(async () => {
await waitServerUp()
loginPage = new LoginPage(isMobileDevice())
videoPublishPage = new VideoPublishPage()
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
myAccountPage = new MyAccountPage()
anonymousSettingsPage = new AnonymousSettingsPage()
await prepareWebBrowser({ hidePrivacyConcerns: false })
})
describe('P2P', function () {
let videoUrl: string
async function goOnVideoWatchPage () {
await go(videoUrl)
await videoWatchPage.waitWatchVideoName('video')
}
async function checkP2P (enabled: boolean) {
await goOnVideoWatchPage()
expect(await videoWatchPage.isPrivacyWarningDisplayed()).toEqual(enabled)
await videoWatchPage.goOnAssociatedEmbed()
expect(await videoWatchPage.isEmbedWarningDisplayed()).toEqual(enabled)
}
before(async () => {
await loginPage.loginAsRootUser()
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video.mp4')
await videoPublishPage.validSecondStep('video')
await videoPublishPage.clickOnWatch()
await videoWatchPage.waitWatchVideoName('video')
videoUrl = await browser.getUrl()
})
beforeEach(async function () {
await goOnVideoWatchPage()
})
it('Should have P2P enabled for a logged in user', async function () {
await checkP2P(true)
})
it('Should disable P2P for a logged in user', async function () {
await myAccountPage.navigateToMySettings()
await myAccountPage.clickOnP2PCheckbox()
await checkP2P(false)
})
it('Should have P2P enabled for anonymous users', async function () {
await loginPage.logout()
await checkP2P(true)
})
it('Should disable P2P for an anonymous user', async function () {
await anonymousSettingsPage.openSettings()
await anonymousSettingsPage.clickOnP2PCheckbox()
await checkP2P(false)
})
})
})

View File

@@ -0,0 +1,86 @@
import { AdminPluginPage } from '../po/admin-plugin.po'
import { LoginPage } from '../po/login.po'
import { VideoPublishPage } from '../po/video-publish.po'
import { getCheckbox, isMobileDevice, prepareWebBrowser, waitServerUp } from '../utils'
describe('Plugins', () => {
let videoPublishPage: VideoPublishPage
let loginPage: LoginPage
let adminPluginPage: AdminPluginPage
function getPluginCheckbox () {
return getCheckbox('hello-world-field-4')
}
async function expectSubmitError (hasError: boolean) {
await videoPublishPage.clickOnSave()
await $('.form-error*=Should be enabled').waitForDisplayed({ reverse: !hasError })
await $('li*=Should be enabled').waitForDisplayed({ reverse: !hasError })
}
before(async () => {
await waitServerUp()
})
beforeEach(async () => {
loginPage = new LoginPage(isMobileDevice())
videoPublishPage = new VideoPublishPage()
adminPluginPage = new AdminPluginPage()
await prepareWebBrowser()
})
it('Should install hello world plugin', async () => {
await loginPage.loginAsRootUser()
await adminPluginPage.navigateToPluginSearch()
await adminPluginPage.search('hello-world')
await adminPluginPage.installHelloWorld()
await browser.refresh()
})
it('Should have checkbox in video edit page', async () => {
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video.mp4')
const el = () => $('span=Super field 4 in main tab')
await el().waitForDisplayed()
// Only displayed if the video is public
await videoPublishPage.setAsPrivate()
await el().waitForDisplayed({ reverse: true })
await videoPublishPage.setAsPublic()
await el().waitForDisplayed()
const checkbox = await getPluginCheckbox()
expect(await checkbox.isDisplayed()).toBeTruthy()
await expectSubmitError(true)
})
it('Should check the checkbox and be able to submit the video', async function () {
const checkbox = await getPluginCheckbox()
await checkbox.waitForClickable()
await checkbox.click()
await expectSubmitError(false)
})
it('Should uncheck the checkbox and not be able to submit the video', async function () {
const checkbox = await getPluginCheckbox()
await checkbox.waitForClickable()
await checkbox.click()
await expectSubmitError(true)
})
it('Should change the privacy and should hide the checkbox', async function () {
await videoPublishPage.setAsPrivate()
await expectSubmitError(false)
})
})

View File

@@ -0,0 +1,57 @@
import { AdminConfigPage } from '../po/admin-config.po'
import { LoginPage } from '../po/login.po'
import { VideoPublishPage } from '../po/video-publish.po'
import { VideoWatchPage } from '../po/video-watch.po'
import { isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
describe('Publish live', function () {
let videoPublishPage: VideoPublishPage
let loginPage: LoginPage
let adminConfigPage: AdminConfigPage
let videoWatchPage: VideoWatchPage
before(async () => {
await waitServerUp()
loginPage = new LoginPage(isMobileDevice())
videoPublishPage = new VideoPublishPage()
adminConfigPage = new AdminConfigPage()
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
await prepareWebBrowser()
await loginPage.loginAsRootUser()
})
it('Should enable live', async function () {
await adminConfigPage.toggleLive(true)
await adminConfigPage.save()
})
it('Should create a classic permanent live', async function () {
await videoPublishPage.navigateTo('Go live')
await videoPublishPage.publishLive()
await videoPublishPage.validSecondStep('Permanent live test')
expect(await videoPublishPage.getLiveState()).toEqual('permanent')
await videoPublishPage.clickOnWatch()
await videoWatchPage.waitWatchVideoName('Permanent live test')
})
it('Should create a permanent live and update it to a normal live', async function () {
await videoPublishPage.navigateTo('Go live')
await videoPublishPage.publishLive()
await videoPublishPage.setNormalLive()
await videoPublishPage.validSecondStep('Normal live test')
await videoPublishPage.clickOnWatch()
await videoWatchPage.waitWatchVideoName('Normal live test')
await videoWatchPage.clickOnManage()
expect(await videoPublishPage.getLiveState()).toEqual('normal')
})
})

View File

@@ -0,0 +1,79 @@
import { LoginPage } from '../po/login.po'
import { VideoPublishPage } from '../po/video-publish.po'
import { VideoWatchPage } from '../po/video-watch.po'
import { isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
describe('Publish video', () => {
let videoPublishPage: VideoPublishPage
let loginPage: LoginPage
let videoWatchPage: VideoWatchPage
before(async () => {
await waitServerUp()
loginPage = new LoginPage(isMobileDevice())
videoPublishPage = new VideoPublishPage()
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
await prepareWebBrowser()
await loginPage.loginAsRootUser()
})
describe('Default upload values', function () {
it('Should have default video values', async function () {
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video3.mp4')
await videoPublishPage.validSecondStep('video')
await videoPublishPage.clickOnWatch()
await videoWatchPage.waitWatchVideoName('video')
expect(await videoWatchPage.getPrivacy()).toBe('Public')
expect(await videoWatchPage.getLicence()).toBe('Unknown')
expect(await videoWatchPage.isDownloadEnabled()).toBeTruthy()
expect(await videoWatchPage.areCommentsEnabled()).toBeTruthy()
})
})
describe('Common', function () {
it('Should upload a video and on refresh being redirected to the manage page', async function () {
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video.mp4')
await videoPublishPage.validSecondStep('first video')
await videoPublishPage.refresh('first video')
})
it('Should upload a video and schedule upload date', async function () {
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video.mp4')
await videoPublishPage.scheduleUpload()
await videoPublishPage.validSecondStep('scheduled')
await videoPublishPage.refresh('scheduled')
expect(videoPublishPage.getScheduleInput()).toBeDisplayed()
const nextDay = new Date()
nextDay.setDate(1)
nextDay.setMonth(nextDay.getMonth() + 1)
const inputDate = new Date(await videoPublishPage.getScheduleInput().getValue())
expect(inputDate.getDate()).toEqual(nextDay.getDate())
expect(inputDate.getMonth()).toEqual(nextDay.getMonth())
expect(inputDate.getFullYear()).toEqual(nextDay.getFullYear())
})
})
describe('Import', function () {
it('Should import a video and on refresh being redirected to the manage page', async function () {
await videoPublishPage.navigateTo()
await videoPublishPage.importVideo()
await videoPublishPage.validSecondStep('second video')
await videoPublishPage.refresh('second video')
})
})
})

View File

@@ -0,0 +1,408 @@
import { AdminConfigPage } from '../po/admin-config.po'
import { AdminRegistrationPage } from '../po/admin-registration.po'
import { LoginPage } from '../po/login.po'
import { SignupPage } from '../po/signup.po'
import {
browserSleep,
findEmailTo,
getEmailPort,
getScreenshotPath,
getVerificationLink,
go,
isMobileDevice,
MockSMTPServer,
prepareWebBrowser,
waitServerUp
} from '../utils'
function checkEndMessage (options: {
message: string
requiresEmailVerification: boolean
requiresApproval: boolean
afterEmailVerification: boolean
}) {
const { message, requiresApproval, requiresEmailVerification, afterEmailVerification } = options
{
const created = 'account has been created'
const request = 'account request has been sent'
if (requiresApproval) {
expect(message).toContain(request)
expect(message).not.toContain(created)
} else {
expect(message).not.toContain(request)
expect(message).toContain(created)
}
}
{
const checkEmail = 'Check your email'
if (requiresEmailVerification) {
expect(message).toContain(checkEmail)
} else {
expect(message).not.toContain(checkEmail)
const moderatorsApproval = 'moderator will check your registration request'
if (requiresApproval) {
expect(message).toContain(moderatorsApproval)
} else {
expect(message).not.toContain(moderatorsApproval)
}
}
}
{
const emailVerified = 'email has been verified'
if (afterEmailVerification) {
expect(message).toContain(emailVerified)
} else {
expect(message).not.toContain(emailVerified)
}
}
}
describe('Signup', () => {
let loginPage: LoginPage
let adminConfigPage: AdminConfigPage
let signupPage: SignupPage
let adminRegistrationPage: AdminRegistrationPage
async function prepareSignup (options: {
enabled: boolean
requiresApproval?: boolean
requiresEmailVerification?: boolean
}) {
await loginPage.loginAsRootUser()
// Ensure we change the state of the form to "dirty" so we can save the form
await adminConfigPage.toggleSignup(options.enabled)
if (options.enabled) {
if (options.requiresApproval !== undefined) {
await adminConfigPage.toggleSignupApproval(options.requiresApproval)
}
if (options.requiresEmailVerification !== undefined) {
await adminConfigPage.toggleSignupEmailVerification(options.requiresEmailVerification)
}
}
await adminConfigPage.save()
await loginPage.logout()
await browser.refresh()
}
before(async () => {
await waitServerUp()
})
beforeEach(async () => {
loginPage = new LoginPage(isMobileDevice())
adminConfigPage = new AdminConfigPage()
signupPage = new SignupPage()
adminRegistrationPage = new AdminRegistrationPage()
await prepareWebBrowser()
})
describe('Signup disabled', function () {
it('Should disable signup', async () => {
await prepareSignup({ enabled: false })
await expect(signupPage.getRegisterMenuButton()).not.toBeDisplayed()
})
})
describe('Email verification disabled', function () {
describe('Direct registration', function () {
it('Should enable signup without approval', async () => {
await prepareSignup({ enabled: true, requiresApproval: false, requiresEmailVerification: false })
await signupPage.getRegisterMenuButton().waitForDisplayed()
})
it('Should go on signup page', async function () {
await signupPage.clickOnRegisterButton()
})
it('Should validate the first step (about page)', async function () {
await signupPage.validateStep()
})
it('Should validate the second step (terms)', async function () {
await signupPage.checkTerms()
await signupPage.validateStep()
})
it('Should validate the third step (account)', async function () {
await signupPage.fillAccountStep({ username: 'user_1', displayName: 'user_1_dn' })
await signupPage.validateStep()
})
it('Should validate the third step (channel)', async function () {
await signupPage.fillChannelStep({ name: 'user_1_channel' })
await signupPage.validateStep()
})
it('Should be logged in', async function () {
await loginPage.ensureIsLoggedInAs('user_1_dn')
})
it('Should have a valid end message', async function () {
const message = await signupPage.getEndMessage()
checkEndMessage({
message,
requiresEmailVerification: false,
requiresApproval: false,
afterEmailVerification: false
})
await browser.saveScreenshot(getScreenshotPath('direct-without-email.png'))
await loginPage.logout()
})
})
describe('Registration with approval', function () {
it('Should enable signup with approval', async () => {
await prepareSignup({ enabled: true, requiresApproval: true, requiresEmailVerification: false })
await signupPage.getRegisterMenuButton().waitForDisplayed()
})
it('Should go on signup page', async function () {
await signupPage.clickOnRegisterButton()
})
it('Should validate the first step (about page)', async function () {
await signupPage.validateStep()
})
it('Should validate the second step (terms)', async function () {
await signupPage.checkTerms()
await signupPage.fillRegistrationReason('my super reason')
await signupPage.validateStep()
})
it('Should validate the third step (account)', async function () {
await signupPage.fillAccountStep({ username: 'user_2', displayName: 'user_2 display name', password: 'superpassword' })
await signupPage.validateStep()
})
it('Should validate the third step (channel)', async function () {
await signupPage.fillChannelStep({ name: 'user_2_channel' })
await signupPage.validateStep()
})
it('Should have a valid end message', async function () {
const message = await signupPage.getEndMessage()
checkEndMessage({
message,
requiresEmailVerification: false,
requiresApproval: true,
afterEmailVerification: false
})
await browser.saveScreenshot(getScreenshotPath('request-without-email.png'))
})
it('Should display a message when trying to login with this account', async function () {
const error = await loginPage.getLoginError('user_2', 'superpassword')
expect(error).toContain('awaiting approval')
})
it('Should accept the registration', async function () {
await loginPage.loginAsRootUser()
await adminRegistrationPage.navigateToRegistrationsList()
await adminRegistrationPage.accept('user_2', 'moderation response')
await loginPage.logout()
})
it('Should be able to login with this new account', async function () {
await loginPage.login({ username: 'user_2', password: 'superpassword', displayName: 'user_2 display name' })
await loginPage.logout()
})
})
})
describe('Email verification enabled', function () {
const emails: any[] = []
before(async () => {
await MockSMTPServer.Instance.collectEmails(await getEmailPort(), emails)
})
describe('Direct registration', function () {
it('Should enable signup without approval', async () => {
await prepareSignup({ enabled: true, requiresApproval: false, requiresEmailVerification: true })
await signupPage.getRegisterMenuButton().waitForDisplayed()
})
it('Should go on signup page', async function () {
await signupPage.clickOnRegisterButton()
})
it('Should validate the first step (about page)', async function () {
await signupPage.validateStep()
})
it('Should validate the second step (terms)', async function () {
await signupPage.checkTerms()
await signupPage.validateStep()
})
it('Should validate the third step (account)', async function () {
await signupPage.fillAccountStep({ username: 'user_3', displayName: 'user_3 display name', email: 'user_3@example.com' })
await signupPage.validateStep()
})
it('Should validate the third step (channel)', async function () {
await signupPage.fillChannelStep({ name: 'user_3_channel' })
await signupPage.validateStep()
})
it('Should have a valid end message', async function () {
const message = await signupPage.getEndMessage()
checkEndMessage({
message,
requiresEmailVerification: true,
requiresApproval: false,
afterEmailVerification: false
})
await browser.saveScreenshot(getScreenshotPath('direct-with-email.png'))
})
it('Should validate the email', async function () {
let email: { text: string }
while (!(email = findEmailTo(emails, 'user_3@example.com'))) {
await browserSleep(100)
}
await go(getVerificationLink(email))
const message = await signupPage.getEndMessage()
checkEndMessage({
message,
requiresEmailVerification: false,
requiresApproval: false,
afterEmailVerification: true
})
await browser.saveScreenshot(getScreenshotPath('direct-after-email.png'))
})
})
describe('Registration with approval', function () {
it('Should enable signup without approval', async () => {
await prepareSignup({ enabled: true, requiresApproval: true, requiresEmailVerification: true })
await signupPage.getRegisterMenuButton().waitForDisplayed()
})
it('Should go on signup page', async function () {
await signupPage.clickOnRegisterButton()
})
it('Should validate the first step (about page)', async function () {
await signupPage.validateStep()
})
it('Should validate the second step (terms)', async function () {
await signupPage.checkTerms()
await signupPage.fillRegistrationReason('my super reason 2')
await signupPage.validateStep()
})
it('Should validate the third step (account)', async function () {
await signupPage.fillAccountStep({
username: 'user_4',
displayName: 'user_4 display name',
email: 'user_4@example.com',
password: 'superpassword'
})
await signupPage.validateStep()
})
it('Should validate the third step (channel)', async function () {
await signupPage.fillChannelStep({ name: 'user_4_channel' })
await signupPage.validateStep()
})
it('Should have a valid end message', async function () {
const message = await signupPage.getEndMessage()
checkEndMessage({
message,
requiresEmailVerification: true,
requiresApproval: true,
afterEmailVerification: false
})
await browser.saveScreenshot(getScreenshotPath('request-with-email.png'))
})
it('Should display a message when trying to login with this account', async function () {
const error = await loginPage.getLoginError('user_4', 'superpassword')
expect(error).toContain('awaiting approval')
})
it('Should accept the registration', async function () {
await loginPage.loginAsRootUser()
await adminRegistrationPage.navigateToRegistrationsList()
await adminRegistrationPage.accept('user_4', 'moderation response 2')
await loginPage.logout()
})
it('Should validate the email', async function () {
let email: { text: string }
while (!(email = findEmailTo(emails, 'user_4@example.com'))) {
await browserSleep(100)
}
await go(getVerificationLink(email))
const message = await signupPage.getEndMessage()
checkEndMessage({
message,
requiresEmailVerification: false,
requiresApproval: true,
afterEmailVerification: true
})
await browser.saveScreenshot(getScreenshotPath('request-after-email.png'))
})
})
after(() => {
MockSMTPServer.Instance.kill()
})
})
after(async () => {
await browser.saveScreenshot(getScreenshotPath('after-test.png'))
})
})

View File

@@ -0,0 +1,76 @@
import { AdminConfigPage } from '../po/admin-config.po'
import { LoginPage } from '../po/login.po'
import { MyAccountPage } from '../po/my-account.po'
import {
browserSleep,
findEmailTo,
getEmailPort,
getVerificationLink,
go,
isMobileDevice,
MockSMTPServer,
prepareWebBrowser,
waitServerUp
} from '../utils'
describe('User settings', () => {
let loginPage: LoginPage
let myAccountPage: MyAccountPage
let adminConfigPage: AdminConfigPage
const emails: any[] = []
before(async () => {
await waitServerUp()
loginPage = new LoginPage(isMobileDevice())
myAccountPage = new MyAccountPage()
adminConfigPage = new AdminConfigPage()
await MockSMTPServer.Instance.collectEmails(await getEmailPort(), emails)
await prepareWebBrowser()
})
describe('Email', function () {
before(async function () {
await loginPage.loginAsRootUser()
await adminConfigPage.toggleSignup(true)
await adminConfigPage.toggleSignupEmailVerification(true)
await adminConfigPage.save()
await browser.refresh()
})
it('Should ask to change the email', async function () {
await myAccountPage.navigateToMySettings()
await myAccountPage.updateEmail('email2@example.com', loginPage.getRootPassword())
const pendingEmailBlock = $('.pending-email')
await pendingEmailBlock.waitForDisplayed()
await expect(pendingEmailBlock).toHaveText(expect.stringContaining('email2@example.com is awaiting email verification'))
let email: { text: string }
while (!(email = findEmailTo(emails, 'email2@example.com'))) {
await browserSleep(100)
}
await go(getVerificationLink(email))
const alertBlock = $('.alert-success')
await alertBlock.waitForDisplayed()
await expect(alertBlock).toHaveText(expect.stringContaining('Email updated'))
await myAccountPage.navigateToMySettings()
const changeEmailBlock = $('.change-email .form-group-description')
await changeEmailBlock.waitForDisplayed()
await expect(changeEmailBlock).toHaveText(expect.stringContaining('Your current email is email2@example.com'))
})
})
after(() => {
MockSMTPServer.Instance.kill()
})
})

View File

@@ -0,0 +1,226 @@
import { LoginPage } from '../po/login.po'
import { MyAccountPage } from '../po/my-account.po'
import { PlayerPage } from '../po/player.po'
import { SignupPage } from '../po/signup.po'
import { VideoPublishPage } from '../po/video-publish.po'
import { VideoWatchPage } from '../po/video-watch.po'
import { go, isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
describe('Password protected videos', () => {
let videoPublishPage: VideoPublishPage
let loginPage: LoginPage
let videoWatchPage: VideoWatchPage
let signupPage: SignupPage
let playerPage: PlayerPage
let myAccountPage: MyAccountPage
let passwordProtectedVideoUrl: string
let playlistUrl: string
const seed = Math.random()
const passwordProtectedVideoName = seed + ' - password protected'
const publicVideoName1 = seed + ' - public 1'
const publicVideoName2 = seed + ' - public 2'
const videoPassword = 'password'
const regularUsername = 'user_1'
const regularUserPassword = 'user password'
const playlistName = seed + ' - playlist'
function testRateAndComment () {
it('Should add and remove like on video', async function () {
await videoWatchPage.like()
await videoWatchPage.like()
})
it('Should create thread on video', async function () {
await videoWatchPage.createThread('My first comment')
})
it('Should reply to thread on video', async function () {
await videoWatchPage.createReply('My first reply')
})
}
before(async () => {
await waitServerUp()
loginPage = new LoginPage(isMobileDevice())
videoPublishPage = new VideoPublishPage()
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
signupPage = new SignupPage()
playerPage = new PlayerPage()
myAccountPage = new MyAccountPage()
await prepareWebBrowser()
})
describe('Owner', function () {
before(async () => {
await loginPage.loginAsRootUser()
})
it('Should login, upload a public video and save it to a playlist', async () => {
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video.mp4')
await videoPublishPage.validSecondStep(publicVideoName1)
await videoPublishPage.clickOnWatch()
await videoWatchPage.waitWatchVideoName(publicVideoName1)
await videoWatchPage.clickOnSave()
await videoWatchPage.createPlaylist(playlistName)
await videoWatchPage.saveToPlaylist(playlistName)
await browser.pause(5000)
})
it('Should upload a password protected video', async () => {
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video2.mp4')
await videoPublishPage.setAsPasswordProtected(videoPassword)
await videoPublishPage.validSecondStep(passwordProtectedVideoName)
await videoPublishPage.clickOnWatch()
await videoWatchPage.waitWatchVideoName(passwordProtectedVideoName)
passwordProtectedVideoUrl = await browser.getUrl()
})
it('Should save to playlist the password protected video', async () => {
await videoWatchPage.clickOnSave()
await videoWatchPage.saveToPlaylist(playlistName)
})
it('Should upload a second public video and save it to playlist', async () => {
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video3.mp4')
await videoPublishPage.validSecondStep(publicVideoName2)
await videoPublishPage.clickOnWatch()
await videoWatchPage.waitWatchVideoName(publicVideoName2)
await videoWatchPage.clickOnSave()
await videoWatchPage.saveToPlaylist(playlistName)
})
it('Should play video without password', async function () {
await go(passwordProtectedVideoUrl)
await videoWatchPage.waitWatchVideoName(passwordProtectedVideoName)
expect(await videoWatchPage.getPrivacy()).toBe('Password protected')
await playerPage.playAndPauseVideo(false, 2)
})
testRateAndComment()
it('Should play video on embed without password', async function () {
await videoWatchPage.goOnAssociatedEmbed()
await playerPage.playAndPauseVideo(false, 2)
})
it('Should have the playlist in my account', async function () {
await go('/')
await myAccountPage.navigateToMyPlaylists()
const videosNumberText = await myAccountPage.getPlaylistVideosText(playlistName)
expect(videosNumberText).toEqual('3 videos')
await myAccountPage.clickOnPlaylist(playlistName)
const count = await myAccountPage.countTotalPlaylistElements()
expect(count).toEqual(3)
})
it('Should update the playlist to public', async () => {
const url = await browser.getUrl()
const regex = /\/my-library\/video-playlists\/([^/]+)/i
const match = url.match(regex)
const uuid = match ? match[1] : null
expect(uuid).not.toBeNull()
await myAccountPage.updatePlaylistPrivacy(uuid, 'Public')
})
it('Should watch the playlist', async () => {
await myAccountPage.clickOnPlaylist(playlistName)
await myAccountPage.playPlaylist()
await videoWatchPage.waitWatchVideoName(publicVideoName1, 40 * 1000)
playlistUrl = await browser.getUrl()
await videoWatchPage.waitWatchVideoName(passwordProtectedVideoName, 40 * 1000)
await videoWatchPage.waitWatchVideoName(publicVideoName2, 40 * 1000)
})
after(async () => {
await loginPage.logout()
})
})
describe('Regular users', function () {
before(async () => {
await signupPage.fullSignup({
accountInfo: {
username: regularUsername,
password: regularUserPassword
},
channelInfo: {
name: 'user_1_channel'
}
})
})
it('Should requires password to play video', async function () {
await go(passwordProtectedVideoUrl)
await videoWatchPage.fillVideoPassword(videoPassword)
await videoWatchPage.waitWatchVideoName(passwordProtectedVideoName)
expect(await videoWatchPage.getPrivacy()).toBe('Password protected')
await playerPage.playAndPauseVideo(true, 2)
})
testRateAndComment()
it('Should requires password to play video on embed', async function () {
await videoWatchPage.goOnAssociatedEmbed(true)
await playerPage.fillEmbedVideoPassword(videoPassword)
await playerPage.playAndPauseVideo(false, 2)
})
it('Should watch the playlist without password protected video', async () => {
await go(playlistUrl)
await playerPage.playVideo()
await videoWatchPage.waitWatchVideoName(publicVideoName2, 40 * 1000)
})
after(async () => {
await loginPage.logout()
})
})
describe('Anonymous users', function () {
it('Should requires password to play video', async function () {
await go(passwordProtectedVideoUrl)
await videoWatchPage.fillVideoPassword(videoPassword)
await videoWatchPage.waitWatchVideoName(passwordProtectedVideoName)
expect(await videoWatchPage.getPrivacy()).toBe('Password protected')
await playerPage.playAndPauseVideo(true, 2)
})
it('Should requires password to play video on embed', async function () {
await videoWatchPage.goOnAssociatedEmbed(true)
await playerPage.fillEmbedVideoPassword(videoPassword)
await playerPage.playAndPauseVideo(false, 2)
})
it('Should watch the playlist without password protected video', async () => {
await go(playlistUrl)
await playerPage.playVideo()
await videoWatchPage.waitWatchVideoName(publicVideoName2, 40 * 1000)
})
})
})

9
client/e2e/src/types/wdio.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
declare global {
namespace WebdriverIO {
interface Element {
chooseFile: (path: string) => Promise<void>
}
}
}
export {}

View File

@@ -0,0 +1,67 @@
export async function browserSleep (amount: number) {
await browser.pause(amount)
}
// ---------------------------------------------------------------------------
export function isMobileDevice () {
const platformName = (browser.capabilities['platformName'] || '').toLowerCase()
return platformName === 'android' || platformName === 'ios'
}
export function isAndroid () {
const platformName = (browser.capabilities['platformName'] || '').toLowerCase()
return platformName === 'android'
}
export function isSafari () {
return browser.capabilities['browserName'] &&
browser.capabilities['browserName'].toLowerCase() === 'safari'
}
export function isIOS () {
return isMobileDevice() && isSafari()
}
export async function go (url: string) {
await browser.url(url)
await browser.execute(() => {
const style = document.createElement('style')
style.innerHTML = 'p-toast { display: none }'
document.head.appendChild(style)
})
}
// ---------------------------------------------------------------------------
export async function prepareWebBrowser (options: {
hidePrivacyConcerns?: boolean // default true
} = {}) {
const { hidePrivacyConcerns = true } = options
if (hidePrivacyConcerns) {
try {
await browser.execute(() => {
localStorage.setItem('video-watch-privacy-concern', 'true')
})
} catch {
console.log('Cannot set local storage to hide privacy concerns')
}
}
if (!isMobileDevice() && process.env.MOZ_HEADLESS_WIDTH) {
await browser.setWindowSize(+process.env.MOZ_HEADLESS_WIDTH, +process.env.MOZ_HEADLESS_HEIGHT)
}
}
export async function waitServerUp () {
await browser.waitUntil(async () => {
await go('/')
await browserSleep(500)
return $('<my-app>').isDisplayed()
}, { timeout: 20 * 1000 })
}

View File

@@ -0,0 +1,76 @@
export async function getCheckbox (name: string) {
const input = $(`my-peertube-checkbox input[id=${name}]`)
await input.waitForExist()
return input.parentElement()
}
export function isCheckboxSelected (name: string) {
return $(`input[id=${name}]`).isSelected()
}
export async function setCheckboxEnabled (name: string, enabled: boolean) {
if (await isCheckboxSelected(name) === enabled) return
const checkbox = await getCheckbox(name)
await checkbox.scrollIntoView({ block: 'center' })
await checkbox.waitForClickable()
await checkbox.click()
}
// ---------------------------------------------------------------------------
export async function isRadioSelected (name: string) {
await $(`input[id=${name}] + label`).waitForClickable()
return $(`input[id=${name}]`).isSelected()
}
export async function clickOnRadio (name: string) {
const label = $(`input[id=${name}] + label`)
await label.waitForClickable()
await label.click()
}
// ---------------------------------------------------------------------------
export async function selectCustomSelect (id: string, valueLabel: string) {
const wrapper = $(`[formcontrolname=${id}] span[role=combobox]`)
await wrapper.waitForExist()
await wrapper.scrollIntoView({ block: 'center' })
await wrapper.waitForClickable()
await wrapper.click()
const getOption = async () => {
const options = await $$(`[formcontrolname=${id}] li[role=option]`).filter(async o => {
const text = await o.getText()
return text.trimStart().startsWith(valueLabel)
})
if (options.length === 0) return undefined
return options[0]
}
await browser.waitUntil(async () => {
const option = await getOption()
if (!option) return false
return option.isDisplayed()
})
return (await getOption()).click()
}
export async function findParentElement (
el: ChainablePromiseElement,
finder: (el: ChainablePromiseElement) => Promise<boolean>
) {
if (await finder(el) === true) return el
return findParentElement(el.parentElement(), finder)
}

View File

@@ -0,0 +1,38 @@
export function getVerificationLink (email: { text: string }) {
const { text } = email
const regexp = /\[(?<link>http:\/\/[^\]]+)\]/g
const matched = text.matchAll(regexp)
if (!matched) throw new Error('Could not find verification link in email')
for (const match of matched) {
const link = match.groups.link
if (link.includes('/verify-account/')) {
return link.replace('127.0.0.1', 'localhost')
}
}
throw new Error('Could not find /verify-account/ link')
}
export function findEmailTo (emails: { text: string, to: { address: string }[] }[], to: string) {
for (const email of emails) {
for (const { address } of email.to) {
if (address === to) return email
}
}
return undefined
}
export async function getEmailPort () {
const key = browser.options.baseUrl + '-emailPort'
// FIXME: typings are wrong, get returns a promise
// FIXME: use * because the key is not properly escaped by the shared store when using get(key)
const emailPort = (await (browser.sharedStore.get('*') as unknown as Promise<number>))[key]
if (!emailPort) throw new Error('Invalid email port')
return emailPort
}

View File

@@ -0,0 +1,13 @@
import { mkdir, rm } from 'node:fs/promises'
import { join } from 'node:path'
const SCREENSHOTS_DIRECTORY = 'screenshots'
export async function createScreenshotsDirectory () {
await rm(SCREENSHOTS_DIRECTORY, { recursive: true, force: true })
await mkdir(SCREENSHOTS_DIRECTORY, { recursive: true })
}
export function getScreenshotPath (filename: string) {
return join(SCREENSHOTS_DIRECTORY, filename)
}

View File

@@ -0,0 +1,113 @@
import { setValue } from '@wdio/shared-store-service'
import { ChildProcessWithoutNullStreams } from 'node:child_process'
import { basename } from 'node:path'
import { createScreenshotsDirectory, getScreenshotPath } from './files'
import { runCommand, runServer } from './server'
let appInstance: number
let app: ChildProcessWithoutNullStreams
let emailPort: number
async function beforeLocalSuite (suite: any) {
const config = buildConfig(suite.file)
await runCommand('npm run clean:server:test -- ' + appInstance)
app = runServer(appInstance, config)
}
function afterLocalSuite () {
app.kill()
app = undefined
}
async function afterLocalTest (test: { file: string }) {
const filename = basename(test.file).replace(/\.ts$/, '')
await browser.saveScreenshot(getScreenshotPath(`${filename}-after-test.png`))
}
async function beforeLocalSession (config: { baseUrl: string }, capabilities: { browserName: string }) {
createScreenshotsDirectory()
appInstance = capabilities['browserName'] === 'chrome'
? 1
: 2
emailPort = 1025 + appInstance
config.baseUrl = 'http://localhost:900' + appInstance
await setValue(config.baseUrl + '-emailPort', emailPort)
}
async function onBrowserStackPrepare () {
const appInstance = 1
await runCommand('npm run clean:server:test -- ' + appInstance)
app = runServer(appInstance)
}
function onBrowserStackComplete () {
app.kill()
app = undefined
}
export {
afterLocalSuite,
afterLocalTest,
beforeLocalSession,
beforeLocalSuite,
onBrowserStackComplete,
onBrowserStackPrepare
}
// ---------------------------------------------------------------------------
function buildConfig (suiteFile: string = undefined) {
const filename = basename(suiteFile)
if (filename === 'custom-server-defaults.e2e-spec.ts') {
return {
defaults: {
publish: {
download_enabled: false,
comments_policy: 2,
privacy: 2,
licence: 4
},
p2p: {
webapp: {
enabled: false
},
embed: {
enabled: false
}
}
}
}
}
if (filename === 'signup.e2e-spec.ts' || filename === 'user-settings.e2e-spec.ts') {
return {
signup: {
limit: -1
},
smtp: {
hostname: '127.0.0.1',
port: emailPort
}
}
}
if (filename === 'video-password.e2e-spec.ts') {
return {
signup: {
enabled: true,
limit: -1
}
}
}
return {}
}

View File

@@ -0,0 +1,8 @@
export * from './common'
export * from './elements'
export * from './email'
export * from './files'
export * from './hooks'
export * from './mock-smtp'
export * from './server'
export * from './urls'

View File

@@ -0,0 +1,57 @@
import MailDev from '@peertube/maildev'
class MockSMTPServer {
private static instance: MockSMTPServer
private started = false
private maildev: any
private emails: object[]
collectEmails (port: number, emailsCollection: object[]) {
return new Promise<number>((res, rej) => {
this.emails = emailsCollection
if (this.started) {
return res(undefined)
}
this.maildev = new MailDev({
ip: '127.0.0.1',
smtp: port,
disableWeb: true,
silent: true
})
this.maildev.on('new', email => {
this.emails.push(email)
})
this.maildev.listen(err => {
if (err) return rej(err)
this.started = true
return res(port)
})
})
}
kill () {
if (!this.maildev) return
this.maildev.close()
this.maildev = null
MockSMTPServer.instance = null
}
static get Instance () {
return this.instance || (this.instance = new this())
}
}
// ---------------------------------------------------------------------------
export {
MockSMTPServer
}

View File

@@ -0,0 +1,68 @@
import { exec, spawn } from 'node:child_process'
import { join, resolve } from 'node:path'
function runServer (appInstance: number, config: any = {}) {
const env = Object.create(process.env)
env['NODE_OPTIONS'] = ''
env['NODE_ENV'] = 'test'
env['NODE_APP_INSTANCE'] = appInstance + ''
env['NODE_CONFIG'] = JSON.stringify({
rates_limit: {
api: {
max: 5000
},
login: {
max: 5000
}
},
log: {
level: 'warn'
},
transcoding: {
enabled: false
},
video_studio: {
enabled: false
},
...config
})
const forkOptions = {
env,
cwd: getRootCWD(),
detached: false
}
const p = spawn('node', [ join('dist', 'server.js') ], forkOptions)
p.stderr.on('data', data => console.error(data.toString()))
p.stdout.on('data', data => console.error(data.toString()))
return p
}
function runCommand (command: string) {
return new Promise<void>((res, rej) => {
// Reset NODE_OPTIONS env set by webdriverio
const env = { ...process.env, NODE_OPTIONS: '' }
const p = exec(command, { env, cwd: getRootCWD() })
p.stderr.on('data', data => console.error(data.toString()))
p.on('error', err => rej(err))
p.on('exit', () => res())
})
}
export {
runServer,
runCommand
}
// ---------------------------------------------------------------------------
function getRootCWD () {
return resolve('../..')
}

View File

@@ -0,0 +1,23 @@
const FIXTURE_URLS = {
INTERNAL_WEB_VIDEO: 'https://peertube2.cpy.re/w/pwfz7NizSdPD4mJcbbmNwa?mode=web-video&start=0',
INTERNAL_HLS_VIDEO: 'https://peertube2.cpy.re/w/pwfz7NizSdPD4mJcbbmNwa?start=0',
INTERNAL_EMBED_WEB_VIDEO: 'https://peertube2.cpy.re/videos/embed/pwfz7NizSdPD4mJcbbmNwa?mode=web-video&start=0',
INTERNAL_EMBED_HLS_VIDEO: 'https://peertube2.cpy.re/videos/embed/pwfz7NizSdPD4mJcbbmNwa?start=0',
INTERNAL_HLS_ONLY_VIDEO: 'https://peertube2.cpy.re/w/tKQmHcqdYZRdCszLUiWM3V?start=0',
INTERNAL_EMBED_HLS_ONLY_VIDEO: 'https://peertube2.cpy.re/videos/embed/tKQmHcqdYZRdCszLUiWM3V?start=0',
WEB_VIDEO: 'https://peertube2.cpy.re/w/122d093a-1ede-43bd-bd34-59d2931ffc5e',
HLS_EMBED: 'https://peertube2.cpy.re/videos/embed/969bf103-7818-43b5-94a0-de159e13de50',
HLS_PLAYLIST_EMBED: 'https://peertube2.cpy.re/video-playlists/embed/73804a40-da9a-40c2-b1eb-2c6d9eec8f0a',
LIVE_VIDEO: 'https://peertube2.cpy.re/w/oBw6LwsMWWRkmXYfuYRpJd',
IMPORT_URL: 'https://download.cpy.re/peertube/good_video.mp4'
}
export {
FIXTURE_URLS
}

28
client/e2e/tsconfig.json Normal file
View File

@@ -0,0 +1,28 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/app",
"noImplicitAny": false,
"esModuleInterop": true,
"module": "commonjs",
"target": "ES2018",
"typeRoots": [
"../node_modules/@wdio",
"../node_modules/@types",
"../node_modules"
],
"types": [
"node",
"@wdio/globals/types",
"@wdio/mocha-framework",
"expect-webdriverio"
]
},
"ts-node": {
"files": true
},
"include": [
"src/**/*.ts",
"./*.ts"
]
}

View File

@@ -0,0 +1,179 @@
import { onBrowserStackComplete, onBrowserStackPrepare } from './src/utils'
import { config as mainConfig } from './wdio.main.conf'
const user = process.env.BROWSERSTACK_USER
const key = process.env.BROWSERSTACK_KEY
if (!user) throw new Error('Miss browser stack user')
if (!key) throw new Error('Miss browser stack key')
function buildMainOptions (sessionName: string) {
return {
projectName: 'PeerTube',
buildName: 'Main E2E - ' + new Date().toISOString(),
sessionName,
consoleLogs: 'info',
networkLogs: true
}
}
function buildBStackDesktopOptions (options: {
sessionName: string
resolution: string
os?: string
osVersion?: string
}) {
const { sessionName, resolution, os, osVersion } = options
return {
'bstack:options': {
...buildMainOptions(sessionName),
os,
osVersion,
resolution
}
}
}
function buildBStackMobileOptions (options: {
sessionName: string
deviceName: string
osVersion: string
}) {
const { sessionName, deviceName, osVersion } = options
return {
'bstack:options': {
...buildMainOptions(sessionName),
realMobile: true,
osVersion,
deviceName
}
}
}
module.exports = {
config: {
...mainConfig,
user,
key,
maxInstances: 5,
capabilities: [
{
browserName: 'Chrome',
...buildBStackDesktopOptions({ sessionName: 'Latest Chrome Desktop', resolution: '1280x1024', os: 'Windows', osVersion: '8' })
},
{
browserName: 'Firefox',
browserVersion: '79', // Oldest supported version
...buildBStackDesktopOptions({ sessionName: 'Firefox ESR Desktop', resolution: '1280x1024', os: 'Windows', osVersion: '8' })
},
{
browserName: 'Safari',
browserVersion: '14',
...buildBStackDesktopOptions({ sessionName: 'Safari Desktop', resolution: '1280x1024' })
},
{
browserName: 'Firefox',
...buildBStackDesktopOptions({ sessionName: 'Firefox Latest', resolution: '1280x1024', os: 'Windows', osVersion: '8' })
},
{
browserName: 'Edge',
...buildBStackDesktopOptions({ sessionName: 'Edge Latest', resolution: '1280x1024' })
},
{
browserName: 'Chrome',
...buildBStackMobileOptions({ sessionName: 'Latest Chrome Android', deviceName: 'Samsung Galaxy S10', osVersion: '9.0' })
},
{
browserName: 'Safari',
...buildBStackMobileOptions({ sessionName: 'Safari iPhone', deviceName: 'iPhone 12', osVersion: '14' })
},
{
browserName: 'Safari',
...buildBStackMobileOptions({ sessionName: 'Safari iPad', deviceName: 'iPad Pro 12.9 2021', osVersion: '14' })
}
],
host: 'hub-cloud.browserstack.com',
connectionRetryTimeout: 240000,
waitforTimeout: 20000,
specs: [
// We don't want to test "local" tests
'./src/suites-all/*.e2e-spec.ts'
],
services: [
[
'browserstack',
{ browserstackLocal: true }
]
],
onWorkerStart: function (_cid, capabilities) {
if (capabilities['bstack:options'].realMobile === true) {
capabilities['bstack:options'].local = false
}
},
before: function () {
require('./src/commands/upload')
// Force keep alive: https://www.browserstack.com/docs/automate/selenium/error-codes/keep-alive-not-used#Node_JS
const http = require('node:http')
const https = require('node:https')
const keepAliveTimeout = 30 * 1000
// eslint-disable-next-line no-prototype-builtins
if (http.globalAgent?.hasOwnProperty('keepAlive')) {
http.globalAgent.keepAlive = true
https.globalAgent.keepAlive = true
http.globalAgent.keepAliveMsecs = keepAliveTimeout
https.globalAgent.keepAliveMsecs = keepAliveTimeout
} else {
const agent = new http.Agent({
keepAlive: true,
keepAliveMsecs: keepAliveTimeout
})
const secureAgent = new https.Agent({
keepAlive: true,
keepAliveMsecs: keepAliveTimeout
})
const httpRequest = http.request
const httpsRequest = https.request
http.request = function (options, callback) {
if (options.protocol === 'https:') {
options['agent'] = secureAgent
return httpsRequest(options, callback)
} else {
options['agent'] = agent
return httpRequest(options, callback)
}
}
}
},
onPrepare: onBrowserStackPrepare,
onComplete: onBrowserStackComplete
} as WebdriverIO.Config
}

View File

@@ -0,0 +1,53 @@
import { afterLocalSuite, afterLocalTest, beforeLocalSession, beforeLocalSuite } from './src/utils'
import { config as mainConfig } from './wdio.main.conf'
const prefs = {
'intl.accept_languages': 'en'
}
// Chrome headless does not support prefs
process.env.LANG = 'en'
// https://github.com/mozilla/geckodriver/issues/1354#issuecomment-479456411
process.env.MOZ_HEADLESS_WIDTH = '1280'
process.env.MOZ_HEADLESS_HEIGHT = '1024'
const windowSizeArg = `--window-size=${process.env.MOZ_HEADLESS_WIDTH},${process.env.MOZ_HEADLESS_HEIGHT}`
module.exports = {
config: {
...mainConfig,
runner: 'local',
maxInstances: 1,
specFileRetries: 0,
capabilities: [
{
'browserName': 'chrome',
'acceptInsecureCerts': true,
'goog:chromeOptions': {
args: [ '--disable-gpu', windowSizeArg ],
prefs
}
}
// {
// 'browserName': 'firefox',
// 'moz:firefoxOptions': {
// binary: '/usr/bin/firefox-developer-edition',
// args: [ '--headless', windowSizeArg ],
// prefs
// }
// }
],
services: [ 'shared-store' ],
beforeSession: beforeLocalSession,
beforeSuite: beforeLocalSuite,
afterSuite: afterLocalSuite,
afterTest: afterLocalTest
} as WebdriverIO.Config
}

View File

@@ -0,0 +1,48 @@
import { afterLocalSuite, afterLocalTest, beforeLocalSession, beforeLocalSuite } from './src/utils'
import { config as mainConfig } from './wdio.main.conf'
const prefs = { 'intl.accept_languages': 'en' }
process.env.LANG = 'en'
// https://github.com/mozilla/geckodriver/issues/1354#issuecomment-479456411
process.env.MOZ_HEADLESS_WIDTH = '1280'
process.env.MOZ_HEADLESS_HEIGHT = '1024'
const windowSizeArg = `--window-size=${process.env.MOZ_HEADLESS_WIDTH},${process.env.MOZ_HEADLESS_HEIGHT}`
module.exports = {
config: {
...mainConfig,
runner: 'local',
maxInstancesPerCapability: 1,
capabilities: [
{
'browserName': 'chrome',
'goog:chromeOptions': {
binary: '/usr/bin/google-chrome-stable',
args: [ '--headless', '--disable-gpu', windowSizeArg ],
prefs
}
},
{
'browserName': 'firefox',
'moz:firefoxOptions': {
binary: '/usr/bin/firefox-developer-edition',
args: [ '--headless', windowSizeArg ],
prefs
}
}
],
services: [ 'shared-store' ],
beforeSession: beforeLocalSession,
beforeSuite: beforeLocalSuite,
afterSuite: afterLocalSuite,
afterTest: afterLocalTest
} as WebdriverIO.Config
}

View File

@@ -0,0 +1,110 @@
export const config = {
//
// ====================
// Runner Configuration
// ====================
//
//
// ==================
// Specify Test Files
// ==================
// Define which test specs should run. The pattern is relative to the directory
// from which `wdio` was called.
//
// The specs are defined as an array of spec files (optionally using wildcards
// that will be expanded). The test for each spec file will be run in a separate
// worker process. In order to have a group of spec files run in the same worker
// process simply enclose them in an array within the specs array.
//
// If you are calling `wdio` from an NPM script (see https://docs.npmjs.com/cli/run-script),
// then the current working directory is where your `package.json` resides, so `wdio`
// will be called from there.
//
specs: [
'./src/suites-all/*.e2e-spec.ts',
'./src/suites-local/*.e2e-spec.ts'
],
// Patterns to exclude.
exclude: [
// 'path/to/excluded/files'
],
//
// ===================
// Test Configurations
// ===================
// Define all options that are relevant for the WebdriverIO instance here
//
// Level of logging verbosity: trace | debug | info | warn | error | silent
logLevel: 'info',
//
// Set specific log levels per logger
// loggers:
// - webdriver, webdriverio
// - @wdio/browserstack-service, @wdio/devtools-service, @wdio/sauce-service
// - @wdio/mocha-framework, @wdio/jasmine-framework
// - @wdio/local-runner
// - @wdio/sumologic-reporter
// - @wdio/cli, @wdio/config, @wdio/utils
// Level of logging verbosity: trace | debug | info | warn | error | silent
// logLevels: {
// webdriver: 'info',
// '@wdio/appium-service': 'info'
// },
//
// If you only want to run your tests until a specific amount of tests have failed use
// bail (default is 0 - don't bail, run all tests).
bail: 0,
//
// Set a base URL in order to shorten url command calls. If your `url` parameter starts
// with `/`, the base url gets prepended, not including the path portion of your baseUrl.
// If your `url` parameter starts without a scheme or `/` (like `some/path`), the base url
// gets prepended directly.
baseUrl: 'http://127.0.0.1:9001',
//
// Default timeout for all waitFor* commands.
waitforTimeout: 5000,
//
// Default timeout in milliseconds for request
// if browser driver or grid doesn't send response
connectionRetryTimeout: 120000,
//
// Default request retries count
connectionRetryCount: 3,
// Framework you want to run your specs with.
// The following are supported: Mocha, Jasmine, and Cucumber
// see also: https://webdriver.io/docs/frameworks
//
// Make sure you have the wdio adapter package for the specific framework installed
// before running any tests.
framework: 'mocha',
//
// The number of times to retry the entire specfile when it fails as a whole
specFileRetries: 2,
//
// Delay in seconds between the spec file retry attempts
// specFileRetriesDelay: 0,
//
// Whether or not retried specfiles should be retried immediately or deferred to the end of the queue
// specFileRetriesDeferred: false,
//
// Test reporter for stdout.
// The only one supported by default is 'dot'
// see also: https://webdriver.io/docs/dot-reporter
reporters: [ 'spec' ],
//
// Options to be passed to Mocha.
// See the full list at http://mochajs.org/
mochaOpts: {
ui: 'bdd',
timeout: 60000,
bail: true
},
tsConfigPath: require('node:path').join(__dirname, './tsconfig.json'),
before: function () {
require('./src/commands/upload')
}
} as Partial<WebdriverIO.Config>

211
client/eslint.config.mjs Normal file
View File

@@ -0,0 +1,211 @@
import { defineConfig, globalIgnores } from 'eslint/config'
import love from 'eslint-config-love'
import stylistic from '@stylistic/eslint-plugin'
import angular from 'angular-eslint'
export default defineConfig([
globalIgnores([
'**/node_modules/',
'**/dist',
'**/build',
'.angular'
]),
{
extends: [
love,
angular.configs.tsRecommended
],
processor: angular.processInlineTemplates,
plugins: {
'@stylistic': stylistic
},
files: [
'src/**/*.ts',
'e2e/**/*.ts'
],
rules: {
'@angular-eslint/component-selector': [
'error',
{
'type': [ 'element', 'attribute' ],
'prefix': 'my',
'style': 'kebab-case'
}
],
'@angular-eslint/directive-selector': [
'error',
{
'type': [ 'element', 'attribute' ],
'prefix': 'my',
'style': 'camelCase'
}
],
'@angular-eslint/use-component-view-encapsulation': 'error',
'@typescript-eslint/prefer-readonly': 'off',
"n/no-callback-literal": "off",
'@stylistic/semi': [ 'error', 'never' ],
'eol-last': [ 'error', 'always' ],
'indent': 'off',
'no-lone-blocks': 'off',
'no-mixed-operators': 'off',
'max-len': [ 'error', {
code: 140
} ],
'array-bracket-spacing': [ 'error', 'always' ],
'quote-props': [ 'error', 'consistent-as-needed' ],
'padded-blocks': 'off',
'no-async-promise-executor': 'off',
'dot-notation': 'off',
'promise/param-names': 'off',
'import/first': 'off',
'operator-linebreak': [ 'error', 'after', {
overrides: {
'?': 'before',
':': 'before'
}
} ],
'@typescript-eslint/consistent-type-assertions': [ 'error', {
assertionStyle: 'as'
} ],
'@typescript-eslint/array-type': [ 'error', {
default: 'array'
} ],
'@typescript-eslint/restrict-template-expressions': [ 'off', {
allowNumber: 'true'
} ],
'@typescript-eslint/no-this-alias': [ 'error', {
allowDestructuring: true,
allowedNames: [ 'self' ]
} ],
'@typescript-eslint/return-await': 'off',
'@typescript-eslint/no-base-to-string': 'off',
'@typescript-eslint/quotes': 'off',
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/promise-function-async': 'off',
'@typescript-eslint/no-dynamic-delete': 'off',
'@typescript-eslint/no-unnecessary-boolean-literal-compare': 'off',
'@typescript-eslint/strict-boolean-expressions': 'off',
'@typescript-eslint/consistent-type-definitions': 'off',
'@typescript-eslint/no-misused-promises': 'off',
'@typescript-eslint/no-namespace': 'off',
'@typescript-eslint/no-empty-interface': 'off',
'@typescript-eslint/no-extraneous-class': 'off',
'@typescript-eslint/prefer-nullish-coalescing': 'off',
'@typescript-eslint/consistent-indexed-object-style': 'off',
'@typescript-eslint/restrict-plus-operands': 'off',
'@typescript-eslint/no-unnecessary-condition': 'off',
'@typescript-eslint/consistent-type-imports': 'off',
'no-implicit-globals': 'off',
'@typescript-eslint/no-confusing-void-expression': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'logical-assignment-operators': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-magic-numbers': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-type-assertion': 'off',
'@typescript-eslint/prefer-destructuring': 'off',
'promise/avoid-new': 'off',
'@typescript-eslint/class-methods-use-this': 'off',
'arrow-body-style': 'off',
'@typescript-eslint/use-unknown-in-catch-callback-variable': 'off',
'@typescript-eslint/consistent-type-exports': 'off',
'@typescript-eslint/init-declarations': 'off',
'no-console': 'off',
'@typescript-eslint/dot-notation': 'off',
'@typescript-eslint/method-signature-style': 'off',
'eslint-comments/require-description': 'off',
'max-lines': 'off',
'@typescript-eslint/no-misused-spread': 'off',
'consistent-this': 'off',
'@typescript-eslint/no-empty-function': 'off',
'prefer-regex-literals': 'off',
'@typescript-eslint/prefer-regexp-exec': 'off',
'@typescript-eslint/prefer-promise-reject-errors': 'off',
'@typescript-eslint/no-unnecessary-template-expression': 'off',
'@typescript-eslint/no-loop-func': 'off',
'@typescript-eslint/switch-exhaustiveness-check': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/no-import-type-side-effects': 'off',
'@typescript-eslint/no-require-imports': 'off',
'no-useless-return': 'off',
'no-return-assign': 'off',
'@typescript-eslint/unbound-method': 'off',
'import/no-named-default': 'off',
'@typescript-eslint/prefer-reduce-type-parameter': 'off',
"@typescript-eslint/no-deprecated": [ 'error', {
allow: [
{ from: 'package', package: 'video.js', name: 'options'}
]
}],
// Can be interesting to enable
'@typescript-eslint/no-unsafe-return': 'off',
// Can be interesting to enable
'complexity': 'off',
// Interesting but has a bug with specific cases
'@typescript-eslint/no-unnecessary-type-parameters': 'off',
// TODO: enable
'@typescript-eslint/prefer-as-const': 'off',
// TODO: enable
'@typescript-eslint/max-params': 'off',
// TODO: enable
'@typescript-eslint/no-unsafe-function-type': 'off',
// TODO: enable
'@typescript-eslint/no-deprecated': 'off',
// TODO: enable
'@typescript-eslint/no-floating-promises': 'off',
// TODO: enable but it fails in our CI
'@typescript-eslint/no-redundant-type-constituents': 'off',
// We use many nested callbacks in our tests
'max-nested-callbacks': 'off',
'no-param-reassign': 'off',
'no-negated-condition': 'off',
'radix': 'off',
'no-plusplus': 'off',
'@typescript-eslint/no-unnecessary-type-conversion': 'off',
'no-promise-executor-return': 'off'
},
languageOptions: {
parserOptions: {
projectService: {
allowDefaultProject: [ 'src/standalone/build-tools/vite-utils.ts' ]
}
}
}
},
{
files: [ '**/*.html' ],
extends: [
angular.configs.templateRecommended,
angular.configs.templateAccessibility,
],
rules: {
// TODO: enable
'@angular-eslint/template/button-has-type': 'off'
}
}
])

126
client/package.json Normal file
View File

@@ -0,0 +1,126 @@
{
"name": "peertube-client",
"version": "8.0.2",
"private": true,
"license": "AGPL-3.0",
"author": {
"name": "Chocobozzz",
"email": "chocobozzz@framasoft.org",
"url": "http://github.com/Chocobozzz"
},
"repository": {
"type": "git",
"url": "git+https://github.com/Chocobozzz/PeerTube.git"
},
"scripts": {
"lint": "npm run lint-ts && npm run lint-scss",
"lint-ts": "eslint",
"lint-scss": "stylelint 'src/**/*.scss'",
"eslint": "eslint",
"ng": "ng",
"stylelint": "stylelint"
},
"browser": {
"net": false,
"stream": false,
"os": false,
"http": false,
"dgram": false,
"util": false
},
"workspaces": [
"../packages/*",
"./src/standalone/player"
],
"typings": "*.d.ts",
"devDependencies": {
"@angular/animations": "~20.3.16",
"@angular/build": "~20.3.15",
"@angular/cdk": "~20.2.14",
"@angular/cli": "~20.3.15",
"@angular/common": "~20.3.16",
"@angular/compiler": "~20.3.16",
"@angular/compiler-cli": "~20.3.16",
"@angular/core": "~20.3.16",
"@angular/forms": "~20.3.16",
"@angular/localize": "~20.3.16",
"@angular/platform-browser": "~20.3.16",
"@angular/platform-browser-dynamic": "~20.3.16",
"@angular/router": "~20.3.16",
"@angular/service-worker": "~20.3.16",
"@ng-bootstrap/ng-bootstrap": "^19.0.1",
"@ngx-loading-bar/core": "^7.0.0",
"@ngx-loading-bar/http-client": "^7.0.0",
"@ngx-loading-bar/router": "^7.0.0",
"@peertube/maildev": "^1.2.0",
"@peertube/player": "workspace:*",
"@peertube/xliffmerge": "^2.0.4",
"@plussub/srt-vtt-parser": "^2.0.5",
"@popperjs/core": "^2.11.8",
"@primeng/themes": "^19.1.4",
"@stylistic/eslint-plugin": "^5.5.0",
"@types/core-js": "^2.5.8",
"@types/debug": "^4.1.12",
"@types/jschannel": "^1.0.6",
"@types/linkifyjs": "^2.1.7",
"@types/lodash-es": "^4.17.12",
"@types/markdown-it": "^14.1.2",
"@types/node": "^20.19.25",
"@types/qrcode": "^1.5.6",
"@types/sanitize-html": "2.16.0",
"@types/sha.js": "^2.4.4",
"@wdio/browserstack-service": "^9.20.0",
"@wdio/cli": "^9.20.0",
"@wdio/globals": "^9.17.0",
"@wdio/local-runner": "^9.20.0",
"@wdio/mocha-framework": "^9.20.0",
"@wdio/shared-store-service": "^9.20.0",
"@wdio/spec-reporter": "^9.20.0",
"angular-eslint": "^20.6.0",
"angularx-qrcode": "20.0.0",
"bootstrap": "^5.3.8",
"buffer": "^6.0.3",
"chart.js": "^4.5.1",
"chartjs-plugin-zoom": "~2.2.0",
"color-bits": "^1.1.1",
"core-js": "^3.46.0",
"debug": "^4.4.3",
"dompurify": "^3.3.0",
"eslint": "^9.39.1",
"eslint-config-love": "^133.0.0",
"expect-webdriverio": "^5.4.3",
"hls.js": "~1.6.14",
"intl-messageformat": "^10.7.18",
"jschannel": "^1.0.2",
"linkify-html": "^4.3.2",
"linkifyjs": "^4.3.2",
"lodash-es": "^4.17.21",
"markdown-it": "14.1.0",
"markdown-it-emoji": "^3.0.0",
"ngx-uploadx": "^7.0.1",
"p2p-media-loader-core": "^2.2.1",
"p2p-media-loader-hlsjs": "^2.2.1",
"primeng": "^19.1.4",
"rxjs": "^7.8.2",
"sass-embedded": "^1.93.3",
"sha.js": "^2.4.12",
"socket.io-client": "^4.8.1",
"stylelint": "^16.25.0",
"stylelint-config-sass-guidelines": "^12.1.0",
"stylelint-order": "^7.0.0",
"tinykeys": "^3.0.0",
"ts-node": "^10.9.2",
"tslib": "^2.8.1",
"type-fest": "^5.2.0",
"typescript": "~5.9.3",
"ua-parser-js": "^2.0.6",
"video.js": "^8.23.4",
"vite": "^7.2.2",
"vite-plugin-checker": "^0.11.0",
"vite-plugin-node-polyfills": "^0.24.0",
"zone.js": "~0.15.1"
},
"dependencies": {
"country-list": "^2.4.1"
}
}

35
client/proxy.config.json Normal file
View File

@@ -0,0 +1,35 @@
{
"/api": {
"target": "http://127.0.0.1:9001",
"secure": false
},
"/plugins": {
"target": "http://127.0.0.1:9001",
"secure": false
},
"/themes": {
"target": "http://127.0.0.1:9001",
"secure": false
},
"/static": {
"target": "http://127.0.0.1:9001",
"secure": false
},
"/lazy-static": {
"target": "http://127.0.0.1:9001",
"secure": false
},
"/socket.io": {
"target": "ws://127.0.0.1:9001",
"secure": false,
"ws": true
},
"/client/assets": {
"target": "http://127.0.0.1:9001",
"secure": false
},
"/client/locales": {
"target": "http://127.0.0.1:9001",
"secure": false
}
}

View File

@@ -0,0 +1,66 @@
<div class="margin-content mt-4">
<h3 class="fs-3 fw-semibold mb-3" i18n>Contact {{ instanceName }} administrators</h3>
@if (isContactFormEnabled()) {
@if (!success) {
<form novalidate [formGroup]="form" (ngSubmit)="sendForm()">
<div class="form-group">
<label i18n for="fromName">Your name</label>
<input
type="text" id="fromName" class="form-control"
formControlName="fromName" [ngClass]="{ 'input-error': formErrors.fromName }"
autocomplete="name"
>
@if (formErrors.fromName) {
<div class="form-error" role="alert">{{ formErrors.fromName }}</div>
}
</div>
<div class="form-group">
<label i18n for="fromEmail">Your email</label>
<input
type="text" id="fromEmail" class="form-control"
formControlName="fromEmail" [ngClass]="{ 'input-error': formErrors['fromEmail'] }"
i18n-placeholder placeholder="Example: john@example.com" autocomplete="email"
>
@if (formErrors.fromEmail) {
<div class="form-error" role="alert">{{ formErrors.fromEmail }}</div>
}
</div>
<div class="form-group">
<label i18n for="subject">Subject</label>
<input
type="text" id="subject" class="form-control"
formControlName="subject" [ngClass]="{ 'input-error': formErrors['subject'] }"
>
@if (formErrors.subject) {
<div class="form-error" role="alert">{{ formErrors.subject }}</div>
}
</div>
<div class="form-group">
<label i18n for="body">Your message</label>
<textarea id="body" formControlName="body" class="form-control" [ngClass]="{ 'input-error': formErrors['body'] }"></textarea>
@if (formErrors.body) {
<div class="form-error" role="alert">{{ formErrors.body }}</div>
}
</div>
@if (error) {
<my-alert type="danger">{{ error }}</my-alert>
}
<input type="submit" i18n-value value="Submit" class="peertube-button primary-button" [disabled]="!form.valid" />
</form>
} @else {
<my-alert type="success">{{ success }}</my-alert>
}
} @else {
<my-alert type="danger" i18n>The contact form is not enabled on this instance.</my-alert>
}
</div>

View File

@@ -0,0 +1,11 @@
@use '_variables' as *;
@use '_mixins' as *;
@use '_form-mixins' as *;
input[type=text] {
@include peertube-input-text(340px);
}
textarea {
@include peertube-textarea(500px, 200px);
}

View File

@@ -0,0 +1,89 @@
import { NgClass } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute } from '@angular/router'
import { ServerService } from '@app/core'
import {
BODY_VALIDATOR,
FROM_EMAIL_VALIDATOR,
FROM_NAME_VALIDATOR,
SUBJECT_VALIDATOR
} from '@app/shared/form-validators/instance-validators'
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
import { InstanceService } from '@app/shared/shared-main/instance/instance.service'
import { HTMLServerConfig, HttpStatusCode } from '@peertube/peertube-models'
type Prefill = {
subject?: string
body?: string
}
@Component({
templateUrl: './about-contact.component.html',
styleUrls: [ './about-contact.component.scss' ],
imports: [ FormsModule, ReactiveFormsModule, NgClass, AlertComponent ]
})
export class AboutContactComponent extends FormReactive implements OnInit {
protected formReactiveService = inject(FormReactiveService)
private route = inject(ActivatedRoute)
private instanceService = inject(InstanceService)
private serverService = inject(ServerService)
error: string
success: string
private serverConfig: HTMLServerConfig
get instanceName () {
return this.serverConfig.instance.name
}
ngOnInit () {
this.serverConfig = this.serverService.getHTMLConfig()
this.buildForm({
fromName: FROM_NAME_VALIDATOR,
fromEmail: FROM_EMAIL_VALIDATOR,
subject: SUBJECT_VALIDATOR,
body: BODY_VALIDATOR
})
this.prefillForm(this.route.snapshot.queryParams)
}
isContactFormEnabled () {
return this.serverConfig.email.enabled && this.serverConfig.contactForm.enabled
}
sendForm () {
const fromName = this.form.value['fromName']
const fromEmail = this.form.value['fromEmail']
const subject = this.form.value['subject']
const body = this.form.value['body']
this.instanceService.contactAdministrator(fromEmail, fromName, subject, body)
.subscribe({
next: () => {
this.success = $localize`Your message has been sent.`
},
error: err => {
this.error = err.status === HttpStatusCode.FORBIDDEN_403
? $localize`You already sent this form recently`
: err.message
}
})
}
private prefillForm (prefill: Prefill) {
if (prefill.subject) {
this.form.get('subject').setValue(prefill.subject)
}
if (prefill.body) {
this.form.get('body').setValue(prefill.body)
}
}
}

View File

@@ -0,0 +1,94 @@
<div class="margin-content mt-5">
<div class="subscriptions me-3 mb-3">
<div class="block-header mb-4 d-flex">
<div class="flex-grow-1 me-2">
<h3 i18n>{{ subscriptionsPagination.totalItems }} {subscriptionsPagination.totalItems, plural, =1 {subscription} other {subscriptions}}</h3>
<div i18n class="text-content">
This is content to which we have subscribed. This allows us to display their videos directly on {{ instanceName }}.
</div>
</div>
<my-subscription-image></my-subscription-image>
</div>
<div class="follows">
@if (subscriptionsPagination.totalItems === 0) {
<div i18n class="no-results">{{ instanceName }} does not have subscriptions.</div>
}
@for (subscription of subscriptions; track subscription) {
<div class="follow-block">
<my-actor-avatar [actor]="subscription" actorType="instance" size="32"></my-actor-avatar>
<div class="ellipsis">
<a class="follow-name" [href]="subscription.url" target="_blank" rel="noopener noreferrer">{{ subscription.name }}</a>
</div>
</div>
}
</div>
<div class="text-center">
@if (canLoadMoreSubscriptions()) {
<my-button class="mt-3 mx-auto" (click)="loadMoreSubscriptions()" theme="secondary" i18n>Show more subscriptions</my-button>
}
</div>
@if (serverStats) {
<div class="stats mt-4">
<h4 i18n>Our network in figures</h4>
<div myPluginSelector pluginSelectorId="about-instance-network-statistics">
<div class="stat">
<strong>{{ serverStats.totalVideos | number }}</strong>
<a routerLink="/videos/browse" [queryParams]="{ scope: 'federated' }" i18n>total videos</a>
<my-global-icon iconName="videos"></my-global-icon>
</div>
<div class="stat">
<strong>{{ serverStats.totalVideoComments | number }}</strong>
<div i18n>total comments</div>
<my-global-icon iconName="message-circle"></my-global-icon>
</div>
</div>
</div>
}
</div>
<div class="followers">
<div class="block-header mb-4 d-flex">
<div class="flex-grow-1 me-2">
<h3 i18n>{{ followersPagination.totalItems }} {followersPagination.totalItems, plural, =1 {follower} other {followers}}</h3>
<div i18n class="text-content">
Our subscribers automatically display videos of {{ instanceName }} on their platforms.
</div>
</div>
<my-follower-image></my-follower-image>
</div>
<div class="follows">
@if (followersPagination.totalItems === 0) {
<div i18n class="no-results">{{ instanceName }} does not have followers.</div>
}
@for (follower of followers; track follower) {
<div class="follow-block">
<my-actor-avatar [actor]="follower" actorType="instance" size="32"></my-actor-avatar>
<div class="ellipsis">
<a class="follow-name" [href]="follower.url" target="_blank" rel="noopener noreferrer">{{ follower.name }}</a>
</div>
</div>
}
<div class="text-center">
@if (canLoadMoreFollowers()) {
<my-button class="mt-3 mx-auto" (click)="loadMoreFollowers()" theme="secondary" i18n>Show more followers</my-button>
}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,85 @@
@use '_variables' as *;
@use '_mixins' as *;
@use '_bootstrap-variables' as *;
@use '_components' as *;
.margin-content {
display: flex;
}
.text-content {
color: pvar(--fg-300);
}
.stat {
@include stats-card;
}
.stats > div {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.followers,
.subscriptions {
flex-basis: 50%;
background-color: pvar(--bg-secondary-400);
padding: 1.5rem;
border-radius: 14px;
h3 {
font-weight: $font-bold;
color: pvar(--fg-400);
@include font-size(2rem);
}
h4 {
color: pvar(--fg-300);
font-weight: $font-bold;
@include font-size(1.25rem);
}
}
.follows {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.follow-block {
width: calc(50% - 1rem);
padding: 1rem;
border-radius: 8px;
background-color: pvar(--bg-secondary-450);
display: flex;
align-items: center;
my-actor-avatar {
@include margin-right(1rem);
}
}
.follow-name {
font-weight: $font-bold;
color: pvar(--fg-400);
}
@media screen and (max-width: #{breakpoint(xl)}) {
.margin-content {
flex-wrap: wrap;
}
.followers,
.subscriptions {
flex-basis: 100%;
}
}
@include on-small-main-col {
.follow-block {
width: 100%;
}
}

View File

@@ -0,0 +1,146 @@
import { DecimalPipe } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core'
import { RouterLink } from '@angular/router'
import { ComponentPagination, hasMoreItems, Notifier, RestService, ServerService } from '@app/core'
import { ActorAvatarComponent } from '@app/shared/shared-actor-image/actor-avatar.component'
import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
import { InstanceFollowService } from '@app/shared/shared-instance/instance-follow.service'
import { ButtonComponent } from '@app/shared/shared-main/buttons/button.component'
import { PluginSelectorDirective } from '@app/shared/shared-main/plugins/plugin-selector.directive'
import { Actor, ServerStats } from '@peertube/peertube-models'
import { SortMeta } from 'primeng/api'
import { FollowerImageComponent } from './follower-image.component'
import { SubscriptionImageComponent } from './subscription-image.component'
@Component({
selector: 'my-about-follows',
templateUrl: './about-follows.component.html',
styleUrls: [ './about-follows.component.scss' ],
imports: [
ActorAvatarComponent,
ButtonComponent,
PluginSelectorDirective,
GlobalIconComponent,
DecimalPipe,
RouterLink,
SubscriptionImageComponent,
FollowerImageComponent
]
})
export class AboutFollowsComponent implements OnInit {
private server = inject(ServerService)
private restService = inject(RestService)
private notifier = inject(Notifier)
private followService = inject(InstanceFollowService)
instanceName: string
followers: Actor[] = []
subscriptions: Actor[] = []
followersPagination: ComponentPagination = {
currentPage: 1,
itemsPerPage: 20,
totalItems: 0
}
subscriptionsPagination: ComponentPagination = {
currentPage: 1,
itemsPerPage: 20,
totalItems: 0
}
serverStats: ServerStats
private loadingFollowers = false
private loadingSubscriptions = false
private sort: SortMeta = {
field: 'createdAt',
order: -1
}
ngOnInit () {
this.loadMoreFollowers(true)
this.loadMoreSubscriptions(true)
this.instanceName = this.server.getHTMLConfig().instance.name
this.server.getServerStats().subscribe(stats => this.serverStats = stats)
}
buildLink (host: string) {
return window.location.protocol + '//' + host
}
canLoadMoreFollowers () {
return hasMoreItems(this.followersPagination)
}
canLoadMoreSubscriptions () {
return hasMoreItems(this.subscriptionsPagination)
}
loadMoreFollowers (reset = false) {
if (this.loadingFollowers) return
this.loadingFollowers = true
if (reset) this.followersPagination.currentPage = 1
else this.followersPagination.currentPage++
const pagination = this.restService.componentToRestPagination(this.followersPagination)
this.followService.getFollowers({ pagination, sort: this.sort, state: 'accepted' })
.subscribe({
next: resultList => {
if (reset) this.followers = []
const newFollowers = resultList.data.map(r => this.formatFollow(r.follower))
this.followers = this.followers.concat(newFollowers)
this.followersPagination.totalItems = resultList.total
},
error: err => this.notifier.handleError(err),
complete: () => this.loadingFollowers = false
})
}
loadMoreSubscriptions (reset = false) {
if (this.loadingSubscriptions) return
this.loadingSubscriptions = true
if (reset) this.subscriptionsPagination.currentPage = 1
else this.subscriptionsPagination.currentPage++
const pagination = this.restService.componentToRestPagination(this.subscriptionsPagination)
this.followService.getFollowing({ pagination, sort: this.sort, state: 'accepted' })
.subscribe({
next: resultList => {
if (reset) this.subscriptions = []
const newFollowings = resultList.data.map(r => this.formatFollow(r.following))
this.subscriptions = this.subscriptions.concat(newFollowings)
this.subscriptionsPagination.totalItems = resultList.total
},
error: err => this.notifier.handleError(err),
complete: () => this.loadingSubscriptions = false
})
}
private formatFollow (actor: Actor) {
return {
...actor,
// Instance follow, only display host
name: actor.name === 'peertube'
? actor.host
: actor.name + '@' + actor.host
}
}
}

View File

@@ -0,0 +1,53 @@
<div class="root" aria-hidden="true">
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M42.2928 87.2622L10.5359 19.0679L28.4902 11.6038L89.4271 62.8128L42.2928 87.2622Z"
fill="url(#paint0_linear_1305_16041)" />
<path d="M57.3679 68.7467L87 26L89.4588 26L89.4588 77.6445L57.3679 68.7467Z"
fill="url(#paint1_linear_1305_16041)" />
<rect x="2.30959" y="14.776" width="37.2961" height="37.2961" rx="9" transform="rotate(-22.8223 2.30959 14.776)"
fill="var(--bg-secondary-400)" stroke="var(--secondary-icon-color)" stroke-width="2" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.0393 26.7067L15.8565 16.767L25.4049 18.5988L20.0393 26.7067Z"
fill="var(--secondary-icon-color)" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.2229 36.6467L20.0401 26.7069L29.5885 28.5387L24.2229 36.6467Z"
fill="var(--secondary-icon-color)" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.5872 28.5378L25.4043 18.598L34.9528 20.4299L29.5872 28.5378Z"
fill="var(--secondary-icon-color)" />
<path
d="M95.2828 29.4743L94.6821 27.4769C94.3634 26.4174 93.637 25.5279 92.6625 25.0041C91.6881 24.4802 90.5454 24.3649 89.4859 24.6835L83.4938 26.4856C82.4343 26.8043 81.5448 27.5307 81.0209 28.5052C80.4971 29.4796 80.3818 30.6223 80.7004 31.6818L81.3011 33.6792"
stroke="var(--bg-secondary-500)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M85.2882 21.5899C87.4944 20.9264 88.745 18.6 88.0815 16.3937C87.418 14.1875 85.0916 12.9369 82.8854 13.6004C80.6791 14.2639 79.4285 16.5903 80.092 18.7965C80.7555 21.0028 83.0819 22.2534 85.2882 21.5899Z"
stroke="var(--bg-secondary-500)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M68.0007 92.0002L22.5001 99.4999L18.0003 99.4996L52.5006 67L68.0007 92.0002Z"
fill="url(#paint2_linear_1305_16041)" />
<rect x="7.78809" y="86.0605" width="28.6783" height="28.6783" rx="6" transform="rotate(-6.5522 7.78809 86.0605)"
fill="var(--secondary-icon-color)" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.5762 98.6849L17.6782 90.8661L23.993 94.1018L18.5762 98.6849Z"
fill="var(--bg)" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.4756 106.504L18.5776 98.685L24.8924 101.921L19.4756 106.504Z"
fill="var(--bg)" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.8916 101.92L23.9936 94.1015L30.3084 97.3371L24.8916 101.92Z"
fill="var(--bg)" />
<defs>
<linearGradient id="paint0_linear_1305_16041" x1="10.3339" y1="3.88906" x2="70.4056" y2="83.557"
gradientUnits="userSpaceOnUse">
<stop offset="0.39" stop-color="var(--bg)" />
<stop offset="0.905" stop-color="var(--bg)" stop-opacity="0" />
</linearGradient>
<linearGradient id="paint1_linear_1305_16041" x1="87.5" y1="22.5" x2="75.6241" y2="83.0273"
gradientUnits="userSpaceOnUse">
<stop stop-color="var(--bg)" />
<stop offset="0.905" stop-color="var(--bg)" stop-opacity="0" />
</linearGradient>
<linearGradient id="paint2_linear_1305_16041" x1="5.83634" y1="113.619" x2="65.326" y2="61.6047"
gradientUnits="userSpaceOnUse">
<stop stop-color="var(--bg)" />
<stop offset="0.905" stop-color="var(--bg)" stop-opacity="0" />
</linearGradient>
</defs>
</svg>
@if (avatarUrl) {
<img [src]="avatarUrl" alt="">
}
</div>

View File

@@ -0,0 +1,19 @@
@use '_variables' as *;
@use '_mixins' as *;
@use '_bootstrap-variables' as *;
@use '_components' as *;
.root {
position: relative;
}
img {
width: 30px;
height: 30px;
border: 1px solid pvar(--bg-secondary-400);
border-radius: $instance-img-radius;
position: absolute;
right: 36px;
bottom: 25px;
transform: rotate(18deg);
}

View File

@@ -0,0 +1,20 @@
import { Component, OnInit, inject } from '@angular/core'
import { ServerService } from '@app/core'
import { Actor } from '@app/shared/shared-main/account/actor.model'
@Component({
selector: 'my-follower-image',
templateUrl: './follower-image.component.html',
styleUrls: [ './follower-image.component.scss' ],
standalone: true,
imports: []
})
export class FollowerImageComponent implements OnInit {
private server = inject(ServerService)
avatarUrl: string
ngOnInit () {
this.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.server.getHTMLConfig().instance, 30)
}
}

View File

@@ -0,0 +1,44 @@
<div class="root" aria-hidden="true">
@if (avatarUrl) {
<img [src]="avatarUrl" alt="">
}
<svg width="125" height="129" viewBox="0 0 125 129" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M43.4996 129L18.9946 27.2709L36.8651 13.7961L124.924 67.6037L43.4996 129Z"
fill="url(#paint0_linear_1305_16148)" />
<rect x="57.9766" y="33.5923" width="39.9544" height="39.9544" rx="10"
transform="rotate(-17.7787 57.9766 33.5923)" fill="var(--bg-secondary-450)" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M76.1448 47.9191L72.7968 37.478L82.3039 40.1868L76.1448 47.9191Z" fill="var(--secondary-icon-color)" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M79.4925 58.3605L76.1445 47.9194L85.6515 50.6282L79.4925 58.3605Z" fill="var(--secondary-icon-color)" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M85.6507 50.6276L82.3027 40.1865L91.8097 42.8953L85.6507 50.6276Z" fill="var(--secondary-icon-color)" />
<g clip-path="url(#clip0_1305_16148)">
<path
d="M39.7866 86.6025L40.6519 83.7089C41.1109 82.1741 40.9414 80.5198 40.1807 79.11C39.4199 77.7002 38.1303 76.6503 36.5955 76.1913L27.915 73.5954C26.3801 73.1364 24.7259 73.3059 23.316 74.0666C21.9062 74.8273 20.8563 76.117 20.3973 77.6518L19.532 80.5453"
stroke="var(--bg-secondary-500)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M33.9859 69.1059C37.182 70.0617 40.5478 68.2456 41.5036 65.0495C42.4594 61.8534 40.6433 58.4877 37.4472 57.5319C34.2511 56.5761 30.8853 58.3922 29.9295 61.5883C28.9737 64.7844 30.7899 68.1501 33.9859 69.1059Z"
stroke="var(--bg-secondary-500)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</g>
<path
d="M64.0505 89.509C62.4526 89.3031 60.9752 90.5494 60.7506 92.2926L59.3273 103.34C59.1027 105.083 60.216 106.663 61.814 106.869L80.6204 109.292C82.2183 109.498 83.7001 108.218 83.9203 106.508"
stroke="var(--secondary-icon-color)" stroke-width="2" />
<path
d="M69.7048 83.8923L91.83 86.7428C93.0902 86.9051 94.0965 88.1833 93.8955 89.7441L92.235 102.632C92.0339 104.193 90.7364 105.175 89.4763 105.012L67.3511 102.162C66.0909 101.999 65.0845 100.721 65.2856 99.1604L66.9461 86.2721C67.1472 84.7112 68.4447 83.7299 69.7048 83.8923Z"
stroke="var(--secondary-icon-color)" stroke-width="2" />
<path d="M77.2559 89.7397L76.2523 97.5294L82.9857 94.4381L77.2559 89.7397Z" fill="var(--secondary-icon-color)" />
<defs>
<linearGradient id="paint0_linear_1305_16148" x1="27.7121" y1="20.698" x2="75.2879" y2="83.7937"
gradientUnits="userSpaceOnUse">
<stop offset="0.293252" stop-color="var(--bg)" />
<stop offset="1" stop-color="var(--bg)" stop-opacity="0" />
</linearGradient>
<clipPath id="clip0_1305_16148">
<rect width="36.2416" height="36.2416" fill="var(--bg)"
transform="translate(21.3838 48) rotate(16.6494)" />
</clipPath>
</defs>
</svg>
</div>

View File

@@ -0,0 +1,18 @@
@use '_variables' as *;
@use '_mixins' as *;
@use '_bootstrap-variables' as *;
@use '_components' as *;
.root {
position: relative;
}
img {
width: 30px;
height: 30px;
border: 1px solid pvar(--bg-secondary-400);
border-radius: $instance-img-radius;
position: absolute;
top: 9px;
left: 15px;
}

View File

@@ -0,0 +1,20 @@
import { Component, OnInit, inject } from '@angular/core'
import { ServerService } from '@app/core'
import { Actor } from '@app/shared/shared-main/account/actor.model'
@Component({
selector: 'my-subscription-image',
templateUrl: './subscription-image.component.html',
styleUrls: [ './subscription-image.component.scss' ],
standalone: true,
imports: []
})
export class SubscriptionImageComponent implements OnInit {
private server = inject(ServerService)
avatarUrl: string
ngOnInit () {
this.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.server.getHTMLConfig().instance, 30)
}
}

View File

@@ -0,0 +1,12 @@
<div class="margin-content">
<my-horizontal-menu [menuEntries]="menuEntries" areChildren="true"></my-horizontal-menu>
<div class="content">
<div>
<router-outlet></router-outlet>
</div>
<my-instance-stat-rules [stats]="serverStats" [config]="serverConfig" [aboutHTML]="aboutHTML"></my-instance-stat-rules>
</div>
</div>

View File

@@ -0,0 +1,26 @@
@use '_variables' as *;
@use '_bootstrap-variables' as *;
@use '_mixins' as *;
.content {
display: flex;
justify-content: space-between;
@include rfs(4rem, gap);
}
my-instance-stat-rules {
min-width: 600px;
max-width: 50%;;
}
@media screen and (max-width: #{breakpoint(xl)}) {
.content {
flex-wrap: wrap;
}
my-instance-stat-rules {
min-width: 100%;
max-width: unset;
}
}

View File

@@ -0,0 +1,68 @@
import { Component, ElementRef, OnInit, inject, viewChild } from '@angular/core'
import { ActivatedRoute, RouterOutlet } from '@angular/router'
import { AboutHTML } from '@app/shared/shared-main/instance/instance.service'
import { ServerConfig, ServerStats } from '@peertube/peertube-models'
import { ResolverData } from './about-instance.resolver'
import { InstanceStatRulesComponent } from './instance-stat-rules.component'
import { HorizontalMenuComponent, HorizontalMenuEntry } from '@app/shared/shared-main/menu/horizontal-menu.component'
@Component({
selector: 'my-about-instance',
templateUrl: './about-instance.component.html',
styleUrls: [ './about-instance.component.scss' ],
imports: [
InstanceStatRulesComponent,
HorizontalMenuComponent,
RouterOutlet
]
})
export class AboutInstanceComponent implements OnInit {
private route = inject(ActivatedRoute)
readonly descriptionWrapper = viewChild<ElementRef<HTMLInputElement>>('descriptionWrapper')
aboutHTML: AboutHTML
serverStats: ServerStats
serverConfig: ServerConfig
menuEntries: HorizontalMenuEntry[] = []
ngOnInit () {
const {
aboutHTML,
serverStats,
serverConfig
}: ResolverData = this.route.snapshot.data.instanceData
this.serverStats = serverStats
this.serverConfig = serverConfig
this.aboutHTML = aboutHTML
this.menuEntries = [
{
label: $localize`General`,
routerLink: '/about/instance/home'
}
]
if (aboutHTML.administrator || aboutHTML.creationReason || aboutHTML.maintenanceLifetime || aboutHTML.businessModel) {
this.menuEntries.push({
label: $localize`Team`,
routerLink: '/about/instance/team'
})
}
if (aboutHTML.moderationInformation || aboutHTML.codeOfConduct) {
this.menuEntries.push({
label: $localize`Moderation and code of conduct`,
routerLink: '/about/instance/moderation'
})
}
// Always displayed, we have the "features found on this instance" table on this page
this.menuEntries.push({
label: $localize`Technical information`,
routerLink: '/about/instance/tech'
})
}
}

View File

@@ -0,0 +1,63 @@
import { forkJoin, Observable } from 'rxjs'
import { map, switchMap } from 'rxjs/operators'
import { Injectable, inject } from '@angular/core'
import { ServerService } from '@app/core'
import { About, ServerConfig, ServerStats } from '@peertube/peertube-models'
import { AboutHTML, InstanceService } from '@app/shared/shared-main/instance/instance.service'
import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service'
export type ResolverData = {
serverConfig: ServerConfig
serverStats: ServerStats
about: About
languages: string[]
categories: string[]
aboutHTML: AboutHTML
descriptionElement: HTMLDivElement
}
@Injectable()
export class AboutInstanceResolver {
private instanceService = inject(InstanceService)
private customMarkupService = inject(CustomMarkupService)
private serverService = inject(ServerService)
resolve (): Observable<ResolverData> {
return forkJoin([
this.buildInstanceAboutObservable(),
this.serverService.getServerStats(),
this.serverService.getConfig()
]).pipe(
map(([
[ about, languages, categories, aboutHTML, { rootElement } ],
serverStats,
serverConfig
]) => {
return {
serverStats,
serverConfig,
about,
languages,
categories,
aboutHTML,
descriptionElement: rootElement
}
})
)
}
private buildInstanceAboutObservable () {
return this.instanceService.getAbout()
.pipe(
switchMap(about => {
return forkJoin([
Promise.resolve(about),
this.instanceService.buildTranslatedLanguages(about),
this.instanceService.buildTranslatedCategories(about),
this.instanceService.buildHtml(about),
this.customMarkupService.buildElement(about.instance.description)
])
})
)
}
}

View File

@@ -0,0 +1,53 @@
import { Routes } from '@angular/router'
import { AboutInstanceComponent } from './about-instance.component'
import { AboutInstanceResolver } from './about-instance.resolver'
import { AboutInstanceHomeComponent } from './children/about-instance-home.component'
import { AboutInstanceModerationComponent } from './children/about-instance-moderation.component'
import { AboutInstanceTeamComponent } from './children/about-instance-team.component'
import { AboutInstanceTechComponent } from './children/about-instance-tech.component'
export const aboutInstanceRoutes: Routes = [
{
path: 'instance',
providers: [ AboutInstanceResolver ],
component: AboutInstanceComponent,
data: {
meta: {
title: $localize`About this platform`
}
},
resolve: {
instanceData: AboutInstanceResolver
},
children: [
{
path: '',
redirectTo: 'home',
pathMatch: 'full'
},
{
path: 'home',
component: AboutInstanceHomeComponent
},
{
path: 'support',
component: AboutInstanceHomeComponent,
data: {
isSupport: true
}
},
{
path: 'team',
component: AboutInstanceTeamComponent
},
{
path: 'tech',
component: AboutInstanceTechComponent
},
{
path: 'moderation',
component: AboutInstanceModerationComponent
}
]
}
]

View File

@@ -0,0 +1,17 @@
@use '_variables' as *;
@use '_mixins' as *;
h4 {
color: pvar(--fg-300);
font-size: 18px;
font-weight: $font-bold;
margin-bottom: 0.25rem;
}
.text-content {
color: pvar(--fg-200);
}
.block {
margin-bottom: 1rem;
}

View File

@@ -0,0 +1,47 @@
@if (categories.length !== 0 || languages.length !== 0 || config.instance.isNSFW) {
<div class="block specifics">
<h4 i18n>Specifics</h4>
@if (languages.length !== 0) {
<div class="d-inline-block me-2">
<span class="text-content top-1px me-1" i18n>Language: </span>
@for (language of languages; track language) {
<span class="pt-badge badge-primary me-1">{{ language }}</span>
}
</div>
}
@if (categories.length !== 0) {
<div class="d-inline-block mt-2">
<span class="text-content top-1px me-1" i18n>Categories: </span>
@for (category of categories; track category) {
<span class="pt-badge badge-secondary me-1">{{ category }}</span>
}
</div>
}
@if (config.instance.isNSFW) {
<div i18n class="fw-bold text-content mt-3">{{ config.instance.name }} is dedicated to sensitive content.</div>
}
</div>
}
@if (descriptionElement) {
<div class="block description">
<h4 i18n>Description</h4>
<my-custom-markup-container class="text-content" [content]="descriptionElement"></my-custom-markup-container>
</div>
}
@if (aboutHTML.terms) {
<div class="block terms">
<h4 i18n class="section-title">Terms</h4>
<div class="text-content" [innerHTML]="aboutHTML.terms"></div>
</div>
}
<my-support-modal #supportModal [name]="config.instance.name" [content]="config.instance.support.text"></my-support-modal>

View File

@@ -0,0 +1,59 @@
import { Component, OnInit, inject, viewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { ServerService } from '@app/core'
import { AboutHTML } from '@app/shared/shared-main/instance/instance.service'
import { SupportModalComponent } from '@app/shared/shared-support-modal/support-modal.component'
import { HTMLServerConfig } from '@peertube/peertube-models'
import { CustomMarkupContainerComponent } from '../../../shared/shared-custom-markup/custom-markup-container.component'
import { ResolverData } from '../about-instance.resolver'
@Component({
templateUrl: './about-instance-home.component.html',
styleUrls: [ './about-instance-common.component.scss' ],
imports: [
CustomMarkupContainerComponent,
SupportModalComponent
]
})
export class AboutInstanceHomeComponent implements OnInit {
private router = inject(Router)
private route = inject(ActivatedRoute)
private serverService = inject(ServerService)
readonly supportModal = viewChild<SupportModalComponent>('supportModal')
aboutHTML: AboutHTML
descriptionElement: HTMLDivElement
languages: string[] = []
categories: string[] = []
config: HTMLServerConfig
ngOnInit () {
this.config = this.serverService.getHTMLConfig()
const {
languages,
categories,
aboutHTML,
descriptionElement
}: ResolverData = this.route.parent.snapshot.data.instanceData
this.aboutHTML = aboutHTML
this.descriptionElement = descriptionElement
this.languages = languages
this.categories = categories
this.route.data.subscribe(data => {
if (!data?.isSupport) return
setTimeout(() => {
const modal = this.supportModal().show()
modal.hidden.subscribe(() => this.router.navigateByUrl('/about/instance/home'))
}, 0)
})
}
}

View File

@@ -0,0 +1,17 @@
<div myPluginSelector pluginSelectorId="about-instance-moderation">
@if (aboutHTML.moderationInformation) {
<div class="block moderation-information">
<h4 i18n class="section-title">Moderation information</h4>
<div [innerHTML]="aboutHTML.moderationInformation"></div>
</div>
}
@if (aboutHTML.codeOfConduct) {
<div class="block code-of-conduct">
<h4 i18n class="section-title">Code of conduct</h4>
<div [innerHTML]="aboutHTML.codeOfConduct"></div>
</div>
}
</div>

View File

@@ -0,0 +1,28 @@
import { Component, OnInit, inject } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ServerService } from '@app/core'
import { AboutHTML } from '@app/shared/shared-main/instance/instance.service'
import { PluginSelectorDirective } from '@app/shared/shared-main/plugins/plugin-selector.directive'
import { ResolverData } from '../about-instance.resolver'
@Component({
templateUrl: './about-instance-moderation.component.html',
styleUrls: [ './about-instance-common.component.scss' ],
imports: [ PluginSelectorDirective ]
})
export class AboutInstanceModerationComponent implements OnInit {
private route = inject(ActivatedRoute)
private serverService = inject(ServerService)
aboutHTML: AboutHTML
get instanceName () {
return this.serverService.getHTMLConfig().instance.name
}
ngOnInit () {
const { aboutHTML }: ResolverData = this.route.parent.snapshot.data.instanceData
this.aboutHTML = aboutHTML
}
}

View File

@@ -0,0 +1,27 @@
@if (aboutHTML.administrator) {
<div class="block administrator">
<h4 i18n>Who we are</h4>
<div class="text-content" [innerHTML]="aboutHTML.administrator"></div>
</div>
}
@if (aboutHTML.creationReason) {
<div class="block creation-reason">
<h4 i18n>Why we created {{ instanceName }}</h4>
<div class="text-content" [innerHTML]="aboutHTML.creationReason"></div>
</div>
}
@if (aboutHTML.maintenanceLifetime) {
<div class="block maintenance-lifetime">
<h4 i18n>How long we plan to maintain {{ instanceName }}</h4>
<div [innerHTML]="aboutHTML.maintenanceLifetime"></div>
</div>
}
@if (aboutHTML.businessModel) {
<div class="block business-model">
<h4 i18n>How we will pay for keeping {{ instanceName }} running</h4>
<div class="text-content" [innerHTML]="aboutHTML.businessModel"></div>
</div>
}

View File

@@ -0,0 +1,27 @@
import { Component, OnInit, inject } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ServerService } from '@app/core'
import { AboutHTML } from '@app/shared/shared-main/instance/instance.service'
import { ResolverData } from '../about-instance.resolver'
@Component({
templateUrl: './about-instance-team.component.html',
styleUrls: [ './about-instance-common.component.scss' ],
imports: []
})
export class AboutInstanceTeamComponent implements OnInit {
private route = inject(ActivatedRoute)
private serverService = inject(ServerService)
aboutHTML: AboutHTML
get instanceName () {
return this.serverService.getHTMLConfig().instance.name
}
ngOnInit () {
const { aboutHTML }: ResolverData = this.route.parent.snapshot.data.instanceData
this.aboutHTML = aboutHTML
}
}

View File

@@ -0,0 +1,12 @@
@if (aboutHTML.hardwareInformation) {
<div myPluginSelector pluginSelectorId="about-instance-other-information">
<h4 i18n class="section-title">Hardware information</h4>
<div [innerHTML]="aboutHTML.hardwareInformation"></div>
</div>
}
<div myPluginSelector pluginSelectorId="about-instance-features">
<h4 class="visually-hidden" i18n>FEATURES</h4>
<my-instance-features-table></my-instance-features-table>
</div>

View File

@@ -0,0 +1,29 @@
import { Component, OnInit, inject } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ServerService } from '@app/core'
import { AboutHTML } from '@app/shared/shared-main/instance/instance.service'
import { PluginSelectorDirective } from '@app/shared/shared-main/plugins/plugin-selector.directive'
import { ResolverData } from '../about-instance.resolver'
import { InstanceFeaturesTableComponent } from '@app/shared/shared-instance/instance-features-table.component'
@Component({
templateUrl: './about-instance-tech.component.html',
styleUrls: [ './about-instance-common.component.scss' ],
imports: [ PluginSelectorDirective, InstanceFeaturesTableComponent ]
})
export class AboutInstanceTechComponent implements OnInit {
private route = inject(ActivatedRoute)
private serverService = inject(ServerService)
aboutHTML: AboutHTML
get instanceName () {
return this.serverService.getHTMLConfig().instance.name
}
ngOnInit () {
const { aboutHTML }: ResolverData = this.route.parent.snapshot.data.instanceData
this.aboutHTML = aboutHTML
}
}

View File

@@ -0,0 +1,168 @@
<div class="root">
<div class="stats-block">
<h4 i18n>Our platform in figures</h4>
<div class="blocks" myPluginSelector pluginSelectorId="about-instance-statistics">
<div class="stat">
<strong>{{ stats().totalModerators + stats().totalAdmins | number }}</strong>
<div i18n>moderators</div>
<my-global-icon iconName="moderation"></my-global-icon>
</div>
<div class="stat">
<strong>{{ stats().totalUsers | number }}</strong>
<div i18n>users</div>
<my-global-icon iconName="user"></my-global-icon>
</div>
<div class="stat">
<strong>{{ stats().totalLocalVideos | number }}</strong>
<a routerLink="/videos/browse" [queryParams]="{ scope: 'local' }" i18n>videos</a>
<my-global-icon iconName="videos"></my-global-icon>
</div>
<div class="stat">
<strong>{{ stats().totalLocalVideoViews | number }}</strong>
<div i18n>views</div>
<my-global-icon iconName="eye-open"></my-global-icon>
</div>
<div class="stat">
<strong>{{ stats().totalLocalVideoComments | number }}</strong>
<div i18n>comments</div>
<my-global-icon iconName="message-circle"></my-global-icon>
</div>
<div class="stat">
<strong>{{ stats().totalLocalVideoFilesSize | bytes:1 }}</strong>
<div i18n>hosted videos</div>
<my-global-icon iconName="film"></my-global-icon>
</div>
</div>
</div>
<div class="usage-rules-block">
<h4 i18n>Usage rules</h4>
<div class="blocks">
@if (config().instance.serverCountry) {
<div class="usage-rule">
<div class="icon-container">
<my-global-icon iconName="message-circle"></my-global-icon>
<div class="icon-status">
<div class="icon-info"></div>
</div>
</div>
<div>
<strong i18n>This platform has been created in {{ config().instance.serverCountry }}</strong>
<div class="rule-content">
<ng-container i18n>Your content (comments, videos...) must comply with the legislation in force in this country.</ng-container>
@if (aboutHTML().codeOfConduct) {
<ng-container i18n> You must also follow our <a routerLink="/about/instance/moderation">code of conduct</a>.</ng-container>
}
</div>
</div>
</div>
}
<div class="usage-rule">
<div class="icon-container">
<my-global-icon iconName="user"></my-global-icon>
@if (config().signup.allowed && config().signup.allowedForCurrentIP) {
<div class="icon-status">
<my-global-icon iconName="tick"></my-global-icon>
</div>
} @else {
<div class="icon-status">
<my-global-icon iconName="cross"></my-global-icon>
</div>
}
</div>
<div>
@if (config().signup.allowed && config().signup.allowedForCurrentIP) {
@if (config().signup.requiresApproval) {
<strong i18n>You can <a routerLink="/signup">request an account</a> on our platform</strong>
@if (stats().averageRegistrationRequestResponseTimeMs) {
<div class="rule-content" i18n>Our moderator will validate it within a {{ stats().averageRegistrationRequestResponseTimeMs | myDaysDurationFormatter }}.</div>
} @else {
<div class="rule-content" i18n>Our moderator will validate it within a few days.</div>
}
} @else {
<strong i18n>You can <a routerLink="/signup">create an account</a> on our platform</strong>
}
} @else {
<strong i18n>Public registration on our platform is not allowed</strong>
}
</div>
</div>
@if (config().federation.enabled) {
<div class="usage-rule">
<div class="icon-container">
<my-global-icon iconName="fediverse"></my-global-icon>
<div class="icon-status">
<my-global-icon iconName="tick"></my-global-icon>
</div>
</div>
<div>
<strong i18n>This platform is compatible with Mastodon, Lemmy, Misskey and other services from the Fediverse</strong>
<div class="rule-content" i18n>You can use these services to interact with our videos</div>
</div>
</div>
}
<div class="usage-rule">
@if (canUpload()) {
<div class="icon-container">
<my-global-icon iconName="upload"></my-global-icon>
<div class="icon-status">
<my-global-icon iconName="tick"></my-global-icon>
</div>
</div>
<div>
<strong i18n>You can publish videos</strong>
<div class="rule-content">
<ng-container i18n>By default, your account allows you to publish videos.</ng-container>
@if (canPublishLive()) {
<ng-container i18n> You can also stream lives.</ng-container>
}
</div>
</div>
} @else {
<div class="icon-container">
<my-global-icon iconName="upload"></my-global-icon>
<div class="icon-status">
<my-global-icon iconName="cross"></my-global-icon>
</div>
</div>
<div>
@if (isContactFormEnabled()) {
<strong i18n>Contact us to publish videos</strong>
} @else {
<strong i18n>You can't publish videos</strong>
}
<div class="rule-content">
<ng-container i18n>By default, your account does not allow to publish videos.</ng-container>
@if (isContactFormEnabled()) {
<ng-container i18n> If you want to publish videos, <a routerLink="/about/contact">contact us</a>.</ng-container>
}
</div>
</div>
}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,104 @@
@use '_variables' as *;
@use '_mixins' as *;
@use '_components' as *;
.root {
padding: 1.5rem;
border-radius: 14px;
background-color: pvar(--bg-secondary-400);
}
h4 {
font-size: 20px;
color: pvar(--fg-300);
font-weight: $font-bold;
}
.stats-block {
.blocks {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
}
.stat {
@include stats-card;
}
.usage-rules-block {
@include rfs(1.5rem, margin-top);
.blocks {
display: flex;
flex-direction: column;
gap: 1rem;
}
.usage-rule {
color: pvar(--fg-300);
border-radius: 8px;
padding: 1rem 1.5rem;
display: flex;
align-items: center;
gap: 1rem;
}
.usage-rule:nth-child(2n + 1) {
background-color: pvar(--bg-secondary-450);
}
.usage-rule:nth-child(2n) {
border: 1px solid pvar(--border-secondary);
}
strong {
font-weight: $font-bold;
color: pvar(--fg-400);
}
.rule-content {
@include font-size(14px);
}
.icon-container {
position: relative;
> my-global-icon:first-child {
color: pvar(--secondary-icon-color);
@include global-icon-size(42px);
}
}
.icon-status {
background-color: pvar(--bg);
border-radius: 100%;
position: absolute;
right: -5px;
bottom: -5px;
text-align: center;
@include global-icon-size(18px);
my-global-icon {
@include global-icon-size(14px);
}
}
my-global-icon[iconName=tick] {
color: pvar(--green);
}
my-global-icon[iconName=cross] {
color: pvar(--red);
}
.icon-info::after {
content: '!';
display: block;
color: pvar(--fg-200);
font-size: 14px;
font-weight: $font-bold;
}
}

View File

@@ -0,0 +1,54 @@
import { CommonModule, DecimalPipe } from '@angular/common'
import { Component, inject, input } from '@angular/core'
import { RouterLink } from '@angular/router'
import { BytesPipe } from '@app/shared/shared-main/common/bytes.pipe'
import { DaysDurationFormatterPipe } from '@app/shared/shared-main/date/days-duration-formatter.pipe'
import { AboutHTML } from '@app/shared/shared-main/instance/instance.service'
import { PluginSelectorDirective } from '@app/shared/shared-main/plugins/plugin-selector.directive'
import { ServerConfig, ServerStats } from '@peertube/peertube-models'
import { GlobalIconComponent } from '../../shared/shared-icons/global-icon.component'
import { AuthService } from '@app/core'
@Component({
selector: 'my-instance-stat-rules',
templateUrl: './instance-stat-rules.component.html',
styleUrls: [ './instance-stat-rules.component.scss' ],
imports: [
CommonModule,
GlobalIconComponent,
DecimalPipe,
DaysDurationFormatterPipe,
BytesPipe,
PluginSelectorDirective,
RouterLink
]
})
export class InstanceStatRulesComponent {
private auth = inject(AuthService)
readonly stats = input.required<ServerStats>()
readonly config = input.required<ServerConfig>()
readonly aboutHTML = input.required<AboutHTML>()
canUpload () {
const user = this.auth.getUser()
if (user) {
if (user.videoQuota === 0 || user.videoQuotaDaily === 0) return false
return true
}
const config = this.config()
return config.user.videoQuota !== 0 && config.user.videoQuotaDaily !== 0
}
canPublishLive () {
return this.config().live.enabled
}
isContactFormEnabled () {
const config = this.config()
return config.email.enabled && config.contactForm.enabled
}
}

View File

@@ -0,0 +1,61 @@
<div class="margin-content mt-5">
<h3 i18n class="fs-3 text-center fw-semibold mb-3">
This platform is powered by PeerTube
</h3>
<img class="d-block my-4 mx-auto" width="121px" height="147px" src="/client/assets/images/mascot/default.svg" alt="mascot"/>
<div class="text-center">
<p i18n>
PeerTube is a self-hosted ActivityPub-federated video streaming platform using P2P directly in your web browser.
</p>
<p i18n>
It is free and open-source software, under <a class="link-primary" href="https://github.com/Chocobozzz/PeerTube/blob/develop/LICENSE">AGPLv3
licence</a>.
</p>
<p i18n>
For more information, please visit <a class="link-primary" target="_blank" rel="noopener noreferrer" href="https://joinpeertube.org">joinpeertube.org</a>.
</p>
</div>
<div class="d-flex flex-wrap justify-content-center my-5">
<div class="card">
<div class="card-body">
<div class="card-title">
<a i18n class="link-primary" target="_blank" rel="noopener noreferrer" href="https://docs.joinpeertube.org/use/setup-account">Use PeerTube documentation</a>
</div>
<div i18n class="card-text">
Discover how to setup your account, what is a channel, how to create a playlist and more!
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="card-title">
<a i18n class="link-primary" target="_blank" rel="noopener noreferrer" href="https://docs.joinpeertube.org/use/third-party-application">PeerTube Applications</a>
</div>
<div i18n class="card-text">
Discover unofficial Android applications or browser addons!
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="card-title">
<a i18n class="link-primary" target="_blank" rel="noopener noreferrer" href="https://docs.joinpeertube.org/contribute/getting-started">Contribute on PeerTube</a>
</div>
<div i18n class="card-text">
Want to help to improve PeerTube? You can translate the web interface, give your feedback or directly contribute to the code!
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,24 @@
@use '_variables' as *;
@use '_mixins' as *;
.margin-content {
max-width: 1200px;
margin-inline-start: auto;
margin-inline-end: auto;
}
.card {
flex-basis: 300px;
@include margin(2rem);
}
.card-title {
font-size: 1.1rem;
text-align: center;
margin-bottom: 1rem;
}
.card-body {
text-align: center;
}

View File

@@ -0,0 +1,22 @@
import { Component, AfterViewChecked, inject } from '@angular/core'
import { ViewportScroller } from '@angular/common'
@Component({
selector: 'my-about-peertube',
templateUrl: './about-peertube.component.html',
styleUrls: [ './about-peertube.component.scss' ],
standalone: true
})
export class AboutPeertubeComponent implements AfterViewChecked {
private viewportScroller = inject(ViewportScroller)
private lastScrollHash: string
ngAfterViewChecked () {
if (window.location.hash && window.location.hash !== this.lastScrollHash) {
this.viewportScroller.scrollToAnchor(window.location.hash.replace('#', ''))
this.lastScrollHash = window.location.hash
}
}
}

View File

@@ -0,0 +1,85 @@
<div>
<div class="margin-content">
<h1>
<my-global-icon iconName="help"></my-global-icon>
<ng-container i18n>About</ng-container>
</h1>
<div class="instance-info-container">
@if (bannerUrl) {
<div class="banner">
<img [src]="bannerUrl" alt="">
</div>
}
<div class="instance-info">
@if (avatarUrl) {
<div class="avatar">
<img [src]="avatarUrl" alt="">
</div>
}
<div>
<div class="instance-name">{{ config.instance.name }}</div>
<div class="instance-description">{{ config.instance.shortDescription }}</div>
</div>
<div class="ms-auto">
<div class="social-buttons d-flex flex-wrap justify-content-end">
@if (config.instance.social.mastodonLink) {
<a
class="media peertube-button-link rounded-icon-button mb-3" i18n-title title="Go to the Mastodon profile"
target="_blank" rel="noopener noreferrer" [href]="config.instance.social.mastodonLink"
>
<my-global-icon iconName="mastodon"></my-global-icon>
</a>
}
@if (config.instance.social.xLink) {
<a
class="media peertube-button-link rounded-icon-button mb-3" i18n-title title="Go to the X profile"
target="_blank" rel="noopener noreferrer" [href]="config.instance.social.xLink"
>
<my-global-icon iconName="x-twitter"></my-global-icon>
</a>
}
@if (config.instance.social.blueskyLink) {
<a
class="media peertube-button-link rounded-icon-button mb-3" i18n-title title="Go to the Bluesky profile"
target="_blank" rel="noopener noreferrer" [href]="config.instance.social.blueskyLink"
>
<my-global-icon iconName="bluesky"></my-global-icon>
</a>
}
@if (config.instance.social.externalLink) {
<a
class="external-link peertube-button-link rounded-icon-button mb-3" i18n-title title="Go to the external website"
target="_blank" rel="noopener noreferrer" [href]="config.instance.social.externalLink"
>
<my-global-icon iconName="link"></my-global-icon>
</a>
}
</div>
<div class="d-flex flex-wrap justify-content-end">
@if (isContactFormEnabled()) {
<my-button class="ms-3" theme="primary" ptRouterLink="/about/contact" i18n>Contact us</my-button>
}
@if (config.instance.support.text) {
<my-button class="ms-3" theme="secondary" ptRouterLink="/about/instance/support" i18n>Support</my-button>
}
</div>
</div>
</div>
</div>
<my-horizontal-menu [menuEntries]="menuEntries" withMarginBottom="false"></my-horizontal-menu>
</div>
<router-outlet></router-outlet>
</div>

View File

@@ -0,0 +1,100 @@
@use "_variables" as *;
@use "_mixins" as *;
$container-radius: 14px;
h1 {
font-weight: $font-bold;
@include font-size(2rem);
@include rfs(1.5rem, margin-bottom);
my-global-icon {
@include margin-right(0.5rem);
@include global-icon-size(24px);
}
}
.instance-info-container {
background: pvar(--bg-secondary-400);
border-radius: $container-radius;
@include rfs(2rem, margin-bottom);
}
.instance-info {
display: flex;
flex-wrap: wrap;
@include rfs(1.25rem, gap);
@include rfs(1.75rem, padding);
}
.avatar img {
border-radius: $instance-img-radius;
width: 110px;
height: 110px;
}
.banner {
@include fade-text(73%, #{pvar(--bg-secondary-400)});
img {
border-start-start-radius: $container-radius;
border-start-end-radius: $container-radius;
border-end-start-radius: 0;
border-end-end-radius: 0;
}
}
.instance-name {
color: pvar(--fg-350);
font-weight: $font-bold;
line-height: 1;
margin-bottom: 0.5rem;
@include font-size(2.25rem);
}
.instance-description {
color: pvar(--fg-300);
@include font-size(1.25rem);
}
.social-buttons {
.peertube-button-link {
@include margin-left(0.5rem);
}
.media {
color: pvar(--fg-300);
background-color: pvar(--bg-secondary-450);
border: 1px solid pvar(--bg-secondary-450);
&:hover {
color: pvar(--fg-300);
background-color: pvar(--bg-secondary-400);
}
&:active {
background-color: pvar(--bg-secondary-350);
}
}
.external-link {
color: pvar(--bg-secondary-350);
background-color: pvar(--fg-350);
border: 1px solid pvar(--fg-350);
&:hover {
background-color: pvar(--fg-400);
color: pvar(--bg-secondary-400);
}
&:active {
background-color: pvar(--fg-450);
}
}
}

View File

@@ -0,0 +1,61 @@
import { Component, OnInit, inject, viewChild } from '@angular/core'
import { RouterOutlet } from '@angular/router'
import { ServerService } from '@app/core'
import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
import { Actor } from '@app/shared/shared-main/account/actor.model'
import { ButtonComponent } from '@app/shared/shared-main/buttons/button.component'
import { HorizontalMenuComponent, HorizontalMenuEntry } from '@app/shared/shared-main/menu/horizontal-menu.component'
import { SupportModalComponent } from '@app/shared/shared-support-modal/support-modal.component'
import { maxBy } from '@peertube/peertube-core-utils'
import { HTMLServerConfig } from '@peertube/peertube-models'
@Component({
selector: 'my-about',
templateUrl: './about.component.html',
styleUrls: [ './about.component.scss' ],
imports: [ RouterOutlet, HorizontalMenuComponent, GlobalIconComponent, ButtonComponent ]
})
export class AboutComponent implements OnInit {
private server = inject(ServerService)
readonly supportModal = viewChild<SupportModalComponent>('supportModal')
bannerUrl: string
avatarUrl: string
menuEntries: HorizontalMenuEntry[] = []
config: HTMLServerConfig
ngOnInit () {
this.config = this.server.getHTMLConfig()
this.bannerUrl = this.config.instance.banners.length !== 0
? maxBy(this.config.instance.banners, 'width').fileUrl
: undefined
this.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.config.instance, 110)
this.menuEntries = [
{
label: $localize`Platform`,
routerLink: '/about/instance',
pluginSelectorId: 'about-menu-instance'
},
{
label: $localize`PeerTube`,
routerLink: '/about/peertube',
pluginSelectorId: 'about-menu-peertube'
},
{
label: $localize`Network`,
routerLink: '/about/follows',
pluginSelectorId: 'about-menu-network'
}
]
}
isContactFormEnabled () {
return this.config.email.enabled && this.config.contactForm.enabled
}
}

View File

@@ -0,0 +1,60 @@
import { Routes } from '@angular/router'
import { AboutFollowsComponent } from '@app/+about/about-follows/about-follows.component'
import { AboutPeertubeComponent } from '@app/+about/about-peertube/about-peertube.component'
import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service'
import { DynamicElementService } from '@app/shared/shared-custom-markup/dynamic-element.service'
import { InstanceFollowService } from '@app/shared/shared-instance/instance-follow.service'
import { AboutContactComponent } from './about-contact/about-contact.component'
import { aboutInstanceRoutes } from './about-instance/about-instance.routes'
import { AboutComponent } from './about.component'
export default [
{
path: '',
component: AboutComponent,
providers: [
InstanceFollowService,
CustomMarkupService,
DynamicElementService
],
children: [
{
path: '',
redirectTo: 'instance',
pathMatch: 'full'
},
...aboutInstanceRoutes,
{
path: 'peertube',
component: AboutPeertubeComponent,
data: {
meta: {
title: $localize`About PeerTube`
}
}
},
{
path: 'follows',
component: AboutFollowsComponent,
data: {
meta: {
title: $localize`About this platform's network`
}
}
},
{
path: 'contact',
component: AboutContactComponent,
data: {
meta: {
title: $localize`Contact`
}
}
}
]
}
] satisfies Routes

View File

@@ -0,0 +1,65 @@
<h1 class="visually-hidden" i18n>Video channels</h1>
<div class="margin-content">
@if (channelPagination.totalItems === 0) {
<div class="no-results" i18n>This account does not have channels.</div>
}
<div class="channels" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onChannelDataSubject.asObservable()">
@for (videoChannel of videoChannels; track videoChannel) {
<div class="channel">
<div class="channel-avatar-row">
<my-actor-avatar
[actor]="videoChannel" actorType="channel"
[internalHref]="getVideoChannelLink(videoChannel)"
i18n-title
title="See this video channel"
size="75"
></my-actor-avatar>
<h2 class="fs-5 lh-1 fw-bold m-0">
<a class="text-decoration-none" [routerLink]="getVideoChannelLink(videoChannel)" i18n-title title="See this video channel">
{{ videoChannel.displayName }}
</a>
</h2>
<div class="actor-counters">
<div class="followers" i18n>{videoChannel.followersCount, plural, =0 {No subscribers} =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div>
@if (getTotalVideosOf(videoChannel) !== undefined) {
<span class="videos-count" i18n>
{getTotalVideosOf(videoChannel), plural, =0 {No videos} =1 {1 video} other {{{ getTotalVideosOf(videoChannel) }} videos}}
</span>
}
</div>
<div class="description-html" [innerHTML]="getChannelDescription(videoChannel)"></div>
</div>
<my-subscribe-button [videoChannels]="[videoChannel]"></my-subscribe-button>
<a i18n class="button-show-channel peertube-button-link primary-button" [routerLink]="getVideoChannelLink(videoChannel)">Show this channel</a>
<div class="videos-overflow-workaround">
<div class="videos">
@if (getTotalVideosOf(videoChannel) === 0) {
<div class="no-results h-auto" i18n>This channel doesn't have any videos.</div>
}
@for (video of getVideosOf(videoChannel); track video) {
<my-video-miniature
[video]="video" [user]="userMiniature" [displayVideoActions]="true" [displayOptions]="miniatureDisplayOptions"
></my-video-miniature>
}
@if (getTotalVideosOf(videoChannel)) {
<div class="miniature-show-channel">
<a class="link-primary" i18n [routerLink]="getVideoChannelLink(videoChannel)">SHOW THIS CHANNEL ></a>
</div>
}
</div>
</div>
</div>
}
</div>
</div>

View File

@@ -0,0 +1,147 @@
@use 'sass:math';
@use '_variables' as *;
@use '_mixins' as *;
@use '_miniature' as *;
.margin-content {
@include grid-videos-miniature-margins;
}
.channel {
max-width: $max-channels-width;
background-color: pvar(--bg-secondary-350);
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
column-gap: 15px;
@include padding(1.75rem);
@include margin(2rem, 0);
}
.channel-avatar-row {
grid-column: 1;
grid-row: 1;
display: grid;
grid-template-columns: auto auto 1fr;
grid-template-rows: auto 1fr;
my-actor-avatar {
grid-column: 1;
grid-row: 1 / 3;
@include margin-right(15px);
}
a {
color: pvar(--fg);
@include peertube-word-wrap;
}
h2 {
grid-row: 1;
grid-column: 2;
}
.actor-counters {
grid-row: 1;
grid-column: 3;
@include margin-left(15px);
@include actor-counters;
}
.description-html {
grid-column: 2 / 4;
grid-row: 2;
max-height: 80px;
@include fade-text(50px, pvar(--bg-secondary-350));
}
}
my-subscribe-button {
grid-row: 1;
grid-column: 2;
}
.videos {
display: flex;
grid-column: 1 / 3;
grid-row: 2;
position: relative;
my-video-miniature {
min-width: $video-thumbnail-medium-width;
max-width: $video-thumbnail-medium-width;
@include margin-right(15px);
}
}
.videos-overflow-workaround {
overflow-x: hidden;
@include margin-top(2rem);
}
.miniature-show-channel {
height: 100%;
position: absolute;
right: 0;
background: linear-gradient(90deg, transparent 0, pvar(--bg-secondary-350) 45px);
padding: (math.div($video-thumbnail-medium-height, 2) - 10px) 15px 0 60px;
z-index: z(miniature) + 1;
}
.button-show-channel {
display: none;
}
@include on-small-main-col {
.channel-avatar-row {
grid-template-columns: auto auto auto 1fr;
.avatar-link {
grid-row: 1 / 4;
}
.actor-counters {
margin: 0;
font-size: 13px;
grid-row: 2;
grid-column: 2 / 4;
}
.description-html {
grid-row: 3;
font-size: 14px;
}
}
.videos {
display: none;
}
my-subscribe-button,
.button-show-channel {
grid-column: 1 / 4;
grid-row: 3;
margin-top: 15px;
}
my-subscribe-button {
justify-self: start;
}
.button-show-channel {
display: block;
justify-self: end;
}
}

View File

@@ -0,0 +1,158 @@
import { Component, inject, OnDestroy, OnInit } from '@angular/core'
import { RouterLink } from '@angular/router'
import { ComponentPagination, hasMoreItems, MarkdownService, User, UserService } from '@app/core'
import { SimpleMemoize } from '@app/helpers'
import { NSFWPolicyType, VideoSortField } from '@peertube/peertube-models'
import { from, Subject, Subscription } from 'rxjs'
import { concatMap, map, switchMap, tap } from 'rxjs/operators'
import { ActorAvatarComponent } from '../../shared/shared-actor-image/actor-avatar.component'
import { InfiniteScrollerDirective } from '../../shared/shared-main/common/infinite-scroller.directive'
import { SubscribeButtonComponent } from '../../shared/shared-user-subscription/subscribe-button.component'
import { MiniatureDisplayOptions, VideoMiniatureComponent } from '../../shared/shared-video-miniature/video-miniature.component'
import { Account } from '@app/shared/shared-main/account/account.model'
import { AccountService } from '@app/shared/shared-main/account/account.service'
import { VideoChannel } from '@app/shared/shared-main/channel/video-channel.model'
import { VideoChannelService } from '@app/shared/shared-main/channel/video-channel.service'
import { Video } from '@app/shared/shared-main/video/video.model'
import { VideoService } from '@app/shared/shared-main/video/video.service'
@Component({
selector: 'my-account-video-channels',
templateUrl: './account-video-channels.component.html',
styleUrls: [ './account-video-channels.component.scss' ],
imports: [ InfiniteScrollerDirective, ActorAvatarComponent, RouterLink, SubscribeButtonComponent, VideoMiniatureComponent ]
})
export class AccountVideoChannelsComponent implements OnInit, OnDestroy {
private accountService = inject(AccountService)
private videoChannelService = inject(VideoChannelService)
private videoService = inject(VideoService)
private markdown = inject(MarkdownService)
private userService = inject(UserService)
account: Account
videoChannels: VideoChannel[] = []
videos: { [id: number]: { total: number, videos: Video[] } } = {}
channelsDescriptionHTML: { [id: number]: string } = {}
channelPagination: ComponentPagination = {
currentPage: 1,
itemsPerPage: 2,
totalItems: null
}
videosPagination: ComponentPagination = {
currentPage: 1,
itemsPerPage: 5,
totalItems: null
}
videosSort: VideoSortField = '-publishedAt'
onChannelDataSubject = new Subject<any>()
userMiniature: User
nsfwPolicy: NSFWPolicyType
miniatureDisplayOptions: MiniatureDisplayOptions = {
date: true,
views: true,
by: false,
avatar: false,
privacyLabel: false
}
private accountSub: Subscription
ngOnInit () {
// Parent get the account for us
this.accountSub = this.accountService.accountLoaded
.subscribe(account => {
this.account = account
this.videoChannels = []
this.loadMoreChannels()
})
this.userService.getAnonymousOrLoggedUser()
.subscribe(user => {
this.userMiniature = user
this.nsfwPolicy = user.nsfwPolicy
})
}
ngOnDestroy () {
if (this.accountSub) this.accountSub.unsubscribe()
}
loadMoreChannels () {
const options = {
account: this.account,
componentPagination: this.channelPagination,
sort: '-updatedAt'
}
this.videoChannelService.listAccountChannels(options)
.pipe(
tap(res => {
this.channelPagination.totalItems = res.total
}),
switchMap(res => from(res.data)),
concatMap(videoChannel => {
const options = {
videoChannel,
videoPagination: this.videosPagination,
sort: this.videosSort,
nsfw: this.videoService.nsfwPolicyToParam(this.nsfwPolicy)
}
return this.videoService.listChannelVideos(options)
.pipe(map(data => ({ videoChannel, videos: data.data, total: data.total })))
})
)
.subscribe(async ({ videoChannel, videos, total }) => {
this.channelsDescriptionHTML[videoChannel.id] = await this.markdown.textMarkdownToHTML({
markdown: videoChannel.description,
withEmoji: true,
withHtml: true
})
this.videoChannels.push(videoChannel)
this.videos[videoChannel.id] = { videos, total }
this.onChannelDataSubject.next([ videoChannel ])
})
}
getVideosOf (videoChannel: VideoChannel) {
const obj = this.videos[videoChannel.id]
if (!obj) return []
return obj.videos
}
getTotalVideosOf (videoChannel: VideoChannel) {
const obj = this.videos[videoChannel.id]
if (!obj) return undefined
return obj.total
}
getChannelDescription (videoChannel: VideoChannel) {
return this.channelsDescriptionHTML[videoChannel.id]
}
onNearOfBottom () {
if (!hasMoreItems(this.channelPagination)) return
this.channelPagination.currentPage += 1
this.loadMoreChannels()
}
@SimpleMemoize()
getVideoChannelLink (videoChannel: VideoChannel) {
return [ '/c', videoChannel.nameWithHost ]
}
}

View File

@@ -0,0 +1,17 @@
<h1 class="visually-hidden" i18n>Videos</h1>
@if (account) {
<my-videos-list
#videosList
[getVideosObservableFunction]="getVideosObservableFunction"
[getSyndicationItemsFunction]="getSyndicationItemsFunction"
[defaultSort]="defaultSort"
displayFilters="true"
[displayAsRow]="displayAsRow()"
hideScopeFilter="true"
loadUserVideoPreferences="true"
highlightLives="true"
[disabled]="disabled"
>
</my-videos-list>
}

View File

@@ -0,0 +1,78 @@
import { Component, inject, OnDestroy, OnInit, viewChild } from '@angular/core'
import { ComponentPaginationLight, DisableForReuseHook, ScreenService } from '@app/core'
import { Account } from '@app/shared/shared-main/account/account.model'
import { AccountService } from '@app/shared/shared-main/account/account.service'
import { VideoService } from '@app/shared/shared-main/video/video.service'
import { VideoFilters } from '@app/shared/shared-video-miniature/video-filters.model'
import { VideoSortField } from '@peertube/peertube-models'
import { Subscription } from 'rxjs'
import { VideosListComponent } from '../../shared/shared-video-miniature/videos-list.component'
@Component({
selector: 'my-account-videos',
templateUrl: './account-videos.component.html',
imports: [ VideosListComponent ]
})
export class AccountVideosComponent implements OnInit, OnDestroy, DisableForReuseHook {
private screenService = inject(ScreenService)
private accountService = inject(AccountService)
private videoService = inject(VideoService)
readonly videosList = viewChild<VideosListComponent>('videosList')
getVideosObservableFunction = this.getVideosObservable.bind(this)
getSyndicationItemsFunction = this.getSyndicationItems.bind(this)
defaultSort = '-publishedAt' as VideoSortField
account: Account
disabled = false
private alreadyLoaded = false
private accountSub: Subscription
ngOnInit () {
// Parent get the account for us
this.accountSub = this.accountService.accountLoaded
.subscribe(account => {
if (account.id === this.account?.id) return
this.account = account
if (this.alreadyLoaded) this.videosList().reloadVideos()
this.alreadyLoaded = true
})
}
ngOnDestroy () {
if (this.accountSub) this.accountSub.unsubscribe()
}
getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) {
return this.videoService.listAccountVideos({
...filters.toVideosAPIObject(),
videoPagination: pagination,
account: this.account,
skipCount: true,
includeScheduledLive: true
})
}
getSyndicationItems () {
return this.videoService.getAccountFeedUrls(this.account.id)
}
displayAsRow () {
return this.screenService.isInMobileView()
}
disableForReuse () {
this.disabled = true
}
enabledForReuse () {
this.disabled = false
}
}

View File

@@ -0,0 +1,113 @@
@if (account) {
<div class="root">
<div class="margin-content account-info d-md-grid d-block">
<div class="account-avatar-row">
<my-actor-avatar [size]="getAccountAvatarSize()" actorType="account" [actor]="account"></my-actor-avatar>
<div>
<div class="section-label" i18n>ACCOUNT</div>
<div class="actor-info">
<div>
<div class="actor-display-name align-items-center">
<h1>{{ account.displayName }}</h1>
<my-user-moderation-dropdown
class="mx-3"
[prependActions]="prependModerationActions"
buttonSize="small"
[account]="account"
[user]="accountUser"
placement="bottom-left auto"
(userChanged)="onUserChanged()"
(userDeleted)="onUserDeleted()"
></my-user-moderation-dropdown>
@if (accountUser?.blocked) {
<span tabindex="0" [ngbTooltip]="accountUser.blockedReason" class="pt-badge badge-danger" i18n>Banned</span>
}
<my-account-block-badges [account]="account"></my-account-block-badges>
</div>
<div class="actor-handle">
<span>&#64;{{ account.nameWithHost }}</span>
<my-copy-button
[value]="account.nameWithHostForced"
i18n-notification
notification="Username copied"
title="Copy account handle"
i18n-title
></my-copy-button>
</div>
<div class="actor-counters">
<span i18n>
{naiveAggregatedSubscribers(), plural, =0 {No subscribers} =1 {1 subscriber} other {{{ naiveAggregatedSubscribers () }} subscribers }}
</span>
@if (accountVideosCount !== undefined) {
<span class="videos-count" i18n>
{accountVideosCount, plural, =0 {No videos} =1 {1 video} other {{{ accountVideosCount }} videos }}
</span>
}
</div>
</div>
</div>
</div>
</div>
<div class="description" [ngClass]="{ expanded: accountDescriptionExpanded }">
<div class="description-html" [innerHTML]="accountDescriptionHTML"></div>
</div>
@if (hasShowMoreDescription()) {
<button
class="show-more peertube-button-like-link d-md-none d-block"
(click)="accountDescriptionExpanded = !accountDescriptionExpanded"
title="Show the complete description"
i18n-title
i18n
>
Show more...
</button>
}
<div class="buttons">
@if (isManageable()) {
<a routerLink="/my-account" class="peertube-button-link primary-button" i18n>Manage account</a>
}
@if (hasVideoChannels() && !isManageable()) {
<my-subscribe-button [account]="account" [videoChannels]="videoChannels"></my-subscribe-button>
}
</div>
</div>
<div class="margin-content horizontal-menu mb-3">
<ng-template #linkTemplate let-item="item">
<a [routerLink]="item.routerLink" routerLinkActive="active" class="sub-menu-entry">{{ item.label }}</a>
</ng-template>
<my-horizontal-menu [hidden]="hideMenu" [menuEntries]="links"></my-horizontal-menu>
<my-simple-search-input
class="ms-auto"
[initialValue]="search"
[alwaysShow]="!isInSmallView()"
(searchChanged)="searchChanged($event)"
(inputDisplayChanged)="onSearchInputDisplayChanged($event)"
name="search-videos"
i18n-iconTitle
icon-title="Search account videos"
i18n-placeholder
placeholder="Search account videos"
></my-simple-search-input>
</div>
<router-outlet></router-outlet>
</div>
}
@if (prependModerationActions) {
<my-account-report #accountReportModal></my-account-report>
}

View File

@@ -0,0 +1,102 @@
@use '_variables' as *;
@use '_mixins' as *;
@use '_account-channel-page' as *;
@use '_miniature' as *;
.root {
--co-font-size: 1rem;
--co-secondary-font-size: 1rem;
}
my-horizontal-menu {
flex-grow: 1;
@include margin-right(3rem);
}
.horizontal-menu {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
}
my-copy-button {
@include margin-left(3px);
}
.account-info {
grid-template-columns: 1fr min-content;
grid-template-rows: auto auto;
@include margin-bottom(3rem);
@include font-size(1rem);
}
.account-avatar-row {
@include avatar-row-responsive(2rem, var(--co-secondary-font-size));
}
.actor-display-name {
align-items: center;
}
.description {
grid-column: 1 / 3;
max-width: 1000px;
word-break: break-word;
}
.show-more {
text-align: center;
@include show-more-description;
@include padding-bottom(3.75rem);
}
.buttons {
grid-column: 2;
grid-row: 1;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-content: flex-start;
>*:not(:last-child) {
@include margin-bottom(1rem);
}
>a {
white-space: nowrap;
}
}
.pt-badge {
@include margin-right(5px);
}
@media screen and (max-width: $small-view) {
.description:not(.expanded) {
max-height: 70px;
@include fade-text(30px, pvar(--bg));
}
.buttons {
justify-content: center;
}
}
@media screen and (max-width: $mobile-view) {
.root {
--co-font-size: 14px;
--co-secondary-font-size: 13px;
}
.links {
margin: auto !important;
width: min-content;
}
}

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