mirror of
https://github.com/Chocobozzz/PeerTube.git
synced 2025-10-03 09:49:20 +02:00
Improve NSFW system
* Add NSFW flags to videos so the publisher can add more NSFW context * Add NSFW summary to videos, similar to content warning system so the publisher has a free text to describe NSFW aspect of its video * Add additional "warn" NSFW policy: the video thumbnail is not blurred and we display a tag below the video miniature, the video player includes the NSFW warning (with context if available) and it also prevent autoplay * "blur" NSFW settings inherits "warn" policy and also blur the video thumbnail * Add NSFW flag settings to users so they can have more granular control about what content they want to hide, warn or display
This commit is contained in:
parent
fac6b15ada
commit
dd4027a10f
181 changed files with 5081 additions and 2061 deletions
|
@ -1,3 +1,4 @@
|
|||
import { NSFWPolicyType } from '@peertube/peertube-models'
|
||||
import { browserSleep, go, setCheckboxEnabled } from '../utils'
|
||||
|
||||
export class AdminConfigPage {
|
||||
|
@ -18,16 +19,16 @@ export class AdminConfigPage {
|
|||
await $('h2=' + waitTitles[tab]).waitForDisplayed()
|
||||
}
|
||||
|
||||
async updateNSFWSetting (newValue: 'do_not_list' | 'blur' | 'display') {
|
||||
async updateNSFWSetting (newValue: NSFWPolicyType) {
|
||||
await this.navigateTo('instance-information')
|
||||
|
||||
const elem = $('#instanceDefaultNSFWPolicy')
|
||||
const elem = $(`#instanceDefaultNSFWPolicy-${newValue} + label`)
|
||||
|
||||
await elem.waitForDisplayed()
|
||||
await elem.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header
|
||||
await elem.waitForClickable()
|
||||
|
||||
return elem.selectByAttribute('value', newValue)
|
||||
return elem.click()
|
||||
}
|
||||
|
||||
async updateHomepage (newValue: string) {
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import { browserSleep, findParentElement, go } from '../utils'
|
||||
|
||||
export class AdminRegistrationPage {
|
||||
|
||||
async navigateToRegistratonsList () {
|
||||
async navigateToRegistrationsList () {
|
||||
await go('/admin/moderation/registrations/list')
|
||||
|
||||
await $('my-registration-list').waitForDisplayed()
|
||||
|
@ -31,5 +30,4 @@ export class AdminRegistrationPage {
|
|||
|
||||
await browserSleep(1000)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
24
client/e2e/src/po/admin-user.po.ts
Normal file
24
client/e2e/src/po/admin-user.po.ts
Normal 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()
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import { NSFWPolicyType } from '@peertube/peertube-models'
|
||||
import { getCheckbox } from '../utils'
|
||||
|
||||
export class AnonymousSettingsPage {
|
||||
|
||||
async openSettings () {
|
||||
const link = await $('my-header .settings-button')
|
||||
await link.waitForClickable()
|
||||
|
@ -10,10 +10,36 @@ export class AnonymousSettingsPage {
|
|||
await $('my-user-video-settings').waitForDisplayed()
|
||||
}
|
||||
|
||||
async closeSettings () {
|
||||
const closeModal = await $('.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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { NSFWPolicyType } from '@peertube/peertube-models'
|
||||
import { getCheckbox, go, selectCustomSelect } from '../utils'
|
||||
|
||||
export class MyAccountPage {
|
||||
|
@ -21,14 +22,26 @@ export class MyAccountPage {
|
|||
return $('a[href="/my-account"]').click()
|
||||
}
|
||||
|
||||
async updateNSFW (newValue: 'do_not_list' | 'blur' | 'display') {
|
||||
const nsfw = $('#nsfwPolicy')
|
||||
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.selectByAttribute('value', newValue)
|
||||
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()
|
||||
}
|
||||
|
@ -55,6 +68,7 @@ export class MyAccountPage {
|
|||
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')
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { browserSleep, isIOS, isMobileDevice, isSafari } from '../utils'
|
||||
|
||||
export class PlayerPage {
|
||||
|
||||
getWatchVideoPlayerCurrentTime () {
|
||||
const elem = $('video')
|
||||
|
||||
|
@ -10,7 +9,7 @@ export class PlayerPage {
|
|||
: elem.getProperty('currentTime')
|
||||
|
||||
return p.then(t => parseInt(t + '', 10))
|
||||
.then(t => Math.ceil(t))
|
||||
.then(t => Math.ceil(t))
|
||||
}
|
||||
|
||||
waitUntilPlaylistInfo (text: string, maxTime: number) {
|
||||
|
@ -28,6 +27,10 @@ export class PlayerPage {
|
|||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
|
@ -66,11 +69,31 @@ export class PlayerPage {
|
|||
return this.clickOnPlayButton()
|
||||
}
|
||||
|
||||
private async clickOnPlayButton () {
|
||||
const playButton = () => $('.vjs-big-play-button')
|
||||
getPlayButton () {
|
||||
return $('.vjs-big-play-button')
|
||||
}
|
||||
|
||||
await playButton().waitForClickable()
|
||||
await playButton().click()
|
||||
getNSFWContentText () {
|
||||
return $('.video-js .nsfw-content').getText()
|
||||
}
|
||||
|
||||
getNSFWMoreContent () {
|
||||
return $('.video-js .nsfw-more-content')
|
||||
}
|
||||
|
||||
getMoreNSFWInfoButton () {
|
||||
return $('.video-js .nsfw-container button')
|
||||
}
|
||||
|
||||
async hasPoster () {
|
||||
const property = await $('.video-js .vjs-poster').getCSSProperty('background-image')
|
||||
|
||||
return property.value.startsWith('url(')
|
||||
}
|
||||
|
||||
private async clickOnPlayButton () {
|
||||
await this.getPlayButton().waitForClickable()
|
||||
await this.getPlayButton().click()
|
||||
}
|
||||
|
||||
async fillEmbedVideoPassword (videoPassword: string) {
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { getCheckbox } from '../utils'
|
||||
|
||||
export class SignupPage {
|
||||
|
||||
getRegisterMenuButton () {
|
||||
return $('.create-account-button')
|
||||
}
|
||||
|
@ -47,7 +46,7 @@ export class SignupPage {
|
|||
await $('#displayName').setValue(options.displayName || `${options.username} display name`)
|
||||
|
||||
await $('#username').setValue(options.username)
|
||||
await $('#password').setValue(options.password || 'password')
|
||||
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' })
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
import { browserSleep, go } from '../utils'
|
||||
import { browserSleep, findParentElement, go } from '../utils'
|
||||
|
||||
export class VideoListPage {
|
||||
|
||||
constructor (private isMobileDevice: boolean, private isSafari: boolean) {
|
||||
|
||||
}
|
||||
|
||||
async goOnVideosList () {
|
||||
|
@ -53,11 +51,12 @@ export class VideoListPage {
|
|||
await this.waitForList()
|
||||
}
|
||||
|
||||
async getNSFWFilter () {
|
||||
async getNSFWFilterText () {
|
||||
const el = $('.active-filter*=Sensitive')
|
||||
|
||||
await el.waitForDisplayed()
|
||||
|
||||
return el
|
||||
return el.getText()
|
||||
}
|
||||
|
||||
async getVideosListName () {
|
||||
|
@ -67,16 +66,39 @@ export class VideoListPage {
|
|||
return texts.map(t => t.trim())
|
||||
}
|
||||
|
||||
videoExists (name: string) {
|
||||
isVideoDisplayed (name: string) {
|
||||
return $('.video-name=' + name).isDisplayed()
|
||||
}
|
||||
|
||||
async videoIsBlurred (name: string) {
|
||||
const filter = await $('.video-name=' + name).getCSSProperty('filter')
|
||||
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 = await miniature.$('.nsfw-warning')
|
||||
await warning.waitForDisplayed()
|
||||
|
||||
expect(await warning.getAttribute('aria-label')).toEqual(summary)
|
||||
}
|
||||
|
||||
private async getVideoMiniature (name: string) {
|
||||
const videoName = await $('.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 => {
|
||||
|
@ -92,9 +114,8 @@ export class VideoListPage {
|
|||
const elem = await video()
|
||||
|
||||
return elem?.isClickable()
|
||||
});
|
||||
|
||||
(await video()).click()
|
||||
})
|
||||
;(await video()).click()
|
||||
|
||||
await browser.waitUntil(async () => (await browser.getUrl()).includes('/w/'))
|
||||
}
|
||||
|
@ -116,8 +137,4 @@ export class VideoListPage {
|
|||
private waitForList () {
|
||||
return $('.videos .video-miniature .video-name').waitForDisplayed()
|
||||
}
|
||||
|
||||
private waitForTitle (title: string) {
|
||||
return $('h1=' + title).waitForDisplayed()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { clickOnRadio, getCheckbox, go, isRadioSelected, selectCustomSelect } from '../utils'
|
||||
import { clickOnRadio, getCheckbox, go, isRadioSelected, selectCustomSelect, setCheckboxEnabled } from '../utils'
|
||||
|
||||
export abstract class VideoManage {
|
||||
async clickOnSave () {
|
||||
|
@ -19,13 +19,24 @@ export abstract class VideoManage {
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async setAsNSFW () {
|
||||
async setAsNSFW (options: {
|
||||
violent?: boolean
|
||||
summary?: string
|
||||
} = {}) {
|
||||
await this.goOnPage('Moderation')
|
||||
|
||||
const checkbox = await getCheckbox('nsfw')
|
||||
await checkbox.waitForClickable()
|
||||
|
||||
return checkbox.click()
|
||||
await checkbox.click()
|
||||
|
||||
if (options.violent) {
|
||||
await setCheckboxEnabled('nsfwFlagViolent', true)
|
||||
}
|
||||
|
||||
if (options.summary) {
|
||||
await $('#nsfwSummary').setValue(options.summary)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
@ -65,10 +76,11 @@ export abstract class VideoManage {
|
|||
await input.waitForClickable()
|
||||
await input.click()
|
||||
|
||||
const nextDay = $('.p-datepicker-today + td > span')
|
||||
await nextDay.waitForClickable()
|
||||
await nextDay.click()
|
||||
await nextDay.waitForDisplayed({ reverse: true })
|
||||
const nextMonth = $('.p-datepicker-next')
|
||||
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 () {
|
||||
|
@ -92,7 +104,7 @@ export abstract class VideoManage {
|
|||
async getLiveState () {
|
||||
await this.goOnPage('Live settings')
|
||||
|
||||
if (await isRadioSelected('#permanentLiveTrue')) return 'permanent'
|
||||
if (await isRadioSelected('permanentLiveTrue')) return 'permanent'
|
||||
|
||||
return 'normal'
|
||||
}
|
||||
|
|
|
@ -52,7 +52,7 @@ export class VideoPublishPage extends VideoManage {
|
|||
await submit.click()
|
||||
|
||||
// Wait for the import to finish
|
||||
await this.getSaveButton().waitForClickable()
|
||||
await this.getSaveButton().waitForClickable({ timeout: 15000 }) // Can be slow
|
||||
}
|
||||
|
||||
async publishLive () {
|
||||
|
@ -71,6 +71,7 @@ export class VideoPublishPage extends VideoManage {
|
|||
await this.goOnPage('Main information')
|
||||
|
||||
const nameInput = $('input#name')
|
||||
await nameInput.scrollIntoView()
|
||||
await nameInput.clearValue()
|
||||
await nameInput.setValue(videoName)
|
||||
|
||||
|
|
|
@ -40,7 +40,7 @@ export class VideoWatchPage {
|
|||
}
|
||||
|
||||
isPrivacyWarningDisplayed () {
|
||||
return $('my-privacy-concerns').isDisplayed()
|
||||
return $('.privacy-concerns-text').isDisplayed()
|
||||
}
|
||||
|
||||
async goOnAssociatedEmbed (passwordProtected = false) {
|
||||
|
@ -74,6 +74,79 @@ export class VideoWatchPage {
|
|||
return go(FIXTURE_URLS.HLS_PLAYLIST_EMBED)
|
||||
}
|
||||
|
||||
getModalTitleEl () {
|
||||
return $('.modal-content .modal-title')
|
||||
}
|
||||
|
||||
confirmModal () {
|
||||
return $('.modal-content .modal-footer .primary-button').click()
|
||||
}
|
||||
|
||||
private async getVideoNameElement () {
|
||||
// We have 2 video info name block, pick the first that is not empty
|
||||
const elem = async () => {
|
||||
const elems = await $$('.video-info-first-row .video-info-name').filter(e => e.isDisplayed())
|
||||
|
||||
return elems[0]
|
||||
}
|
||||
|
||||
await browser.waitUntil(async () => {
|
||||
const e = await elem()
|
||||
|
||||
return e?.isDisplayed()
|
||||
})
|
||||
|
||||
return elem()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Video password
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
isPasswordProtected () {
|
||||
return $('#confirmInput').isExisting()
|
||||
}
|
||||
|
||||
async fillVideoPassword (videoPassword: string) {
|
||||
const videoPasswordInput = await $('input#confirmInput')
|
||||
await videoPasswordInput.waitForClickable()
|
||||
await videoPasswordInput.clearValue()
|
||||
await videoPasswordInput.setValue(videoPassword)
|
||||
|
||||
const confirmButton = await $('input[value="Confirm"]')
|
||||
await confirmButton.waitForClickable()
|
||||
return confirmButton.click()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Video actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async like () {
|
||||
const likeButton = await $('.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()
|
||||
|
||||
|
@ -90,6 +163,17 @@ export class VideoWatchPage {
|
|||
}
|
||||
}
|
||||
|
||||
async clickOnMoreDropdownIcon () {
|
||||
const dropdown = $('my-video-actions-dropdown .action-button')
|
||||
await dropdown.click()
|
||||
|
||||
await $('.dropdown-menu.show .dropdown-item').waitForDisplayed()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Playlists
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
clickOnSave () {
|
||||
return $('.action-button-save').click()
|
||||
}
|
||||
|
@ -116,69 +200,9 @@ export class VideoWatchPage {
|
|||
return playlist().click()
|
||||
}
|
||||
|
||||
async clickOnMoreDropdownIcon () {
|
||||
const dropdown = $('my-video-actions-dropdown .action-button')
|
||||
await dropdown.click()
|
||||
|
||||
await $('.dropdown-menu.show .dropdown-item').waitForDisplayed()
|
||||
}
|
||||
|
||||
private async getVideoNameElement () {
|
||||
// We have 2 video info name block, pick the first that is not empty
|
||||
const elem = async () => {
|
||||
const elems = await $$('.video-info-first-row .video-info-name').filter(e => e.isDisplayed())
|
||||
|
||||
return elems[0]
|
||||
}
|
||||
|
||||
await browser.waitUntil(async () => {
|
||||
const e = await elem()
|
||||
|
||||
return e?.isDisplayed()
|
||||
})
|
||||
|
||||
return elem()
|
||||
}
|
||||
|
||||
isPasswordProtected () {
|
||||
return $('#confirmInput').isExisting()
|
||||
}
|
||||
|
||||
async fillVideoPassword (videoPassword: string) {
|
||||
const videoPasswordInput = await $('input#confirmInput')
|
||||
await videoPasswordInput.waitForClickable()
|
||||
await videoPasswordInput.clearValue()
|
||||
await videoPasswordInput.setValue(videoPassword)
|
||||
|
||||
const confirmButton = await $('input[value="Confirm"]')
|
||||
await confirmButton.waitForClickable()
|
||||
return confirmButton.click()
|
||||
}
|
||||
|
||||
async like () {
|
||||
const likeButton = await $('.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)
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async createThread (comment: string) {
|
||||
const textarea = await $('my-video-comment-add textarea')
|
||||
|
|
393
client/e2e/src/suites-local/nsfw.e2e-spec.ts
Normal file
393
client/e2e/src/suites-local/nsfw.e2e-spec.ts
Normal 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, 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-test.png'))
|
||||
await checkVideo({ policy, videoName: video, nsfwTooltip })
|
||||
}
|
||||
}
|
||||
|
||||
for (const video of videos) {
|
||||
await videoSearchPage.search(video)
|
||||
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 browser.maximizeWindow()
|
||||
})
|
||||
|
||||
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 = await playerPage.getMoreNSFWInfoButton()
|
||||
expect(await moreButton.isDisplayed()).toBeTruthy()
|
||||
|
||||
await moreButton.click()
|
||||
await playerPage.getNSFWMoreContent().waitForDisplayed()
|
||||
|
||||
const moreContent = await playerPage.getNSFWMoreContent().getText()
|
||||
expect(moreContent).toContain('Violence')
|
||||
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 = await videoWatchPage.getModalTitleEl()
|
||||
await confirmTitle.waitForDisplayed()
|
||||
expect(await confirmTitle.getText()).toContain('Mature or explicit content')
|
||||
|
||||
await videoWatchPage.confirmModal()
|
||||
await videoWatchPage.waitWatchVideoName(nsfwVideo)
|
||||
|
||||
await playerPage.waitUntilPlaying()
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -3,7 +3,7 @@ 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, waitServerUp } from '../utils'
|
||||
import { getScreenshotPath, go, isMobileDevice, isSafari, waitServerUp } from '../utils'
|
||||
|
||||
describe('Player settings', () => {
|
||||
let videoPublishPage: VideoPublishPage
|
||||
|
@ -80,4 +80,8 @@ describe('Player settings', () => {
|
|||
await checkP2P(false)
|
||||
})
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await browser.saveScreenshot(getScreenshotPath('after-test.png'))
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,22 +1,41 @@
|
|||
import { LoginPage } from '../po/login.po'
|
||||
import { VideoPublishPage } from '../po/video-publish.po'
|
||||
import { getScreenshotPath, isMobileDevice, waitServerUp } from '../utils'
|
||||
import { VideoWatchPage } from '../po/video-watch.po'
|
||||
import { getScreenshotPath, isMobileDevice, isSafari, 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 browser.maximizeWindow()
|
||||
|
||||
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()
|
||||
|
|
|
@ -190,7 +190,7 @@ describe('Signup', () => {
|
|||
})
|
||||
|
||||
it('Should validate the third step (account)', async function () {
|
||||
await signupPage.fillAccountStep({ username: 'user_2', displayName: 'user_2 display name', password: 'password' })
|
||||
await signupPage.fillAccountStep({ username: 'user_2', displayName: 'user_2 display name', password: 'superpassword' })
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
|
@ -213,7 +213,7 @@ describe('Signup', () => {
|
|||
})
|
||||
|
||||
it('Should display a message when trying to login with this account', async function () {
|
||||
const error = await loginPage.getLoginError('user_2', 'password')
|
||||
const error = await loginPage.getLoginError('user_2', 'superpassword')
|
||||
|
||||
expect(error).toContain('awaiting approval')
|
||||
})
|
||||
|
@ -221,14 +221,14 @@ describe('Signup', () => {
|
|||
it('Should accept the registration', async function () {
|
||||
await loginPage.loginAsRootUser()
|
||||
|
||||
await adminRegistrationPage.navigateToRegistratonsList()
|
||||
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: 'password', displayName: 'user_2 display name' })
|
||||
await loginPage.login({ username: 'user_2', password: 'superpassword', displayName: 'user_2 display name' })
|
||||
|
||||
await loginPage.logout()
|
||||
})
|
||||
|
@ -335,7 +335,7 @@ describe('Signup', () => {
|
|||
username: 'user_4',
|
||||
displayName: 'user_4 display name',
|
||||
email: 'user_4@example.com',
|
||||
password: 'password'
|
||||
password: 'superpassword'
|
||||
})
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
@ -359,7 +359,7 @@ describe('Signup', () => {
|
|||
})
|
||||
|
||||
it('Should display a message when trying to login with this account', async function () {
|
||||
const error = await loginPage.getLoginError('user_4', 'password')
|
||||
const error = await loginPage.getLoginError('user_4', 'superpassword')
|
||||
|
||||
expect(error).toContain('awaiting approval')
|
||||
})
|
||||
|
@ -367,7 +367,7 @@ describe('Signup', () => {
|
|||
it('Should accept the registration', async function () {
|
||||
await loginPage.loginAsRootUser()
|
||||
|
||||
await adminRegistrationPage.navigateToRegistratonsList()
|
||||
await adminRegistrationPage.navigateToRegistrationsList()
|
||||
await adminRegistrationPage.accept('user_4', 'moderation response 2')
|
||||
|
||||
await loginPage.logout()
|
||||
|
@ -399,4 +399,8 @@ describe('Signup', () => {
|
|||
MockSMTPServer.Instance.kill()
|
||||
})
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await browser.saveScreenshot(getScreenshotPath('after-test.png'))
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,213 +0,0 @@
|
|||
import { AdminConfigPage } from '../po/admin-config.po'
|
||||
import { LoginPage } from '../po/login.po'
|
||||
import { MyAccountPage } from '../po/my-account.po'
|
||||
import { VideoListPage } from '../po/video-list.po'
|
||||
import { VideoSearchPage } from '../po/video-search.po'
|
||||
import { VideoPublishPage } from '../po/video-publish.po'
|
||||
import { VideoWatchPage } from '../po/video-watch.po'
|
||||
import { NSFWPolicy } from '../types/common'
|
||||
import { isMobileDevice, isSafari, waitServerUp } from '../utils'
|
||||
|
||||
describe('Videos list', () => {
|
||||
let videoListPage: VideoListPage
|
||||
let videoPublishPage: VideoPublishPage
|
||||
let adminConfigPage: AdminConfigPage
|
||||
let loginPage: LoginPage
|
||||
let myAccountPage: MyAccountPage
|
||||
let videoSearchPage: VideoSearchPage
|
||||
let videoWatchPage: VideoWatchPage
|
||||
|
||||
const seed = Math.random()
|
||||
const nsfwVideo = seed + ' - nsfw'
|
||||
const normalVideo = seed + ' - normal'
|
||||
|
||||
async function checkNormalVideo () {
|
||||
expect(await videoListPage.videoExists(normalVideo)).toBeTruthy()
|
||||
expect(await videoListPage.videoIsBlurred(normalVideo)).toBeFalsy()
|
||||
}
|
||||
|
||||
async function checkNSFWVideo (policy: NSFWPolicy, filterText?: string) {
|
||||
if (policy === 'do_not_list') {
|
||||
if (filterText) expect(filterText).toContain('hidden')
|
||||
|
||||
expect(await videoListPage.videoExists(nsfwVideo)).toBeFalsy()
|
||||
return
|
||||
}
|
||||
|
||||
if (policy === 'blur') {
|
||||
if (filterText) expect(filterText).toContain('blurred')
|
||||
|
||||
expect(await videoListPage.videoExists(nsfwVideo)).toBeTruthy()
|
||||
expect(await videoListPage.videoIsBlurred(nsfwVideo)).toBeTruthy()
|
||||
return
|
||||
}
|
||||
|
||||
// display
|
||||
if (filterText) expect(filterText).toContain('displayed')
|
||||
|
||||
expect(await videoListPage.videoExists(nsfwVideo)).toBeTruthy()
|
||||
expect(await videoListPage.videoIsBlurred(nsfwVideo)).toBeFalsy()
|
||||
}
|
||||
|
||||
async function checkCommonVideoListPages (policy: NSFWPolicy) {
|
||||
const promisesWithFilters = [
|
||||
videoListPage.goOnRootAccount.bind(videoListPage),
|
||||
videoListPage.goOnBrowseVideos.bind(videoListPage),
|
||||
videoListPage.goOnRootChannel.bind(videoListPage)
|
||||
]
|
||||
|
||||
for (const p of promisesWithFilters) {
|
||||
await p()
|
||||
|
||||
const filter = await videoListPage.getNSFWFilter()
|
||||
const filterText = await filter.getText()
|
||||
|
||||
await checkNormalVideo()
|
||||
await checkNSFWVideo(policy, filterText)
|
||||
}
|
||||
|
||||
const promisesWithoutFilters = [
|
||||
videoListPage.goOnRootAccountChannels.bind(videoListPage),
|
||||
videoListPage.goOnHomepage.bind(videoListPage)
|
||||
]
|
||||
for (const p of promisesWithoutFilters) {
|
||||
await p()
|
||||
|
||||
await checkNormalVideo()
|
||||
await checkNSFWVideo(policy)
|
||||
}
|
||||
}
|
||||
|
||||
async function checkSearchPage (policy: NSFWPolicy) {
|
||||
await videoSearchPage.search(normalVideo)
|
||||
await checkNormalVideo()
|
||||
|
||||
await videoSearchPage.search(nsfwVideo)
|
||||
await checkNSFWVideo(policy)
|
||||
}
|
||||
|
||||
async function updateAdminNSFW (nsfw: NSFWPolicy) {
|
||||
await adminConfigPage.updateNSFWSetting(nsfw)
|
||||
await adminConfigPage.save()
|
||||
}
|
||||
|
||||
async function updateUserNSFW (nsfw: NSFWPolicy) {
|
||||
await myAccountPage.navigateToMySettings()
|
||||
await myAccountPage.updateNSFW(nsfw)
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
await waitServerUp()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
videoListPage = new VideoListPage(isMobileDevice(), isSafari())
|
||||
adminConfigPage = new AdminConfigPage()
|
||||
loginPage = new LoginPage(isMobileDevice())
|
||||
videoPublishPage = new VideoPublishPage()
|
||||
myAccountPage = new MyAccountPage()
|
||||
videoSearchPage = new VideoSearchPage()
|
||||
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
|
||||
|
||||
await browser.maximizeWindow()
|
||||
})
|
||||
|
||||
it('Should login and disable NSFW', async () => {
|
||||
await loginPage.loginAsRootUser()
|
||||
await updateUserNSFW('display')
|
||||
})
|
||||
|
||||
it('Should set the homepage', async () => {
|
||||
await adminConfigPage.updateHomepage('<peertube-videos-list data-sort="-publishedAt"></peertube-videos-list>')
|
||||
await adminConfigPage.save()
|
||||
})
|
||||
|
||||
it('Should upload 2 videos (NSFW and classic videos)', async () => {
|
||||
await videoPublishPage.navigateTo()
|
||||
await videoPublishPage.uploadVideo('video.mp4')
|
||||
await videoPublishPage.setAsNSFW()
|
||||
await videoPublishPage.validSecondStep(nsfwVideo)
|
||||
|
||||
await videoPublishPage.navigateTo()
|
||||
await videoPublishPage.uploadVideo('video2.mp4')
|
||||
await videoPublishPage.validSecondStep(normalVideo)
|
||||
})
|
||||
|
||||
it('Should logout', async function () {
|
||||
await loginPage.logout()
|
||||
})
|
||||
|
||||
describe('Anonymous users', 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')
|
||||
await checkSearchPage('do_not_list')
|
||||
})
|
||||
|
||||
it('Should correctly handle blur', async () => {
|
||||
await loginPage.loginAsRootUser()
|
||||
await updateAdminNSFW('blur')
|
||||
|
||||
await loginPage.logout()
|
||||
await checkCommonVideoListPages('blur')
|
||||
await checkSearchPage('blur')
|
||||
})
|
||||
|
||||
it('Should correctly handle display', async () => {
|
||||
await loginPage.loginAsRootUser()
|
||||
await updateAdminNSFW('display')
|
||||
|
||||
await loginPage.logout()
|
||||
await checkCommonVideoListPages('display')
|
||||
await checkSearchPage('display')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Logged in users', function () {
|
||||
before(async () => {
|
||||
await loginPage.loginAsRootUser()
|
||||
})
|
||||
|
||||
it('Should correctly handle do not list', async () => {
|
||||
await updateUserNSFW('do_not_list')
|
||||
await checkCommonVideoListPages('do_not_list')
|
||||
await checkSearchPage('do_not_list')
|
||||
})
|
||||
|
||||
it('Should correctly handle blur', async () => {
|
||||
await updateUserNSFW('blur')
|
||||
await checkCommonVideoListPages('blur')
|
||||
await checkSearchPage('blur')
|
||||
})
|
||||
|
||||
it('Should correctly handle display', async () => {
|
||||
await updateUserNSFW('display')
|
||||
await checkCommonVideoListPages('display')
|
||||
await checkSearchPage('display')
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await loginPage.logout()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Default upload values', function () {
|
||||
it('Should have default video values', async function () {
|
||||
await loginPage.loginAsRootUser()
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
|
@ -1 +0,0 @@
|
|||
export type NSFWPolicy = 'do_not_list' | 'blur' | 'display'
|
|
@ -41,19 +41,30 @@ export async function selectCustomSelect (id: string, valueLabel: string) {
|
|||
await wrapper.waitForClickable()
|
||||
await wrapper.click()
|
||||
|
||||
const option = await $$(`[formcontrolname=${id}] li[role=option]`).filter(async o => {
|
||||
const text = await o.getText()
|
||||
const getOption = async () => {
|
||||
const options = await $$(`[formcontrolname=${id}] li[role=option]`).filter(async o => {
|
||||
const text = await o.getText()
|
||||
|
||||
return text.trimStart().startsWith(valueLabel)
|
||||
}).then(options => options[0])
|
||||
return text.trimStart().startsWith(valueLabel)
|
||||
})
|
||||
|
||||
await option.waitForDisplayed()
|
||||
if (options.length === 0) return undefined
|
||||
|
||||
return option.click()
|
||||
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: WebdriverIO.Element,
|
||||
el: ChainablePromiseElement,
|
||||
finder: (el: WebdriverIO.Element) => Promise<boolean>
|
||||
) {
|
||||
if (await finder(el) === true) return el
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { mkdirSync, rmSync } from 'fs'
|
||||
import { mkdir, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
|
||||
const SCREENSHOTS_DIRECTORY = 'screenshots'
|
||||
|
||||
export function createScreenshotsDirectory () {
|
||||
rmSync(SCREENSHOTS_DIRECTORY, { recursive: true, force: true })
|
||||
mkdirSync(SCREENSHOTS_DIRECTORY, { recursive: true })
|
||||
export async function createScreenshotsDirectory () {
|
||||
await rm(SCREENSHOTS_DIRECTORY, { recursive: true, force: true })
|
||||
await mkdir(SCREENSHOTS_DIRECTORY, { recursive: true })
|
||||
}
|
||||
|
||||
export function getScreenshotPath (filename: string) {
|
||||
|
|
|
@ -102,13 +102,7 @@ export const config = {
|
|||
bail: true
|
||||
},
|
||||
|
||||
autoCompileOpts: {
|
||||
autoCompile: true,
|
||||
|
||||
tsNodeOpts: {
|
||||
project: require('path').join(__dirname, './tsconfig.json')
|
||||
}
|
||||
},
|
||||
tsConfigPath: require('path').join(__dirname, './tsconfig.json'),
|
||||
|
||||
before: function () {
|
||||
require('./src/commands/upload')
|
||||
|
|
|
@ -75,12 +75,12 @@
|
|||
"@types/video.js": "^7.3.40",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.2",
|
||||
"@typescript-eslint/parser": "^7.0.2",
|
||||
"@wdio/browserstack-service": "^8.10.5",
|
||||
"@wdio/cli": "^8.10.5",
|
||||
"@wdio/local-runner": "^8.10.5",
|
||||
"@wdio/mocha-framework": "^8.10.4",
|
||||
"@wdio/shared-store-service": "^8.10.5",
|
||||
"@wdio/spec-reporter": "^8.10.5",
|
||||
"@wdio/browserstack-service": "^9.12.7",
|
||||
"@wdio/cli": "^9.12.7",
|
||||
"@wdio/local-runner": "^9.12.7",
|
||||
"@wdio/mocha-framework": "^9.12.6",
|
||||
"@wdio/shared-store-service": "^9.12.7",
|
||||
"@wdio/spec-reporter": "^9.12.6",
|
||||
"angularx-qrcode": "19.0.0",
|
||||
"bootstrap": "^5.1.3",
|
||||
"buffer": "^6.0.3",
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
<span *ngFor="let category of categories" class="pt-badge badge-secondary me-1">{{ category }}</span>
|
||||
</div>
|
||||
|
||||
<div i18n *ngIf="config.instance.isNSFW" class="fw-bold text-content mt-3">{{ config.instance.name }} is dedicated to sensitive/NSFW content.</div>
|
||||
<div i18n *ngIf="config.instance.isNSFW" class="fw-bold text-content mt-3">{{ config.instance.name }} is dedicated to sensitive content.</div>
|
||||
</div>
|
||||
|
||||
<div class="block description">
|
||||
|
|
|
@ -59,9 +59,7 @@ export class AccountVideoChannelsComponent implements OnInit, OnDestroy {
|
|||
views: true,
|
||||
by: false,
|
||||
avatar: false,
|
||||
privacyLabel: false,
|
||||
privacyText: false,
|
||||
blacklistInfo: false
|
||||
privacyLabel: false
|
||||
}
|
||||
|
||||
private accountSub: Subscription
|
||||
|
@ -109,7 +107,7 @@ export class AccountVideoChannelsComponent implements OnInit, OnDestroy {
|
|||
nsfw: this.videoService.nsfwPolicyToParam(this.nsfwPolicy)
|
||||
}
|
||||
|
||||
return this.videoService.getVideoChannelVideos(options)
|
||||
return this.videoService.listChannelVideos(options)
|
||||
.pipe(map(data => ({ videoChannel, videos: data.data, total: data.total })))
|
||||
})
|
||||
)
|
||||
|
|
|
@ -59,7 +59,7 @@ export class AccountVideosComponent implements OnInit, OnDestroy, DisableForReus
|
|||
skipCount: true
|
||||
}
|
||||
|
||||
return this.videoService.getAccountVideos(options)
|
||||
return this.videoService.listAccountVideos(options)
|
||||
}
|
||||
|
||||
getSyndicationItems () {
|
||||
|
|
|
@ -238,7 +238,7 @@ export class AccountsComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
private loadAccountVideosCount () {
|
||||
this.videoService.getAccountVideos({
|
||||
this.videoService.listAccountVideos({
|
||||
account: this.account,
|
||||
videoPagination: {
|
||||
currentPage: 1,
|
||||
|
|
|
@ -174,9 +174,9 @@
|
|||
|
||||
</div>
|
||||
|
||||
<div class="pt-two-cols mt-4"> <!-- moderation & nsfw grid -->
|
||||
<div class="pt-two-cols mt-4"> <!-- moderation grid -->
|
||||
<div class="title-col">
|
||||
<h2 i18n>MODERATION & NSFW</h2>
|
||||
<h2 i18n>MODERATION & SENSITIVE CONTENT</h2>
|
||||
<div i18n class="inner-form-description">
|
||||
Manage <a class="link-primary" routerLink="/admin/overview/users">users</a> to build a moderation team.
|
||||
</div>
|
||||
|
@ -186,34 +186,24 @@
|
|||
<div class="form-group">
|
||||
<my-peertube-checkbox inputName="instanceIsNSFW" formControlName="isNSFW">
|
||||
<ng-template ptTemplate="label">
|
||||
<ng-container i18n>This instance is dedicated to sensitive or NSFW content</ng-container>
|
||||
<ng-container i18n>This instance is dedicated to sensitive content</ng-container>
|
||||
</ng-template>
|
||||
|
||||
<ng-template ptTemplate="help">
|
||||
<ng-container i18n>
|
||||
Enabling it will allow other administrators to know that you are mainly federating sensitive content.<br />
|
||||
Moreover, the NSFW checkbox on video upload will be automatically checked by default.
|
||||
Moreover, the "sensitive content" checkbox on video upload will be automatically checked by default.
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="instanceDefaultNSFWPolicy">Policy on videos containing sensitive content</label>
|
||||
|
||||
<my-help>
|
||||
<ng-container i18n>
|
||||
With <strong>Hide</strong> or <strong>Blur thumbnails</strong>, a confirmation will be requested to watch the video.
|
||||
</ng-container>
|
||||
</my-help>
|
||||
|
||||
<div class="peertube-select-container">
|
||||
<select id="instanceDefaultNSFWPolicy" formControlName="defaultNSFWPolicy" class="form-control">
|
||||
<option i18n value="do_not_list">Hide</option>
|
||||
<option i18n value="blur">Blur thumbnails</option>
|
||||
<option i18n value="display">Display</option>
|
||||
</select>
|
||||
</div>
|
||||
<my-select-radio
|
||||
[items]="nsfwItems" inputId="instanceDefaultNSFWPolicy" isGroup="true"
|
||||
i18n-label label="Policy on videos containing sensitive content"
|
||||
formControlName="defaultNSFWPolicy"
|
||||
></my-select-radio>
|
||||
|
||||
<div *ngIf="formErrors().instance.defaultNSFWPolicy" class="form-error" role="alert">{{ formErrors().instance.defaultNSFWPolicy }}</div>
|
||||
</div>
|
||||
|
@ -238,7 +228,7 @@
|
|||
|
||||
<div class="form-group">
|
||||
<label i18n for="instanceModerationInformation">Moderation information</label><my-help helpType="markdownText"></my-help>
|
||||
<div i18n class="label-small-info">Who moderates the instance? What is the policy regarding NSFW videos? Political videos? etc</div>
|
||||
<div i18n class="label-small-info">Who moderates the instance? What is the policy regarding sensitive content? Political videos? etc</div>
|
||||
|
||||
<my-markdown-textarea
|
||||
inputId="instanceModerationInformation" formControlName="moderationInformation" markdownType="enhanced"
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { NgClass, NgIf } from '@angular/common'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { HttpErrorResponse } from '@angular/common/http'
|
||||
import { Component, OnInit, inject, input } from '@angular/core'
|
||||
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
|
@ -6,6 +6,7 @@ import { RouterLink } from '@angular/router'
|
|||
import { Notifier, ServerService } from '@app/core'
|
||||
import { genericUploadErrorHandler } from '@app/helpers'
|
||||
import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service'
|
||||
import { SelectRadioComponent } from '@app/shared/shared-forms/select/select-radio.component'
|
||||
import { InstanceService } from '@app/shared/shared-main/instance/instance.service'
|
||||
import { maxBy } from '@peertube/peertube-core-utils'
|
||||
import { ActorImage, HTMLServerConfig } from '@peertube/peertube-models'
|
||||
|
@ -16,8 +17,8 @@ import { CustomMarkupHelpComponent } from '../../../shared/shared-custom-markup/
|
|||
import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component'
|
||||
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
|
||||
import { SelectCheckboxComponent } from '../../../shared/shared-forms/select/select-checkbox.component'
|
||||
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/common/peertube-template.directive'
|
||||
import { HelpComponent } from '../../../shared/shared-main/buttons/help.component'
|
||||
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/common/peertube-template.directive'
|
||||
|
||||
@Component({
|
||||
selector: 'my-edit-instance-information',
|
||||
|
@ -28,8 +29,8 @@ import { HelpComponent } from '../../../shared/shared-main/buttons/help.componen
|
|||
ReactiveFormsModule,
|
||||
ActorAvatarEditComponent,
|
||||
ActorBannerEditComponent,
|
||||
NgClass,
|
||||
NgIf,
|
||||
SelectRadioComponent,
|
||||
CommonModule,
|
||||
CustomMarkupHelpComponent,
|
||||
MarkdownTextareaComponent,
|
||||
SelectCheckboxComponent,
|
||||
|
@ -54,6 +55,25 @@ export class EditInstanceInformationComponent implements OnInit {
|
|||
instanceBannerUrl: string
|
||||
instanceAvatars: ActorImage[] = []
|
||||
|
||||
nsfwItems: SelectOptionsItem[] = [
|
||||
{
|
||||
id: 'do_not_list',
|
||||
label: $localize`Hide`
|
||||
},
|
||||
{
|
||||
id: 'warn',
|
||||
label: $localize`Warn`
|
||||
},
|
||||
{
|
||||
id: 'blur',
|
||||
label: $localize`Blur`
|
||||
},
|
||||
{
|
||||
id: 'display',
|
||||
label: $localize`Display`
|
||||
}
|
||||
]
|
||||
|
||||
private serverConfig: HTMLServerConfig
|
||||
|
||||
get instanceName () {
|
||||
|
|
|
@ -48,16 +48,15 @@
|
|||
<td>
|
||||
<my-video-cell [video]="videoBlock.video" size="small">
|
||||
<div>
|
||||
<span
|
||||
*ngIf="videoBlock.type === 2"
|
||||
i18n-title title="The video was blocked due to automatic blocking of new videos" class="pt-badge badge-info" i18n
|
||||
>Auto block</span>
|
||||
@if (videoBlock.type === 2) {
|
||||
<span i18n-title title="The video was blocked due to automatic blocking of new videos" class="pt-badge badge-info badge-small" i18n>Auto block</span>
|
||||
}
|
||||
</div>
|
||||
</my-video-cell>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<span *ngIf="videoBlock.video.nsfw" class="pt-badge badge-red" i18n>NSFW</span>
|
||||
<my-video-nsfw-badge [video]="videoBlock.video" theme="red"></my-video-nsfw-badge>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
|
|
@ -19,6 +19,7 @@ import { AutoColspanDirective } from '../../../shared/shared-main/common/auto-co
|
|||
import { EmbedComponent } from '../../../shared/shared-main/video/embed.component'
|
||||
import { TableExpanderIconComponent } from '../../../shared/shared-tables/table-expander-icon.component'
|
||||
import { VideoCellComponent } from '../../../shared/shared-tables/video-cell.component'
|
||||
import { VideoNSFWBadgeComponent } from '../../../shared/shared-video/video-nsfw-badge.component'
|
||||
|
||||
@Component({
|
||||
selector: 'my-video-block-list',
|
||||
|
@ -36,7 +37,8 @@ import { VideoCellComponent } from '../../../shared/shared-tables/video-cell.com
|
|||
VideoCellComponent,
|
||||
AutoColspanDirective,
|
||||
EmbedComponent,
|
||||
PTDatePipe
|
||||
PTDatePipe,
|
||||
VideoNSFWBadgeComponent
|
||||
]
|
||||
})
|
||||
export class VideoBlockListComponent extends RestTable implements OnInit {
|
||||
|
|
|
@ -74,7 +74,7 @@
|
|||
|
||||
<my-video-privacy-badge [video]="video"></my-video-privacy-badge>
|
||||
|
||||
<span *ngIf="video.nsfw" class="pt-badge badge-red" i18n>NSFW</span>
|
||||
<my-video-nsfw-badge [video]="video" theme="red"></my-video-nsfw-badge>
|
||||
|
||||
<span *ngIf="isUnpublished(video)" class="pt-badge badge-yellow">{{ video.state.label }}</span>
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ import { VideoBlockComponent } from '@app/shared/shared-moderation/video-block.c
|
|||
import { VideoBlockService } from '@app/shared/shared-moderation/video-block.service'
|
||||
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { getAllFiles } from '@peertube/peertube-core-utils'
|
||||
import { FileStorage, UserRight, VideoFile, VideoState, VideoStreamingPlaylistType } from '@peertube/peertube-models'
|
||||
import { FileStorage, NSFWFlag, UserRight, VideoFile, VideoState, VideoStreamingPlaylistType } from '@peertube/peertube-models'
|
||||
import { videoRequiresFileToken } from '@root-helpers/video'
|
||||
import { SharedModule, SortMeta } from 'primeng/api'
|
||||
import { TableModule, TableRowExpandEvent } from 'primeng/table'
|
||||
|
@ -31,6 +31,7 @@ import {
|
|||
VideoActionsDisplayType,
|
||||
VideoActionsDropdownComponent
|
||||
} from '../../../shared/shared-video-miniature/video-actions-dropdown.component'
|
||||
import { VideoNSFWBadgeComponent } from '../../../shared/shared-video/video-nsfw-badge.component'
|
||||
import { VideoPrivacyBadgeComponent } from '../../../shared/shared-video/video-privacy-badge.component'
|
||||
import { VideoAdminService } from './video-admin.service'
|
||||
|
||||
|
@ -58,7 +59,8 @@ import { VideoAdminService } from './video-admin.service'
|
|||
PTDatePipe,
|
||||
RouterLink,
|
||||
BytesPipe,
|
||||
VideoPrivacyBadgeComponent
|
||||
VideoPrivacyBadgeComponent,
|
||||
VideoNSFWBadgeComponent
|
||||
]
|
||||
})
|
||||
export class VideoListComponent extends RestTable<Video> implements OnInit {
|
||||
|
@ -305,7 +307,11 @@ export class VideoListComponent extends RestTable<Video> implements OnInit {
|
|||
this.videoAdminService.getAdminVideos({
|
||||
pagination: this.pagination,
|
||||
sort: this.sort,
|
||||
nsfw: 'both', // Always list NSFW video, overriding instance/user setting
|
||||
|
||||
// Always list NSFW video, overriding instance/user setting
|
||||
nsfw: 'both',
|
||||
nsfwFlagsExcluded: NSFWFlag.NONE,
|
||||
|
||||
search: this.search
|
||||
}).pipe(finalize(() => this.loading = false))
|
||||
.subscribe({
|
||||
|
|
|
@ -28,7 +28,7 @@
|
|||
color: pvar(--fg-400);
|
||||
|
||||
&[iconName=npm] {
|
||||
@include fill-svg-color(pvar(--fg-400));
|
||||
@include fill-path-svg-color(pvar(--fg-400));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -57,9 +57,7 @@ export class MyHistoryComponent implements OnInit, DisableForReuseHook {
|
|||
date: true,
|
||||
views: true,
|
||||
by: true,
|
||||
privacyLabel: false,
|
||||
privacyText: true,
|
||||
blacklistInfo: true
|
||||
privacyLabel: false
|
||||
}
|
||||
|
||||
getVideosObservableFunction = this.getVideosObservable.bind(this)
|
||||
|
|
|
@ -145,7 +145,7 @@
|
|||
</td>
|
||||
|
||||
<td *ngIf="isSelected('sensitive')">
|
||||
<span *ngIf="video.nsfw" class="pt-badge badge-yellow" i18n>NSFW</span>
|
||||
<my-video-nsfw-badge [video]="video"></my-video-nsfw-badge>
|
||||
</td>
|
||||
|
||||
<td *ngIf="isSelected('playlists')">
|
||||
|
|
|
@ -49,6 +49,7 @@ import {
|
|||
VideoActionsDisplayType,
|
||||
VideoActionsDropdownComponent
|
||||
} from '../../shared/shared-video-miniature/video-actions-dropdown.component'
|
||||
import { VideoNSFWBadgeComponent } from '../../shared/shared-video/video-nsfw-badge.component'
|
||||
import { VideoPrivacyBadgeComponent } from '../../shared/shared-video/video-privacy-badge.component'
|
||||
import { VideoStateBadgeComponent } from '../../shared/shared-video/video-state-badge.component'
|
||||
import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component'
|
||||
|
@ -95,7 +96,8 @@ type QueryParams = {
|
|||
ChannelToggleComponent,
|
||||
AutoColspanDirective,
|
||||
SelectCheckboxComponent,
|
||||
PTDatePipe
|
||||
PTDatePipe,
|
||||
VideoNSFWBadgeComponent
|
||||
]
|
||||
})
|
||||
export class MyVideosComponent extends RestTable<Video> implements OnInit, OnDestroy {
|
||||
|
|
|
@ -35,25 +35,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" role="radiogroup">
|
||||
<div class="radio-label label-container">
|
||||
<label for="sensitiveContent" i18n>Display sensitive content</label>
|
||||
<button type="button" i18n class="reset-button reset-button-small" (click)="resetField('nsfw')" *ngIf="advancedSearch().nsfw !== undefined">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="peertube-radio-container">
|
||||
<input type="radio" name="sensitiveContent" id="sensitiveContentYes" value="both" [(ngModel)]="advancedSearch().nsfw">
|
||||
<label i18n for="sensitiveContentYes" class="radio">Yes</label>
|
||||
</div>
|
||||
|
||||
<div class="peertube-radio-container">
|
||||
<input type="radio" name="sensitiveContent" id="sensitiveContentNo" value="false" [(ngModel)]="advancedSearch().nsfw">
|
||||
<label i18n for="sensitiveContentNo" class="radio">No</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="radio-label label-container">
|
||||
<label for="publishedDateRange" i18n>Published date</label>
|
||||
|
|
|
@ -74,9 +74,7 @@ export class SearchComponent implements OnInit, OnDestroy {
|
|||
views: true,
|
||||
by: true,
|
||||
avatar: true,
|
||||
privacyLabel: false,
|
||||
privacyText: false,
|
||||
blacklistInfo: false
|
||||
privacyLabel: false
|
||||
}
|
||||
|
||||
errorMessage: string
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { NgIf } from '@angular/common'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { AfterViewInit, Component, OnDestroy, OnInit, inject, viewChild } from '@angular/core'
|
||||
import { ComponentPaginationLight, DisableForReuseHook, HooksService, ScreenService } from '@app/core'
|
||||
import { VideoChannel } from '@app/shared/shared-main/channel/video-channel.model'
|
||||
|
@ -12,7 +12,7 @@ import { VideosListComponent } from '../../shared/shared-video-miniature/videos-
|
|||
@Component({
|
||||
selector: 'my-video-channel-videos',
|
||||
templateUrl: './video-channel-videos.component.html',
|
||||
imports: [ NgIf, VideosListComponent ]
|
||||
imports: [ CommonModule, VideosListComponent ]
|
||||
})
|
||||
export class VideoChannelVideosComponent implements OnInit, AfterViewInit, OnDestroy, DisableForReuseHook {
|
||||
private screenService = inject(ScreenService)
|
||||
|
@ -65,7 +65,7 @@ export class VideoChannelVideosComponent implements OnInit, AfterViewInit, OnDes
|
|||
skipCount: true
|
||||
}
|
||||
|
||||
return this.videoService.getVideoChannelVideos(params)
|
||||
return this.videoService.listChannelVideos(params)
|
||||
}
|
||||
|
||||
getSyndicationItems () {
|
||||
|
|
|
@ -176,7 +176,7 @@ export class VideoChannelsComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
private loadChannelVideosCount () {
|
||||
this.videoService.getVideoChannelVideos({
|
||||
this.videoService.listChannelVideos({
|
||||
videoChannel: this.videoChannel,
|
||||
videoPagination: {
|
||||
currentPage: 1,
|
||||
|
|
|
@ -58,7 +58,7 @@ export class VideosListAllComponent implements OnInit, OnDestroy, DisableForReus
|
|||
}
|
||||
|
||||
return this.hooks.wrapObsFun(
|
||||
this.videoService.getVideos.bind(this.videoService),
|
||||
this.videoService.listVideos.bind(this.videoService),
|
||||
params,
|
||||
'common',
|
||||
'filter:api.browse-videos.videos.list.params',
|
||||
|
|
|
@ -14,6 +14,6 @@
|
|||
my-global-icon {
|
||||
color: pvar(--active-icon-color) !important;
|
||||
|
||||
@include fill-svg-color(pvar(--active-icon-bg));
|
||||
@include fill-path-svg-color(pvar(--active-icon-bg));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -59,15 +59,10 @@ export class VideoRecommendationService {
|
|||
return this.userService.getAnonymousOrLoggedUser()
|
||||
.pipe(
|
||||
switchMap(user => {
|
||||
const nsfw = user.nsfwPolicy
|
||||
? this.videos.nsfwPolicyToParam(user.nsfwPolicy)
|
||||
: undefined
|
||||
|
||||
const defaultSubscription = this.videos.getVideos({
|
||||
const defaultSubscription = this.videos.listVideos({
|
||||
skipCount: true,
|
||||
videoPagination: pagination,
|
||||
sort: '-publishedAt',
|
||||
nsfw
|
||||
sort: '-publishedAt'
|
||||
}).pipe(map(v => v.data))
|
||||
|
||||
const searchIndexConfig = this.config.search.searchIndex
|
||||
|
@ -83,7 +78,6 @@ export class VideoRecommendationService {
|
|||
tagsOneOf: currentVideo.tags.join(','),
|
||||
sort: '-publishedAt',
|
||||
searchTarget: 'local',
|
||||
nsfw,
|
||||
excludeAlreadyWatched: user.id
|
||||
? true
|
||||
: undefined
|
||||
|
|
|
@ -32,7 +32,7 @@
|
|||
<my-plugin-placeholder pluginId="player-next"></my-plugin-placeholder>
|
||||
</div>
|
||||
|
||||
<my-video-alert [video]="video" [user]="user" [noPlaylistVideoFound]="noPlaylistVideoFound"></my-video-alert>
|
||||
<my-video-alert [video]="video" [user]="authUser" [noPlaylistVideoFound]="noPlaylistVideoFound"></my-video-alert>
|
||||
</div>
|
||||
|
||||
<!-- Video information -->
|
||||
|
@ -110,7 +110,7 @@
|
|||
class="border-top pt-3"
|
||||
[video]="video"
|
||||
[videoPassword]="videoPassword"
|
||||
[user]="user"
|
||||
[user]="authUser"
|
||||
(timestampClicked)="handleTimestampClicked($event)"
|
||||
></my-video-comments>
|
||||
</div>
|
||||
|
|
|
@ -185,7 +185,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
|
||||
private hotkeys: Hotkey[] = []
|
||||
|
||||
get user () {
|
||||
get authUser () {
|
||||
return this.authService.getUser()
|
||||
}
|
||||
|
||||
|
@ -276,11 +276,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
isUserOwner () {
|
||||
return this.video.isLocal === true && this.video.account.name === this.user?.username
|
||||
}
|
||||
|
||||
isVideoBlur (video: Video) {
|
||||
return video.isVideoNSFWForUser(this.user, this.serverConfig)
|
||||
return this.video.isLocal === true && this.video.account.name === this.authUser?.username
|
||||
}
|
||||
|
||||
isChannelDisplayNameGeneric () {
|
||||
|
@ -526,7 +522,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
this.transcriptionWidgetOpened = false
|
||||
}
|
||||
|
||||
if (this.isVideoBlur(this.video)) {
|
||||
if (this.video.isVideoNSFWHiddenForUser(loggedInOrAnonymousUser, this.serverConfig)) {
|
||||
const res = await this.confirmService.confirm(
|
||||
$localize`This video contains mature or explicit content. Are you sure you want to watch it?`,
|
||||
$localize`Mature or explicit content`
|
||||
|
@ -556,7 +552,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
|
||||
const videoState = this.video.state.id
|
||||
if (videoState === VideoState.LIVE_ENDED || videoState === VideoState.WAITING_FOR_LIVE) {
|
||||
this.updatePlayerOnNoLive()
|
||||
this.updatePlayerOnNoLive({ hasPlayed: false })
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -573,7 +569,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
urlOptions: this.getUrlOptions(),
|
||||
loggedInOrAnonymousUser,
|
||||
forceAutoplay,
|
||||
user: this.user
|
||||
user: this.authUser
|
||||
}
|
||||
|
||||
const loadOptions = await this.hooks.wrapFun(
|
||||
|
@ -649,31 +645,18 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
})
|
||||
}
|
||||
|
||||
private isAutoplay () {
|
||||
private isAutoplay (loggedInOrAnonymousUser: User) {
|
||||
// We'll jump to the thread id, so do not play the video
|
||||
if (this.route.snapshot.params['threadId']) return false
|
||||
|
||||
if (this.user) return this.user.autoPlayVideo
|
||||
// Prevent autoplay if we need to warn the user
|
||||
if (this.video.isVideoNSFWWarnedForUser(loggedInOrAnonymousUser, this.serverConfig)) return false
|
||||
|
||||
if (this.anonymousUser) return this.anonymousUser.autoPlayVideo
|
||||
if (loggedInOrAnonymousUser) return loggedInOrAnonymousUser.autoPlayVideo
|
||||
|
||||
throw new Error('Cannot guess autoplay because user and anonymousUser are not defined')
|
||||
}
|
||||
|
||||
private isAutoPlayNext () {
|
||||
return (
|
||||
(this.user?.autoPlayNextVideo) ||
|
||||
this.anonymousUser.autoPlayNextVideo
|
||||
)
|
||||
}
|
||||
|
||||
private isPlaylistAutoPlayNext () {
|
||||
return (
|
||||
(this.user?.autoPlayNextVideoPlaylist) ||
|
||||
this.anonymousUser.autoPlayNextVideoPlaylist
|
||||
)
|
||||
}
|
||||
|
||||
private buildPeerTubePlayerConstructorOptions (options: {
|
||||
urlOptions: URLOptions
|
||||
}): PeerTubePlayerConstructorOptions {
|
||||
|
@ -821,11 +804,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
return {
|
||||
mode,
|
||||
|
||||
autoplay: this.isAutoplay(),
|
||||
autoplay: this.isAutoplay(loggedInOrAnonymousUser),
|
||||
forceAutoplay,
|
||||
|
||||
duration: this.video.duration,
|
||||
poster: video.previewUrl,
|
||||
p2pEnabled: isP2PEnabled(video, this.serverConfig, loggedInOrAnonymousUser.p2pEnabled),
|
||||
|
||||
startTime,
|
||||
|
@ -846,9 +828,20 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
videoFileToken: () => videoFileToken,
|
||||
requiresUserAuth: videoRequiresUserAuth(video, videoPassword),
|
||||
requiresPassword: video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED &&
|
||||
!video.canBypassPassword(this.user),
|
||||
!video.canBypassPassword(this.authUser),
|
||||
videoPassword: () => videoPassword,
|
||||
|
||||
poster: video.isVideoNSFWBlurForUser(loggedInOrAnonymousUser, this.serverConfig)
|
||||
? null
|
||||
: video.previewUrl,
|
||||
|
||||
nsfwWarning: video.isVideoNSFWWarnedForUser(loggedInOrAnonymousUser, this.serverConfig)
|
||||
? {
|
||||
flags: video.nsfwFlags,
|
||||
summary: video.nsfwSummary
|
||||
}
|
||||
: undefined,
|
||||
|
||||
videoCaptions: playerCaptions,
|
||||
videoChapters,
|
||||
storyboard,
|
||||
|
@ -877,9 +870,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
|
||||
upnext: {
|
||||
isEnabled: () => {
|
||||
if (this.playlist) return this.isPlaylistAutoPlayNext()
|
||||
if (this.playlist) return loggedInOrAnonymousUser?.autoPlayNextVideoPlaylist
|
||||
|
||||
return this.isAutoPlayNext()
|
||||
return loggedInOrAnonymousUser?.autoPlayNextVideo
|
||||
},
|
||||
|
||||
isSuspended: (player: videojs.Player) => {
|
||||
|
@ -943,10 +936,13 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
this.video.viewers = newViewers
|
||||
}
|
||||
|
||||
private updatePlayerOnNoLive () {
|
||||
private updatePlayerOnNoLive ({ hasPlayed }: { hasPlayed: boolean }) {
|
||||
this.peertubePlayer.unload()
|
||||
this.peertubePlayer.disable()
|
||||
this.peertubePlayer.setPoster(this.video.previewPath)
|
||||
|
||||
if (hasPlayed || !this.video.isVideoNSFWBlurForUser(this.authUser || this.anonymousUser, this.serverConfig)) {
|
||||
this.peertubePlayer.setPoster(this.video.previewPath)
|
||||
}
|
||||
}
|
||||
|
||||
private buildHotkeysHelp (video: Video) {
|
||||
|
@ -1039,6 +1035,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
|
||||
this.video.state.id = VideoState.LIVE_ENDED
|
||||
|
||||
this.updatePlayerOnNoLive()
|
||||
this.updatePlayerOnNoLive({ hasPlayed: true })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import {
|
|||
LiveVideo,
|
||||
LiveVideoCreate,
|
||||
LiveVideoUpdate,
|
||||
NSFWFlag,
|
||||
VideoCaption,
|
||||
VideoChapter,
|
||||
VideoCreate,
|
||||
|
@ -31,12 +32,19 @@ const debugLogger = debug('peertube:video-manage:video-edit')
|
|||
export type VideoEditPrivacyType = VideoPrivacyType | typeof VideoEdit.SPECIAL_SCHEDULED_PRIVACY
|
||||
|
||||
type CommonUpdateForm =
|
||||
& Omit<VideoUpdate, 'privacy' | 'videoPasswords' | 'thumbnailfile' | 'scheduleUpdate' | 'commentsEnabled' | 'originallyPublishedAt'>
|
||||
& Omit<
|
||||
VideoUpdate,
|
||||
'privacy' | 'videoPasswords' | 'thumbnailfile' | 'scheduleUpdate' | 'commentsEnabled' | 'originallyPublishedAt' | 'nsfwFlags'
|
||||
>
|
||||
& {
|
||||
schedulePublicationAt?: Date
|
||||
originallyPublishedAt?: Date
|
||||
privacy?: VideoEditPrivacyType
|
||||
videoPassword?: string
|
||||
|
||||
nsfwFlagViolent?: boolean
|
||||
nsfwFlagSex?: boolean
|
||||
nsfwFlagShocking?: boolean
|
||||
}
|
||||
|
||||
type LiveUpdateForm = Omit<LiveVideoUpdate, 'replaySettings'> & {
|
||||
|
@ -82,6 +90,8 @@ type UpdateFromAPIOptions = {
|
|||
| 'description'
|
||||
| 'tags'
|
||||
| 'nsfw'
|
||||
| 'nsfwFlags'
|
||||
| 'nsfwSummary'
|
||||
| 'waitTranscoding'
|
||||
| 'support'
|
||||
| 'commentsPolicy'
|
||||
|
@ -317,6 +327,8 @@ export class VideoEdit {
|
|||
description: video.description ?? '',
|
||||
tags: video.tags ?? [],
|
||||
nsfw: video.nsfw ?? null,
|
||||
nsfwSummary: video.nsfwSummary ?? null,
|
||||
nsfwFlags: video.nsfwFlags ?? NSFWFlag.NONE,
|
||||
waitTranscoding: video.waitTranscoding ?? null,
|
||||
support: video.support ?? '',
|
||||
commentsPolicy: video.commentsPolicy?.id ?? null,
|
||||
|
@ -430,7 +442,6 @@ export class VideoEdit {
|
|||
if (values.language !== undefined) this.common.language = values.language
|
||||
if (values.description !== undefined) this.common.description = values.description
|
||||
if (values.tags !== undefined) this.common.tags = values.tags
|
||||
if (values.nsfw !== undefined) this.common.nsfw = values.nsfw
|
||||
if (values.waitTranscoding !== undefined) this.common.waitTranscoding = values.waitTranscoding
|
||||
if (values.support !== undefined) this.common.support = values.support
|
||||
if (values.commentsPolicy !== undefined) this.common.commentsPolicy = values.commentsPolicy
|
||||
|
@ -438,6 +449,41 @@ export class VideoEdit {
|
|||
if (values.previewfile !== undefined) this.common.previewfile = values.previewfile
|
||||
if (values.pluginData !== undefined) this.common.pluginData = values.pluginData
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NSFW
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (values.nsfw !== undefined) this.common.nsfw = values.nsfw
|
||||
|
||||
if (this.common.nsfw) {
|
||||
if (values.nsfwFlagSex !== undefined) {
|
||||
this.common.nsfwFlags = values.nsfwFlagSex
|
||||
? this.common.nsfwFlags | NSFWFlag.EXPLICIT_SEX
|
||||
: this.common.nsfwFlags & ~NSFWFlag.EXPLICIT_SEX
|
||||
}
|
||||
|
||||
if (values.nsfwFlagShocking !== undefined) {
|
||||
this.common.nsfwFlags = values.nsfwFlagShocking
|
||||
? this.common.nsfwFlags | NSFWFlag.SHOCKING_DISTURBING
|
||||
: this.common.nsfwFlags & ~NSFWFlag.SHOCKING_DISTURBING
|
||||
}
|
||||
|
||||
if (values.nsfwFlagViolent !== undefined) {
|
||||
this.common.nsfwFlags = values.nsfwFlagViolent
|
||||
? this.common.nsfwFlags | NSFWFlag.VIOLENT
|
||||
: this.common.nsfwFlags & ~NSFWFlag.VIOLENT
|
||||
}
|
||||
|
||||
if (values.nsfwSummary !== undefined) {
|
||||
this.common.nsfwSummary = values.nsfwSummary
|
||||
}
|
||||
} else {
|
||||
this.common.nsfwSummary = null
|
||||
this.common.nsfwFlags = NSFWFlag.NONE
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (values.videoPassword !== undefined) {
|
||||
this.common.videoPasswords = values.privacy === VideoPrivacy.PASSWORD_PROTECTED && values.videoPassword
|
||||
? [ values.videoPassword ]
|
||||
|
@ -483,7 +529,13 @@ export class VideoEdit {
|
|||
support: this.common.support,
|
||||
name: this.common.name,
|
||||
tags: this.common.tags,
|
||||
|
||||
nsfw: this.common.nsfw,
|
||||
nsfwFlagSex: (this.common.nsfwFlags & NSFWFlag.EXPLICIT_SEX) === NSFWFlag.EXPLICIT_SEX,
|
||||
nsfwFlagShocking: (this.common.nsfwFlags & NSFWFlag.SHOCKING_DISTURBING) === NSFWFlag.SHOCKING_DISTURBING,
|
||||
nsfwFlagViolent: (this.common.nsfwFlags & NSFWFlag.VIOLENT) === NSFWFlag.VIOLENT,
|
||||
nsfwSummary: this.common.nsfwSummary,
|
||||
|
||||
commentsPolicy: this.common.commentsPolicy,
|
||||
waitTranscoding: this.common.waitTranscoding,
|
||||
channelId: this.common.channelId,
|
||||
|
@ -550,6 +602,8 @@ export class VideoEdit {
|
|||
|
||||
tags: this.common.tags,
|
||||
nsfw: this.common.nsfw,
|
||||
nsfwFlags: this.common.nsfwFlags,
|
||||
nsfwSummary: this.common.nsfwSummary || null,
|
||||
waitTranscoding: this.common.waitTranscoding,
|
||||
commentsPolicy: this.common.commentsPolicy,
|
||||
downloadEnabled: this.common.downloadEnabled,
|
||||
|
|
|
@ -9,27 +9,54 @@
|
|||
<div class="form-columns">
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label i18n for="commentsPolicy">Comments policy</label>
|
||||
|
||||
<div class="form-group-description" i18n>
|
||||
You can require comments to be approved depending on <a routerLink="/my-account/auto-tag-policies" target="_blank">your auto-tags policies</a>
|
||||
</div>
|
||||
|
||||
<my-select-options inputId="commentsPolicy" [items]="commentPolicies" formControlName="commentsPolicy"></my-select-options>
|
||||
|
||||
<div *ngIf="formErrors.commentsPolicy" class="form-error" role="alert">
|
||||
{{ formErrors.commentsPolicy }}
|
||||
</div>
|
||||
<my-select-radio
|
||||
i18n-label label="Comments policy"
|
||||
[items]="commentPolicies"
|
||||
inputId="commentsPolicy"
|
||||
formControlName="commentsPolicy"
|
||||
>
|
||||
<div class="form-group-description" i18n>
|
||||
You can require comments to be approved depending on <a routerLink="/my-account/auto-tag-policies" target="_blank">your auto-tags policies</a>
|
||||
</div>
|
||||
</my-select-radio>
|
||||
</div>
|
||||
|
||||
<my-peertube-checkbox inputName="nsfw" formControlName="nsfw" helpPlacement="bottom-right">
|
||||
<my-peertube-checkbox inputName="nsfw" formControlName="nsfw">
|
||||
<ng-template ptTemplate="label">
|
||||
<ng-container i18n>Contains sensitive content</ng-container>
|
||||
<ng-container i18n>Your video contains sensitive content</ng-container>
|
||||
</ng-template>
|
||||
|
||||
<ng-template ptTemplate="help">
|
||||
<ng-container ngProjectAs="description">
|
||||
<ng-container i18n>Some instances hide videos containing mature or explicit content by default.</ng-container>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
||||
<ng-container ngProjectAs="extra">
|
||||
<my-peertube-checkbox inputName="nsfwFlagViolent" formControlName="nsfwFlagViolent">
|
||||
<ng-template ptTemplate="label">
|
||||
<ng-container i18n>Some images may be violent</ng-container>
|
||||
</ng-template>
|
||||
</my-peertube-checkbox>
|
||||
|
||||
<my-peertube-checkbox inputName="nsfwFlagShocking" formControlName="nsfwFlagShocking">
|
||||
<ng-template ptTemplate="label">
|
||||
<ng-container i18n>Parts of the video may shock or disturb</ng-container>
|
||||
</ng-template>
|
||||
</my-peertube-checkbox>
|
||||
|
||||
<my-peertube-checkbox inputName="nsfwFlagSex" formControlName="nsfwFlagSex">
|
||||
<ng-template ptTemplate="label">
|
||||
<ng-container i18n>Content may be perceived as sexually explicit material</ng-container>
|
||||
</ng-template>
|
||||
</my-peertube-checkbox>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="nsfwSummary">Describe what makes your content sensitive</label>
|
||||
<input type="text" id="nsfwSummary" class="form-control" formControlName="nsfwSummary" [ngClass]="{ 'input-error': formErrors.nsfwSummary }" />
|
||||
|
||||
<div *ngIf="formErrors.nsfwSummary" class="form-error" role="alert">{{ formErrors.nsfwSummary }}</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,16 +1,17 @@
|
|||
import { NgIf } from '@angular/common'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
|
||||
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { ServerService } from '@app/core'
|
||||
import { BuildFormArgument } from '@app/shared/form-validators/form-validator.model'
|
||||
import { VIDEO_NSFW_SUMMARY_VALIDATOR } from '@app/shared/form-validators/video-validators'
|
||||
import { FormReactiveErrors, FormReactiveService, FormReactiveValidationMessages } from '@app/shared/shared-forms/form-reactive.service'
|
||||
import { HTMLServerConfig, VideoCommentPolicyType, VideoConstant } from '@peertube/peertube-models'
|
||||
import debug from 'debug'
|
||||
import { CalendarModule } from 'primeng/calendar'
|
||||
import { Subscription } from 'rxjs'
|
||||
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
|
||||
import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
|
||||
import { SelectRadioComponent } from '../../../shared/shared-forms/select/select-radio.component'
|
||||
import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component'
|
||||
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/common/peertube-template.directive'
|
||||
import { VideoManageController } from '../video-manage-controller.service'
|
||||
|
@ -19,6 +20,12 @@ const debugLogger = debug('peertube:video-manage')
|
|||
|
||||
type Form = {
|
||||
nsfw: FormControl<boolean>
|
||||
|
||||
nsfwFlagViolent: FormControl<boolean>
|
||||
nsfwFlagShocking: FormControl<boolean>
|
||||
nsfwFlagSex: FormControl<boolean>
|
||||
nsfwSummary: FormControl<string>
|
||||
|
||||
commentPolicies: FormControl<VideoCommentPolicyType>
|
||||
}
|
||||
|
||||
|
@ -29,15 +36,15 @@ type Form = {
|
|||
],
|
||||
templateUrl: './video-moderation.component.html',
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterLink,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgIf,
|
||||
PeerTubeTemplateDirective,
|
||||
SelectOptionsComponent,
|
||||
CalendarModule,
|
||||
PeertubeCheckboxComponent,
|
||||
GlobalIconComponent
|
||||
GlobalIconComponent,
|
||||
SelectRadioComponent
|
||||
]
|
||||
})
|
||||
export class VideoModerationComponent implements OnInit, OnDestroy {
|
||||
|
@ -71,7 +78,14 @@ export class VideoModerationComponent implements OnInit, OnDestroy {
|
|||
const videoEdit = this.manageController.getStore().videoEdit
|
||||
|
||||
const defaultValues = videoEdit.toCommonFormPatch()
|
||||
const obj: BuildFormArgument = { nsfw: null, commentsPolicy: null }
|
||||
const obj: BuildFormArgument = {
|
||||
commentsPolicy: null,
|
||||
nsfw: null,
|
||||
nsfwFlagViolent: null,
|
||||
nsfwFlagShocking: null,
|
||||
nsfwFlagSex: null,
|
||||
nsfwSummary: VIDEO_NSFW_SUMMARY_VALIDATOR
|
||||
}
|
||||
|
||||
const {
|
||||
form,
|
||||
|
@ -97,5 +111,35 @@ export class VideoModerationComponent implements OnInit, OnDestroy {
|
|||
this.updatedSub = this.manageController.getUpdatedObs().subscribe(() => {
|
||||
this.form.patchValue(videoEdit.toCommonFormPatch())
|
||||
})
|
||||
|
||||
this.updateNSFWControls(videoEdit.toCommonFormPatch().nsfw)
|
||||
this.trackNSFWChange()
|
||||
}
|
||||
|
||||
private trackNSFWChange () {
|
||||
this.form.controls.nsfw
|
||||
.valueChanges
|
||||
.subscribe(newNSFW => this.updateNSFWControls(newNSFW))
|
||||
}
|
||||
|
||||
private updateNSFWControls (nsfw: boolean) {
|
||||
const controls = [
|
||||
this.form.controls.nsfwFlagViolent,
|
||||
this.form.controls.nsfwFlagShocking,
|
||||
this.form.controls.nsfwFlagSex,
|
||||
this.form.controls.nsfwSummary
|
||||
]
|
||||
|
||||
if (!nsfw) {
|
||||
for (const control of controls) {
|
||||
control.disable()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for (const control of controls) {
|
||||
control.enable()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
import { Injectable, inject } from '@angular/core'
|
||||
import { sortBy } from '@peertube/peertube-core-utils'
|
||||
import { HTMLServerConfig, ServerConfig, ServerConfigTheme } from '@peertube/peertube-models'
|
||||
import { logger } from '@root-helpers/logger'
|
||||
import { capitalizeFirstLetter } from '@root-helpers/string'
|
||||
import { ThemeManager } from '@root-helpers/theme-manager'
|
||||
import { UserLocalStorageKeys } from '@root-helpers/users'
|
||||
import { getLuminance, parse, toHSLA } from 'color-bits'
|
||||
import debug from 'debug'
|
||||
import { environment } from '../../../environments/environment'
|
||||
import { AuthService } from '../auth'
|
||||
import { PluginService } from '../plugins/plugin.service'
|
||||
|
@ -13,8 +11,6 @@ import { ServerService } from '../server'
|
|||
import { UserService } from '../users/user.service'
|
||||
import { LocalStorageService } from '../wrappers/storage.service'
|
||||
|
||||
const debugLogger = debug('peertube:theme')
|
||||
|
||||
@Injectable()
|
||||
export class ThemeService {
|
||||
private auth = inject(AuthService)
|
||||
|
@ -23,7 +19,6 @@ export class ThemeService {
|
|||
private server = inject(ServerService)
|
||||
private localStorageService = inject(LocalStorageService)
|
||||
|
||||
private oldInjectedProperties: string[] = []
|
||||
private oldThemeName: string
|
||||
|
||||
private internalThemes: string[] = []
|
||||
|
@ -34,6 +29,8 @@ export class ThemeService {
|
|||
|
||||
private serverConfig: HTMLServerConfig
|
||||
|
||||
private themeManager = new ThemeManager()
|
||||
|
||||
initialize () {
|
||||
this.serverConfig = this.server.getHTMLConfig()
|
||||
this.internalThemes = this.serverConfig.theme.builtIn.map(t => t.name)
|
||||
|
@ -80,30 +77,19 @@ export class ThemeService {
|
|||
|
||||
logger.info(`Injecting ${this.themes.length} themes.`)
|
||||
|
||||
const head = this.getHeadElement()
|
||||
|
||||
for (const theme of this.themes) {
|
||||
// Already added this theme?
|
||||
if (fromLocalStorage === false && this.themeFromLocalStorage && this.themeFromLocalStorage.name === theme.name) continue
|
||||
|
||||
for (const css of theme.css) {
|
||||
const link = document.createElement('link')
|
||||
const links = this.themeManager.injectTheme(theme, environment.apiUrl)
|
||||
|
||||
const href = environment.apiUrl + `/themes/${theme.name}/${theme.version}/css/${css}`
|
||||
link.setAttribute('href', href)
|
||||
link.setAttribute('rel', 'alternate stylesheet')
|
||||
link.setAttribute('type', 'text/css')
|
||||
link.setAttribute('title', theme.name)
|
||||
link.setAttribute('disabled', '')
|
||||
|
||||
if (fromLocalStorage === true) this.themeDOMLinksFromLocalStorage.push(link)
|
||||
|
||||
head.appendChild(link)
|
||||
if (fromLocalStorage === true) {
|
||||
this.themeDOMLinksFromLocalStorage = [ ...this.themeDOMLinksFromLocalStorage, ...links ]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getCurrentTheme () {
|
||||
private getCurrentThemeName () {
|
||||
if (this.themeFromLocalStorage) return this.themeFromLocalStorage.name
|
||||
|
||||
const theme = this.auth.isLoggedIn()
|
||||
|
@ -123,43 +109,24 @@ export class ThemeService {
|
|||
return instanceTheme
|
||||
}
|
||||
|
||||
private loadThemeStyle (name: string) {
|
||||
const links = document.getElementsByTagName('link')
|
||||
|
||||
for (let i = 0; i < links.length; i++) {
|
||||
const link = links[i]
|
||||
if (link.getAttribute('rel').includes('style') && link.getAttribute('title')) {
|
||||
link.disabled = link.getAttribute('title') !== name
|
||||
|
||||
if (!link.disabled) {
|
||||
link.onload = () => this.injectColorPalette()
|
||||
} else {
|
||||
link.onload = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.body.dataset.ptTheme = name
|
||||
}
|
||||
|
||||
private updateCurrentTheme () {
|
||||
const currentTheme = this.getCurrentTheme()
|
||||
if (this.oldThemeName === currentTheme) return
|
||||
const currentThemeName = this.getCurrentThemeName()
|
||||
if (this.oldThemeName === currentThemeName) return
|
||||
|
||||
if (this.oldThemeName) this.removeThemePlugins(this.oldThemeName)
|
||||
|
||||
logger.info(`Enabling ${currentTheme} theme.`)
|
||||
logger.info(`Enabling ${currentThemeName} theme.`)
|
||||
|
||||
this.loadThemeStyle(currentTheme)
|
||||
this.themeManager.loadThemeStyle(currentThemeName)
|
||||
|
||||
const theme = this.getTheme(currentTheme)
|
||||
const theme = this.getTheme(currentThemeName)
|
||||
|
||||
if (this.internalThemes.includes(currentTheme)) {
|
||||
logger.info(`Enabling internal theme ${currentTheme}`)
|
||||
if (this.internalThemes.includes(currentThemeName)) {
|
||||
logger.info(`Enabling internal theme ${currentThemeName}`)
|
||||
|
||||
this.localStorageService.setItem(UserLocalStorageKeys.LAST_ACTIVE_THEME, JSON.stringify({ name: currentTheme }), false)
|
||||
this.localStorageService.setItem(UserLocalStorageKeys.LAST_ACTIVE_THEME, JSON.stringify({ name: currentThemeName }), false)
|
||||
} else if (theme) {
|
||||
logger.info(`Adding scripts of theme ${currentTheme}`)
|
||||
logger.info(`Adding scripts of theme ${currentThemeName}`)
|
||||
|
||||
this.pluginService.addPlugin(theme, true)
|
||||
|
||||
|
@ -170,161 +137,9 @@ export class ThemeService {
|
|||
this.localStorageService.removeItem(UserLocalStorageKeys.LAST_ACTIVE_THEME, false)
|
||||
}
|
||||
|
||||
this.injectCoreColorPalette()
|
||||
this.themeManager.injectCoreColorPalette()
|
||||
|
||||
this.oldThemeName = currentTheme
|
||||
}
|
||||
|
||||
private injectCoreColorPalette (iteration = 0) {
|
||||
if (iteration > 10) {
|
||||
logger.error('Cannot inject core color palette: too many iterations')
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.canInjectCoreColorPalette()) {
|
||||
return setTimeout(() => this.injectCoreColorPalette(iteration + 1))
|
||||
}
|
||||
|
||||
return this.injectColorPalette()
|
||||
}
|
||||
|
||||
private canInjectCoreColorPalette () {
|
||||
const computedStyle = getComputedStyle(document.body)
|
||||
const isDark = computedStyle.getPropertyValue('--is-dark')
|
||||
|
||||
return isDark === '0' || isDark === '1'
|
||||
}
|
||||
|
||||
private injectColorPalette () {
|
||||
console.log(`Injecting color palette`)
|
||||
|
||||
const rootStyle = document.body.style
|
||||
const computedStyle = getComputedStyle(document.body)
|
||||
|
||||
// FIXME: Remove previously injected properties
|
||||
for (const property of this.oldInjectedProperties) {
|
||||
rootStyle.removeProperty(property)
|
||||
}
|
||||
|
||||
this.oldInjectedProperties = []
|
||||
|
||||
const isGlobalDarkTheme = () => {
|
||||
return this.isDarkTheme({
|
||||
fg: computedStyle.getPropertyValue('--fg') || computedStyle.getPropertyValue('--mainForegroundColor'),
|
||||
bg: computedStyle.getPropertyValue('--bg') || computedStyle.getPropertyValue('--mainBackgroundColor'),
|
||||
isDarkVar: computedStyle.getPropertyValue('--is-dark')
|
||||
})
|
||||
}
|
||||
|
||||
const isMenuDarkTheme = () => {
|
||||
return this.isDarkTheme({
|
||||
fg: computedStyle.getPropertyValue('--menu-fg'),
|
||||
bg: computedStyle.getPropertyValue('--menu-bg'),
|
||||
isDarkVar: computedStyle.getPropertyValue('--is-menu-dark')
|
||||
})
|
||||
}
|
||||
|
||||
const toProcess = [
|
||||
{ prefix: 'primary', invertIfDark: true, step: 5, darkTheme: isGlobalDarkTheme },
|
||||
{ prefix: 'on-primary', invertIfDark: true, step: 5, darkTheme: isGlobalDarkTheme },
|
||||
{ prefix: 'bg-secondary', invertIfDark: true, step: 5, darkTheme: isGlobalDarkTheme },
|
||||
{ prefix: 'fg', invertIfDark: true, fallbacks: { '--fg-300': '--greyForegroundColor' }, step: 5, darkTheme: isGlobalDarkTheme },
|
||||
|
||||
{ prefix: 'input-bg', invertIfDark: true, step: 5, darkTheme: isGlobalDarkTheme },
|
||||
|
||||
{ prefix: 'menu-fg', invertIfDark: true, step: 5, darkTheme: isMenuDarkTheme },
|
||||
{ prefix: 'menu-bg', invertIfDark: true, step: 5, darkTheme: isMenuDarkTheme }
|
||||
] as { prefix: string, invertIfDark: boolean, step: number, darkTheme: () => boolean, fallbacks?: Record<string, string> }[]
|
||||
|
||||
for (const { prefix, invertIfDark, step, darkTheme, fallbacks = {} } of toProcess) {
|
||||
const mainColor = computedStyle.getPropertyValue('--' + prefix)
|
||||
|
||||
const darkInverter = invertIfDark && darkTheme()
|
||||
? -1
|
||||
: 1
|
||||
|
||||
if (!mainColor) {
|
||||
console.error(`Cannot create palette of nonexistent "--${prefix}" CSS body variable`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Use trim for some web browsers: https://github.com/Chocobozzz/PeerTube/issues/6952
|
||||
const mainColorHSL = toHSLA(parse(mainColor.trim()))
|
||||
debugLogger(`Theme main variable ${mainColor} -> ${this.toHSLStr(mainColorHSL)}`)
|
||||
|
||||
// Inject in alphabetical order for easy debug
|
||||
const toInject: { id: number, key: string, value: string }[] = [
|
||||
{ id: 500, key: `--${prefix}-500`, value: this.toHSLStr(mainColorHSL) }
|
||||
]
|
||||
|
||||
for (const j of [ -1, 1 ]) {
|
||||
let lastColorHSL = { ...mainColorHSL }
|
||||
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
const suffix = 500 + (50 * i * j)
|
||||
const key = `--${prefix}-${suffix}`
|
||||
|
||||
const existingValue = computedStyle.getPropertyValue(key)
|
||||
if (!existingValue || existingValue === '0') {
|
||||
const newLuminance = this.buildNewLuminance(lastColorHSL, j * step, darkInverter)
|
||||
const newColorHSL = { ...lastColorHSL, l: newLuminance }
|
||||
|
||||
const newColorStr = this.toHSLStr(newColorHSL)
|
||||
|
||||
const value = fallbacks[key]
|
||||
? `var(${fallbacks[key]}, ${newColorStr})`
|
||||
: newColorStr
|
||||
|
||||
toInject.push({ id: suffix, key, value })
|
||||
|
||||
lastColorHSL = newColorHSL
|
||||
|
||||
debugLogger(`Injected theme palette ${key} -> ${value}`)
|
||||
} else {
|
||||
lastColorHSL = toHSLA(parse(existingValue))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const { key, value } of sortBy(toInject, 'id')) {
|
||||
rootStyle.setProperty(key, value)
|
||||
this.oldInjectedProperties.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
document.body.dataset.bsTheme = isGlobalDarkTheme()
|
||||
? 'dark'
|
||||
: ''
|
||||
}
|
||||
|
||||
private buildNewLuminance (base: { l: number }, factor: number, darkInverter: number) {
|
||||
return Math.max(Math.min(100, Math.round(base.l + (factor * -1 * darkInverter))), 0)
|
||||
}
|
||||
|
||||
private toHSLStr (c: { h: number, s: number, l: number, a: number }) {
|
||||
return `hsl(${Math.round(c.h)} ${Math.round(c.s)}% ${Math.round(c.l)}% / ${Math.round(c.a)})`
|
||||
}
|
||||
|
||||
private isDarkTheme (options: {
|
||||
fg: string
|
||||
bg: string
|
||||
isDarkVar: string
|
||||
}) {
|
||||
const { fg, bg, isDarkVar } = options
|
||||
|
||||
if (isDarkVar === '1') {
|
||||
return true
|
||||
} else if (fg && bg) {
|
||||
try {
|
||||
if (getLuminance(parse(bg)) < getLuminance(parse(fg))) {
|
||||
return true
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Cannot parse deprecated CSS variables', err)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
this.oldThemeName = currentThemeName
|
||||
}
|
||||
|
||||
private listenUserTheme () {
|
||||
|
@ -381,9 +196,8 @@ export class ThemeService {
|
|||
this.removeThemePlugins(this.themeFromLocalStorage.name)
|
||||
this.oldThemeName = undefined
|
||||
|
||||
const head = this.getHeadElement()
|
||||
for (const htmlLinkElement of this.themeDOMLinksFromLocalStorage) {
|
||||
head.removeChild(htmlLinkElement)
|
||||
this.themeManager.removeThemeLink(htmlLinkElement)
|
||||
}
|
||||
|
||||
this.themeFromLocalStorage = undefined
|
||||
|
@ -391,10 +205,6 @@ export class ThemeService {
|
|||
}
|
||||
}
|
||||
|
||||
private getHeadElement () {
|
||||
return document.getElementsByTagName('head')[0]
|
||||
}
|
||||
|
||||
private getTheme (name: string) {
|
||||
return this.themes.find(t => t.name === name)
|
||||
}
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { filter, throttleTime } from 'rxjs'
|
||||
import { Injectable, inject } from '@angular/core'
|
||||
import { AuthService, AuthStatus } from '@app/core/auth'
|
||||
import { objectKeysTyped } from '@peertube/peertube-core-utils'
|
||||
import { NSFWPolicyType, UserRoleType, UserUpdateMe } from '@peertube/peertube-models'
|
||||
import { getBoolOrDefault } from '@root-helpers/local-storage-utils'
|
||||
import { getBoolOrDefault, getNumberOrDefault } from '@root-helpers/local-storage-utils'
|
||||
import { logger } from '@root-helpers/logger'
|
||||
import { OAuthUserTokens, UserLocalStorageKeys } from '@root-helpers/users'
|
||||
import { filter, throttleTime } from 'rxjs'
|
||||
import { ServerService } from '../server'
|
||||
import { LocalStorageService } from '../wrappers/storage.service'
|
||||
|
||||
|
@ -110,6 +110,11 @@ export class UserLocalStorageService {
|
|||
|
||||
return {
|
||||
nsfwPolicy: this.localStorageService.getItem<NSFWPolicyType>(UserLocalStorageKeys.NSFW_POLICY) || defaultNSFWPolicy,
|
||||
nsfwFlagsDisplayed: getNumberOrDefault(this.localStorageService.getItem(UserLocalStorageKeys.NSFW_FLAGS_DISPLAYED), undefined),
|
||||
nsfwFlagsWarned: getNumberOrDefault(this.localStorageService.getItem(UserLocalStorageKeys.NSFW_FLAGS_WARNED), undefined),
|
||||
nsfwFlagsBlurred: getNumberOrDefault(this.localStorageService.getItem(UserLocalStorageKeys.NSFW_FLAGS_BLURRED), undefined),
|
||||
nsfwFlagsHidden: getNumberOrDefault(this.localStorageService.getItem(UserLocalStorageKeys.NSFW_FLAGS_HIDDEN), undefined),
|
||||
|
||||
p2pEnabled: getBoolOrDefault(this.localStorageService.getItem(UserLocalStorageKeys.P2P_ENABLED), defaultP2PEnabled),
|
||||
theme: this.localStorageService.getItem(UserLocalStorageKeys.THEME) || 'instance-default',
|
||||
videoLanguages,
|
||||
|
@ -123,6 +128,10 @@ export class UserLocalStorageService {
|
|||
setUserInfo (profile: UserUpdateMe) {
|
||||
const localStorageKeys = {
|
||||
nsfwPolicy: UserLocalStorageKeys.NSFW_POLICY,
|
||||
nsfwFlagsDisplayed: UserLocalStorageKeys.NSFW_FLAGS_DISPLAYED,
|
||||
nsfwFlagsHidden: UserLocalStorageKeys.NSFW_FLAGS_HIDDEN,
|
||||
nsfwFlagsWarned: UserLocalStorageKeys.NSFW_FLAGS_WARNED,
|
||||
nsfwFlagsBlurred: UserLocalStorageKeys.NSFW_FLAGS_BLURRED,
|
||||
p2pEnabled: UserLocalStorageKeys.P2P_ENABLED,
|
||||
autoPlayVideo: UserLocalStorageKeys.AUTO_PLAY_VIDEO,
|
||||
autoPlayNextVideo: UserLocalStorageKeys.AUTO_PLAY_NEXT_VIDEO,
|
||||
|
@ -131,7 +140,7 @@ export class UserLocalStorageService {
|
|||
videoLanguages: UserLocalStorageKeys.VIDEO_LANGUAGES
|
||||
}
|
||||
|
||||
const obj: [string, string | boolean | string[]][] = objectKeysTyped(localStorageKeys)
|
||||
const obj: [string, string | boolean | number | string[]][] = objectKeysTyped(localStorageKeys)
|
||||
.filter(key => key in profile)
|
||||
.map(key => [ localStorageKeys[key], profile[key] ])
|
||||
|
||||
|
@ -155,6 +164,10 @@ export class UserLocalStorageService {
|
|||
|
||||
flushUserInfo () {
|
||||
this.localStorageService.removeItem(UserLocalStorageKeys.NSFW_POLICY)
|
||||
this.localStorageService.removeItem(UserLocalStorageKeys.NSFW_FLAGS_DISPLAYED)
|
||||
this.localStorageService.removeItem(UserLocalStorageKeys.NSFW_FLAGS_WARNED)
|
||||
this.localStorageService.removeItem(UserLocalStorageKeys.NSFW_FLAGS_BLURRED)
|
||||
this.localStorageService.removeItem(UserLocalStorageKeys.NSFW_FLAGS_HIDDEN)
|
||||
this.localStorageService.removeItem(UserLocalStorageKeys.P2P_ENABLED)
|
||||
this.localStorageService.removeItem(UserLocalStorageKeys.AUTO_PLAY_VIDEO)
|
||||
this.localStorageService.removeItem(UserLocalStorageKeys.AUTO_PLAY_VIDEO_PLAYLIST)
|
||||
|
@ -165,6 +178,10 @@ export class UserLocalStorageService {
|
|||
listenUserInfoChange () {
|
||||
return this.localStorageService.watch([
|
||||
UserLocalStorageKeys.NSFW_POLICY,
|
||||
UserLocalStorageKeys.NSFW_FLAGS_DISPLAYED,
|
||||
UserLocalStorageKeys.NSFW_FLAGS_WARNED,
|
||||
UserLocalStorageKeys.NSFW_FLAGS_BLURRED,
|
||||
UserLocalStorageKeys.NSFW_FLAGS_HIDDEN,
|
||||
UserLocalStorageKeys.P2P_ENABLED,
|
||||
UserLocalStorageKeys.AUTO_PLAY_VIDEO,
|
||||
UserLocalStorageKeys.AUTO_PLAY_NEXT_VIDEO,
|
||||
|
|
|
@ -22,7 +22,12 @@ export class User implements UserServerModel {
|
|||
|
||||
emailVerified: boolean
|
||||
emailPublic: boolean
|
||||
|
||||
nsfwPolicy: NSFWPolicyType
|
||||
nsfwFlagsDisplayed: number
|
||||
nsfwFlagsHidden: number
|
||||
nsfwFlagsWarned: number
|
||||
nsfwFlagsBlurred: number
|
||||
|
||||
adminFlags?: UserAdminFlagType
|
||||
|
||||
|
@ -89,7 +94,7 @@ export class User implements UserServerModel {
|
|||
|
||||
patch (obj: UserServerModel) {
|
||||
for (const key of objectKeysTyped(obj)) {
|
||||
(this as any)[key] = obj[key]
|
||||
;(this as any)[key] = obj[key]
|
||||
}
|
||||
|
||||
if (obj.account !== undefined) {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { CommonModule } from '@angular/common'
|
||||
import { Component, OnDestroy, OnInit, inject, output, viewChild } from '@angular/core'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { AuthService, AuthStatus, LocalStorageService, User, UserService } from '@app/core'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { AuthService, AuthStatus, LocalStorageService, PeerTubeRouterService, User, UserService } from '@app/core'
|
||||
import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
|
||||
import { ButtonComponent } from '@app/shared/shared-main/buttons/button.component'
|
||||
import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
|
||||
|
@ -30,7 +30,7 @@ export class QuickSettingsModalComponent implements OnInit, OnDestroy {
|
|||
private authService = inject(AuthService)
|
||||
private localStorageService = inject(LocalStorageService)
|
||||
private route = inject(ActivatedRoute)
|
||||
private router = inject(Router)
|
||||
private peertubeRouter = inject(PeerTubeRouterService)
|
||||
|
||||
private static readonly QUERY_MODAL_NAME = 'quick-settings'
|
||||
|
||||
|
@ -99,6 +99,6 @@ export class QuickSettingsModalComponent implements OnInit, OnDestroy {
|
|||
? QuickSettingsModalComponent.QUERY_MODAL_NAME
|
||||
: null
|
||||
|
||||
this.router.navigate([], { queryParams: { modal }, queryParamsHandling: 'merge' })
|
||||
this.peertubeRouter.silentNavigate([], { modal }, this.route)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -86,6 +86,14 @@ export const VIDEO_SUPPORT_VALIDATOR: BuildFormValidator = {
|
|||
}
|
||||
}
|
||||
|
||||
export const VIDEO_NSFW_SUMMARY_VALIDATOR: BuildFormValidator = {
|
||||
VALIDATORS: [ Validators.minLength(3), Validators.maxLength(250) ],
|
||||
MESSAGES: {
|
||||
minlength: $localize`Video support must be at least 3 characters long.`,
|
||||
maxlength: $localize`Video support cannot be more than 250 characters long.`
|
||||
}
|
||||
}
|
||||
|
||||
export const VIDEO_SCHEDULE_PUBLICATION_AT_VALIDATOR: BuildFormValidator = {
|
||||
VALIDATORS: [], // Required is set dynamically
|
||||
MESSAGES: {
|
||||
|
|
|
@ -92,7 +92,7 @@ export class ChannelMiniatureMarkupComponent implements CustomMarkupComponent, O
|
|||
.pipe(
|
||||
map(user => user.nsfwPolicy),
|
||||
switchMap(nsfwPolicy => {
|
||||
return this.videoService.getVideoChannelVideos({ ...videoOptions, nsfw: this.videoService.nsfwPolicyToParam(nsfwPolicy) })
|
||||
return this.videoService.listChannelVideos({ ...videoOptions, nsfw: this.videoService.nsfwPolicyToParam(nsfwPolicy) })
|
||||
})
|
||||
)
|
||||
}
|
||||
|
|
|
@ -35,9 +35,7 @@ export class PlaylistMiniatureMarkupComponent implements CustomMarkupComponent,
|
|||
views: true,
|
||||
by: true,
|
||||
avatar: true,
|
||||
privacyLabel: false,
|
||||
privacyText: false,
|
||||
blacklistInfo: false
|
||||
privacyLabel: false
|
||||
}
|
||||
|
||||
ngOnInit () {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<my-video-miniature
|
||||
*ngIf="video()"
|
||||
[video]="video()" [user]="getUser()" [displayAsRow]="false"
|
||||
[video]="video()" [user]="user" [displayAsRow]="false"
|
||||
[displayVideoActions]="true" [displayOptions]="displayOptions"
|
||||
>
|
||||
</my-video-miniature>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { NgIf } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, inject, input, model, output } from '@angular/core'
|
||||
import { AuthService, Notifier } from '@app/core'
|
||||
import { AuthService, Notifier, User, UserService } from '@app/core'
|
||||
import { Video } from '@app/shared/shared-main/video/video.model'
|
||||
import { FindInBulkService } from '@app/shared/shared-search/find-in-bulk.service'
|
||||
import { objectKeysTyped } from '@peertube/peertube-core-utils'
|
||||
|
@ -23,6 +23,7 @@ export class VideoMiniatureMarkupComponent implements CustomMarkupComponent, OnI
|
|||
private auth = inject(AuthService)
|
||||
private findInBulk = inject(FindInBulkService)
|
||||
private notifier = inject(Notifier)
|
||||
private userService = inject(UserService)
|
||||
private cd = inject(ChangeDetectorRef)
|
||||
|
||||
readonly uuid = input<string>(undefined)
|
||||
|
@ -36,14 +37,10 @@ export class VideoMiniatureMarkupComponent implements CustomMarkupComponent, OnI
|
|||
views: true,
|
||||
by: true,
|
||||
avatar: true,
|
||||
privacyLabel: false,
|
||||
privacyText: false,
|
||||
blacklistInfo: false
|
||||
privacyLabel: false
|
||||
}
|
||||
|
||||
getUser () {
|
||||
return this.auth.getUser()
|
||||
}
|
||||
user: User
|
||||
|
||||
ngOnInit () {
|
||||
if (this.onlyDisplayTitle()) {
|
||||
|
@ -52,6 +49,9 @@ export class VideoMiniatureMarkupComponent implements CustomMarkupComponent, OnI
|
|||
}
|
||||
}
|
||||
|
||||
this.userService.getAnonymousOrLoggedUser()
|
||||
.subscribe(user => this.user = user)
|
||||
|
||||
if (this.video()) return
|
||||
|
||||
this.findInBulk.getVideo(this.uuid())
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
<div class="video-wrapper" *ngFor="let video of videos">
|
||||
<my-video-miniature
|
||||
[video]="video" [user]="getUser()" [displayAsRow]="false"
|
||||
[video]="video" [user]="user" [displayAsRow]="false"
|
||||
[displayVideoActions]="true" [displayOptions]="displayOptions"
|
||||
>
|
||||
</my-video-miniature>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { NgFor, NgStyle } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, inject, input, output } from '@angular/core'
|
||||
import { AuthService, Notifier } from '@app/core'
|
||||
import { AuthService, Notifier, User, UserService } from '@app/core'
|
||||
import { Video } from '@app/shared/shared-main/video/video.model'
|
||||
import { CommonVideoParams, VideoService } from '@app/shared/shared-main/video/video.service'
|
||||
import { objectKeysTyped } from '@peertube/peertube-core-utils'
|
||||
|
@ -25,6 +25,7 @@ export class VideosListMarkupComponent implements CustomMarkupComponent, OnInit
|
|||
private auth = inject(AuthService)
|
||||
private videoService = inject(VideoService)
|
||||
private notifier = inject(Notifier)
|
||||
private userService = inject(UserService)
|
||||
private cd = inject(ChangeDetectorRef)
|
||||
|
||||
readonly sort = input<string>(undefined)
|
||||
|
@ -42,19 +43,14 @@ export class VideosListMarkupComponent implements CustomMarkupComponent, OnInit
|
|||
readonly loaded = output<boolean>()
|
||||
|
||||
videos: Video[]
|
||||
user: User
|
||||
|
||||
displayOptions: MiniatureDisplayOptions = {
|
||||
date: false,
|
||||
views: true,
|
||||
by: true,
|
||||
avatar: true,
|
||||
privacyLabel: false,
|
||||
privacyText: false,
|
||||
blacklistInfo: false
|
||||
}
|
||||
|
||||
getUser () {
|
||||
return this.auth.getUser()
|
||||
privacyLabel: false
|
||||
}
|
||||
|
||||
limitRowsStyle () {
|
||||
|
@ -74,7 +70,10 @@ export class VideosListMarkupComponent implements CustomMarkupComponent, OnInit
|
|||
}
|
||||
}
|
||||
|
||||
return this.getVideosObservable()
|
||||
this.userService.getAnonymousOrLoggedUser()
|
||||
.subscribe(user => this.user = user)
|
||||
|
||||
this.getVideosObservable()
|
||||
.pipe(finalize(() => this.loaded.emit(true)))
|
||||
.subscribe({
|
||||
next: data => {
|
||||
|
@ -106,19 +105,19 @@ export class VideosListMarkupComponent implements CustomMarkupComponent, OnInit
|
|||
const channelHandle = this.channelHandle()
|
||||
const accountHandle = this.accountHandle()
|
||||
if (channelHandle) {
|
||||
obs = this.videoService.getVideoChannelVideos({
|
||||
obs = this.videoService.listChannelVideos({
|
||||
...options,
|
||||
|
||||
videoChannel: { nameWithHost: channelHandle }
|
||||
})
|
||||
} else if (accountHandle) {
|
||||
obs = this.videoService.getAccountVideos({
|
||||
obs = this.videoService.listAccountVideos({
|
||||
...options,
|
||||
|
||||
account: { nameWithHost: accountHandle }
|
||||
})
|
||||
} else {
|
||||
obs = this.videoService.getVideos(options)
|
||||
obs = this.videoService.listVideos(options)
|
||||
}
|
||||
|
||||
return obs.pipe(map(({ data }) => data))
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
[(ngModel)]="checked"
|
||||
(ngModelChange)="onModelChange()"
|
||||
[id]="inputName()"
|
||||
[disabled]="disabled()"
|
||||
[disabled]="disabled"
|
||||
[attr.aria-describedby]="inputName() + '-description'"
|
||||
/>
|
||||
<span></span>
|
||||
|
|
|
@ -23,9 +23,9 @@ export class PeertubeCheckboxComponent implements ControlValueAccessor, AfterCon
|
|||
readonly labelText = input<string>(undefined)
|
||||
readonly labelInnerHTML = input<string>(undefined)
|
||||
readonly helpPlacement = input('top auto')
|
||||
readonly disabled = model(false)
|
||||
readonly recommended = input(false)
|
||||
|
||||
disabled = false
|
||||
describedby: string
|
||||
|
||||
readonly templates = contentChildren(PeerTubeTemplateDirective)
|
||||
|
@ -66,6 +66,6 @@ export class PeertubeCheckboxComponent implements ControlValueAccessor, AfterCon
|
|||
}
|
||||
|
||||
setDisabledState (isDisabled: boolean) {
|
||||
this.disabled.set(isDisabled)
|
||||
this.disabled = isDisabled
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
<div role="radiogroup">
|
||||
<label *ngIf="label()" [for]="inputId()" [ngClass]="{ 'label-secondary': labelSecondary() }">{{ label() }}</label>
|
||||
|
||||
<ng-content></ng-content>
|
||||
|
||||
@if (isGroup()) {
|
||||
<div [ngClass]="{ 'btn-group': !isInMobileView(), 'btn-group-vertical': isInMobileView() }" role="group">
|
||||
@for (item of items(); track item.id) {
|
||||
<input
|
||||
type="radio" [name]="inputId()" [id]="getRadioId(item)" [value]="item.id"
|
||||
class="btn-check"
|
||||
autocomplete="off" [(ngModel)]="value" (ngModelChange)="update()"
|
||||
/>
|
||||
|
||||
<label class="btn btn-outline-primary" [for]="getRadioId(item)" [attr.data-label]="item.label" i18n>{{ item.label }}</label>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
@for (item of items(); track item.id) {
|
||||
<div class="peertube-radio-container">
|
||||
<input
|
||||
type="radio" [name]="inputId()" [id]="getRadioId(item)" [value]="item.id"
|
||||
autocomplete="off" [(ngModel)]="value" (ngModelChange)="update()"
|
||||
/>
|
||||
|
||||
<label [for]="getRadioId(item)" i18n>{{ item.label }}</label>
|
||||
|
||||
<div *ngIf="item.description" class="form-group-description">
|
||||
{{ item.description}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
|
@ -0,0 +1,37 @@
|
|||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
|
||||
&.label-secondary {
|
||||
font-size: 1rem;
|
||||
font-weight: normal;
|
||||
color: pvar(--fg);
|
||||
}
|
||||
}
|
||||
|
||||
.peertube-radio-container {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.peertube-radio-container .form-group-description {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
// Prevent layout shift on bold
|
||||
.btn-outline-primary {
|
||||
&::after {
|
||||
display: block;
|
||||
content: attr(data-label);
|
||||
font-weight: bold;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-check:checked + .btn-outline-primary {
|
||||
font-weight: $font-bold;
|
||||
letter-spacing: 0;
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
import { CommonModule } from '@angular/common'
|
||||
import { booleanAttribute, Component, forwardRef, inject, input, model } from '@angular/core'
|
||||
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'
|
||||
import { ScreenService } from '@app/core'
|
||||
import { SelectRadioItem } from 'src/types'
|
||||
|
||||
@Component({
|
||||
selector: 'my-select-radio',
|
||||
|
||||
templateUrl: './select-radio.component.html',
|
||||
styleUrls: [ './select-radio.component.scss' ],
|
||||
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => SelectRadioComponent),
|
||||
multi: true
|
||||
}
|
||||
],
|
||||
imports: [ FormsModule, CommonModule ]
|
||||
})
|
||||
export class SelectRadioComponent implements ControlValueAccessor {
|
||||
readonly items = input.required<SelectRadioItem[]>()
|
||||
readonly inputId = input.required<string>()
|
||||
|
||||
readonly label = input<string>()
|
||||
readonly isGroup = input(false, { transform: booleanAttribute })
|
||||
readonly labelSecondary = input(false, { transform: booleanAttribute })
|
||||
|
||||
private readonly screenService = inject(ScreenService)
|
||||
|
||||
readonly value = model('')
|
||||
|
||||
disabled = false
|
||||
|
||||
wroteValue: number | string
|
||||
|
||||
propagateChange = (_: any) => {
|
||||
// empty
|
||||
}
|
||||
|
||||
writeValue (value: string) {
|
||||
this.value.set(value)
|
||||
}
|
||||
|
||||
registerOnChange (fn: (_: any) => void) {
|
||||
this.propagateChange = fn
|
||||
}
|
||||
|
||||
registerOnTouched () {
|
||||
// Unused
|
||||
}
|
||||
|
||||
setDisabledState (isDisabled: boolean) {
|
||||
this.disabled = isDisabled
|
||||
}
|
||||
|
||||
update () {
|
||||
this.propagateChange(this.value())
|
||||
}
|
||||
|
||||
getRadioId (item: SelectRadioItem) {
|
||||
return this.inputId() + '-' + item.id
|
||||
}
|
||||
|
||||
isInMobileView () {
|
||||
return this.screenService.isInMobileView()
|
||||
}
|
||||
}
|
|
@ -79,6 +79,7 @@ const icons = {
|
|||
'ownership-change': require('../../../assets/images/feather/share.svg'),
|
||||
'p2p': require('../../../assets/images/feather/airplay.svg'),
|
||||
'play': require('../../../assets/images/feather/play.svg'),
|
||||
'circle-alert': require('../../../assets/images/feather/circle-alert.svg'),
|
||||
'playlists': require('../../../assets/images/feather/playlists.svg'),
|
||||
'refresh': require('../../../assets/images/feather/refresh-cw.svg'),
|
||||
'repeat': require('../../../assets/images/feather/repeat.svg'),
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
|
||||
<tr>
|
||||
<th class="t-label" scope="row">
|
||||
<div i18n>Default NSFW/sensitive videos policy</div>
|
||||
<div i18n>Default sensitive content policy</div>
|
||||
<span i18n class="fs-7 fw-normal fst-italic">can be redefined by the users</span>
|
||||
</th>
|
||||
|
||||
|
|
|
@ -66,7 +66,8 @@ export class InstanceFeaturesTableComponent implements OnInit {
|
|||
const policy = this.serverConfig().instance.defaultNSFWPolicy
|
||||
|
||||
if (policy === 'do_not_list') return $localize`Hidden`
|
||||
if (policy === 'blur') return $localize`Blurred with confirmation request`
|
||||
if (policy === 'warn') return $localize`Warn users`
|
||||
if (policy === 'blur') return $localize`Warn users and blur thumbnail`
|
||||
if (policy === 'display') return $localize`Displayed`
|
||||
}
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@ import {
|
|||
VideoStreamingPlaylist,
|
||||
VideoStreamingPlaylistType
|
||||
} from '@peertube/peertube-models'
|
||||
import { isVideoNSFWBlurForUser, isVideoNSFWHiddenForUser, isVideoNSFWWarnedForUser } from '@root-helpers/video'
|
||||
|
||||
export class Video implements VideoServerModel {
|
||||
byVideoChannel: string
|
||||
|
@ -68,7 +69,10 @@ export class Video implements VideoServerModel {
|
|||
|
||||
likes: number
|
||||
dislikes: number
|
||||
|
||||
nsfw: boolean
|
||||
nsfwFlags: number
|
||||
nsfwSummary: string
|
||||
|
||||
originInstanceUrl: string
|
||||
originInstanceHost: string
|
||||
|
@ -176,6 +180,8 @@ export class Video implements VideoServerModel {
|
|||
this.dislikes = hash.dislikes
|
||||
|
||||
this.nsfw = hash.nsfw
|
||||
this.nsfwFlags = hash.nsfwFlags
|
||||
this.nsfwSummary = hash.nsfwSummary
|
||||
|
||||
this.account = hash.account
|
||||
this.channel = hash.channel
|
||||
|
@ -217,15 +223,16 @@ export class Video implements VideoServerModel {
|
|||
this.comments = hash.comments
|
||||
}
|
||||
|
||||
isVideoNSFWForUser (user: User, serverConfig: HTMLServerConfig) {
|
||||
// Video is not NSFW, skip
|
||||
if (this.nsfw === false) return false
|
||||
isVideoNSFWWarnedForUser (user: User, serverConfig: HTMLServerConfig) {
|
||||
return isVideoNSFWWarnedForUser(this, serverConfig, user)
|
||||
}
|
||||
|
||||
// Return user setting if logged in
|
||||
if (user) return user.nsfwPolicy !== 'display'
|
||||
isVideoNSFWBlurForUser (user: User, serverConfig: HTMLServerConfig) {
|
||||
return isVideoNSFWBlurForUser(this, serverConfig, user)
|
||||
}
|
||||
|
||||
// Return default instance config
|
||||
return serverConfig.instance.defaultNSFWPolicy !== 'display'
|
||||
isVideoNSFWHiddenForUser (user: User, serverConfig: HTMLServerConfig) {
|
||||
return isVideoNSFWHiddenForUser(this, serverConfig, user)
|
||||
}
|
||||
|
||||
isRemovableBy (user: AuthUser) {
|
||||
|
|
|
@ -18,6 +18,7 @@ import {
|
|||
FeedFormatType,
|
||||
FeedType,
|
||||
FeedType_Type,
|
||||
NSFWFlag,
|
||||
NSFWPolicyType,
|
||||
ResultList,
|
||||
ServerErrorCode,
|
||||
|
@ -30,7 +31,6 @@ import {
|
|||
VideoDetails as VideoDetailsServerModel,
|
||||
VideoFile,
|
||||
VideoFileMetadata,
|
||||
VideoIncludeType,
|
||||
VideoPrivacy,
|
||||
VideoPrivacyType,
|
||||
VideosCommonQuery,
|
||||
|
@ -45,26 +45,14 @@ import { from, Observable, of, throwError } from 'rxjs'
|
|||
import { catchError, concatMap, map, switchMap, toArray } from 'rxjs/operators'
|
||||
import { environment } from '../../../../environments/environment'
|
||||
import { Account } from '../account/account.model'
|
||||
import { AccountService } from '../account/account.service'
|
||||
import { VideoChannel } from '../channel/video-channel.model'
|
||||
import { VideoChannelService } from '../channel/video-channel.service'
|
||||
import { VideoDetails } from './video-details.model'
|
||||
import { VideoPasswordService } from './video-password.service'
|
||||
import { Video } from './video.model'
|
||||
|
||||
export type CommonVideoParams = {
|
||||
export type CommonVideoParams = Omit<VideosCommonQuery, 'start' | 'count' | 'sort'> & {
|
||||
videoPagination?: ComponentPaginationLight
|
||||
sort: VideoSortField | SortMeta
|
||||
include?: VideoIncludeType
|
||||
isLocal?: boolean
|
||||
categoryOneOf?: number[]
|
||||
languageOneOf?: string[]
|
||||
privacyOneOf?: VideoPrivacyType[]
|
||||
isLive?: boolean
|
||||
skipCount?: boolean
|
||||
nsfw?: BooleanBothQuery
|
||||
host?: string
|
||||
search?: string
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
@ -75,6 +63,7 @@ export class VideoService {
|
|||
private restService = inject(RestService)
|
||||
private serverService = inject(ServerService)
|
||||
private confirmService = inject(ConfirmService)
|
||||
private userService = inject(UserService)
|
||||
|
||||
static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos'
|
||||
static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
|
||||
|
@ -113,6 +102,17 @@ export class VideoService {
|
|||
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
||||
}
|
||||
|
||||
removeVideo (idArg: number | number[]) {
|
||||
const ids = arrayify(idArg)
|
||||
|
||||
return from(ids)
|
||||
.pipe(
|
||||
concatMap(id => this.authHttp.delete(`${VideoService.BASE_VIDEO_URL}/${id}`)),
|
||||
toArray(),
|
||||
catchError(err => this.restExtractor.handleError(err))
|
||||
)
|
||||
}
|
||||
|
||||
listMyVideos (options: {
|
||||
videoPagination?: ComponentPaginationLight
|
||||
restPagination?: RestPagination
|
||||
|
@ -154,45 +154,30 @@ export class VideoService {
|
|||
)
|
||||
}
|
||||
|
||||
getAccountVideos (
|
||||
listAccountVideos (
|
||||
options: CommonVideoParams & {
|
||||
account: Pick<Account, 'nameWithHost'>
|
||||
}
|
||||
): Observable<ResultList<Video>> {
|
||||
const { account, ...parameters } = options
|
||||
|
||||
let params = new HttpParams()
|
||||
params = this.buildCommonVideosParams({ params, ...parameters })
|
||||
|
||||
return this.authHttp
|
||||
.get<ResultList<Video>>(AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/videos', { params })
|
||||
.pipe(
|
||||
switchMap(res => this.extractVideos(res)),
|
||||
catchError(err => this.restExtractor.handleError(err))
|
||||
)
|
||||
return this.listVideos({ ...options, videoChannel: options.account })
|
||||
}
|
||||
|
||||
getVideoChannelVideos (
|
||||
parameters: CommonVideoParams & {
|
||||
listChannelVideos (
|
||||
options: CommonVideoParams & {
|
||||
videoChannel: Pick<VideoChannel, 'nameWithHost'>
|
||||
}
|
||||
): Observable<ResultList<Video>> {
|
||||
const { videoChannel } = parameters
|
||||
|
||||
let params = new HttpParams()
|
||||
params = this.buildCommonVideosParams({ params, ...parameters })
|
||||
|
||||
return this.authHttp
|
||||
.get<ResultList<Video>>(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/videos', { params })
|
||||
.pipe(
|
||||
switchMap(res => this.extractVideos(res)),
|
||||
catchError(err => this.restExtractor.handleError(err))
|
||||
)
|
||||
return this.listVideos({ ...options, videoChannel: options.videoChannel })
|
||||
}
|
||||
|
||||
getVideos (parameters: CommonVideoParams): Observable<ResultList<Video>> {
|
||||
listVideos (
|
||||
options: CommonVideoParams & {
|
||||
videoChannel?: Pick<VideoChannel, 'nameWithHost'>
|
||||
account?: Pick<Account, 'nameWithHost'>
|
||||
}
|
||||
): Observable<ResultList<Video>> {
|
||||
let params = new HttpParams()
|
||||
params = this.buildCommonVideosParams({ params, ...parameters })
|
||||
params = this.buildCommonVideosParams({ params, ...options })
|
||||
|
||||
return this.authHttp
|
||||
.get<ResultList<Video>>(VideoService.BASE_VIDEO_URL, { params })
|
||||
|
@ -202,6 +187,87 @@ export class VideoService {
|
|||
)
|
||||
}
|
||||
|
||||
buildCommonVideosParams (options: CommonVideoParams & { params: HttpParams }) {
|
||||
const {
|
||||
params,
|
||||
videoPagination,
|
||||
sort,
|
||||
categoryOneOf,
|
||||
languageOneOf,
|
||||
privacyOneOf,
|
||||
skipCount,
|
||||
search,
|
||||
nsfw,
|
||||
nsfwFlagsExcluded,
|
||||
nsfwFlagsIncluded,
|
||||
|
||||
...otherOptions
|
||||
} = options
|
||||
|
||||
const pagination = videoPagination
|
||||
? this.restService.componentToRestPagination(videoPagination)
|
||||
: undefined
|
||||
|
||||
let newParams = this.restService.addRestGetParams(params, pagination, this.buildListSort(sort))
|
||||
|
||||
if (skipCount) newParams = newParams.set('skipCount', skipCount + '')
|
||||
if (languageOneOf !== undefined) newParams = this.restService.addArrayParams(newParams, 'languageOneOf', languageOneOf)
|
||||
if (categoryOneOf !== undefined) newParams = this.restService.addArrayParams(newParams, 'categoryOneOf', categoryOneOf)
|
||||
if (privacyOneOf !== undefined) newParams = this.restService.addArrayParams(newParams, 'privacyOneOf', privacyOneOf)
|
||||
if (search) newParams = newParams.set('search', search)
|
||||
|
||||
newParams = this.buildNSFWParams(newParams, { nsfw, nsfwFlagsExcluded, nsfwFlagsIncluded })
|
||||
|
||||
return this.restService.addObjectParams(newParams, otherOptions)
|
||||
}
|
||||
|
||||
buildNSFWParams (params: HttpParams, options: Pick<CommonVideoParams, 'nsfw' | 'nsfwFlagsExcluded' | 'nsfwFlagsIncluded'> = {}) {
|
||||
const { nsfw, nsfwFlagsExcluded, nsfwFlagsIncluded } = options
|
||||
|
||||
const anonymous = this.auth.isLoggedIn()
|
||||
? undefined
|
||||
: this.userService.getAnonymousUser()
|
||||
|
||||
const anonymousFlagsExcluded = anonymous
|
||||
? anonymous.nsfwFlagsHidden
|
||||
: undefined
|
||||
|
||||
const anonymousFlagsIncluded = anonymous
|
||||
? anonymous.nsfwFlagsDisplayed | anonymous.nsfwFlagsBlurred | anonymous.nsfwFlagsWarned
|
||||
: undefined
|
||||
|
||||
if (nsfw !== undefined) params = params.set('nsfw', nsfw)
|
||||
else if (anonymous?.nsfwPolicy) params = params.set('nsfw', this.nsfwPolicyToParam(anonymous.nsfwPolicy))
|
||||
|
||||
if (nsfwFlagsExcluded !== undefined) params = params.set('nsfwFlagsExcluded', nsfwFlagsExcluded)
|
||||
else if (anonymousFlagsExcluded !== undefined) params = params.set('nsfwFlagsExcluded', anonymousFlagsExcluded)
|
||||
|
||||
if (nsfwFlagsIncluded !== undefined) params = params.set('nsfwFlagsIncluded', nsfwFlagsIncluded)
|
||||
else if (anonymousFlagsIncluded !== undefined) params = params.set('nsfwFlagsIncluded', anonymousFlagsIncluded)
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
private buildListSort (sortArg: VideoSortField | SortMeta) {
|
||||
const sort = this.restService.buildSortString(sortArg)
|
||||
|
||||
if (typeof sort === 'string') {
|
||||
// Silently use the best algorithm for logged in users if they chose the hot algorithm
|
||||
if (
|
||||
this.auth.isLoggedIn() &&
|
||||
(sort === 'hot' || sort === '-hot')
|
||||
) {
|
||||
return sort.replace('hot', 'best')
|
||||
}
|
||||
|
||||
return sort
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Video feeds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
buildBaseFeedUrls (params: HttpParams, base = VideoService.BASE_FEEDS_URL) {
|
||||
const feeds: { type: FeedType_Type, format: FeedFormatType, label: string, url: string }[] = [
|
||||
{
|
||||
|
@ -278,6 +344,10 @@ export class VideoService {
|
|||
return this.buildBaseFeedUrls(params, VideoService.BASE_SUBSCRIPTION_FEEDS_URL)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Video files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getVideoFileMetadata (metadataUrl: string) {
|
||||
return this.authHttp
|
||||
.get<VideoFileMetadata>(metadataUrl)
|
||||
|
@ -286,17 +356,6 @@ export class VideoService {
|
|||
)
|
||||
}
|
||||
|
||||
removeVideo (idArg: number | number[]) {
|
||||
const ids = arrayify(idArg)
|
||||
|
||||
return from(ids)
|
||||
.pipe(
|
||||
concatMap(id => this.authHttp.delete(`${VideoService.BASE_VIDEO_URL}/${id}`)),
|
||||
toArray(),
|
||||
catchError(err => this.restExtractor.handleError(err))
|
||||
)
|
||||
}
|
||||
|
||||
removeVideoFiles (videoIds: (number | string)[], type: 'hls' | 'web-videos') {
|
||||
return from(videoIds)
|
||||
.pipe(
|
||||
|
@ -316,6 +375,8 @@ export class VideoService {
|
|||
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
runTranscoding (options: {
|
||||
videos: Video[]
|
||||
type: 'hls' | 'web-video'
|
||||
|
@ -475,6 +536,28 @@ export class VideoService {
|
|||
}
|
||||
}
|
||||
|
||||
buildNSFWTooltip (video: Pick<VideoServerModel, 'nsfw' | 'nsfwFlags'>) {
|
||||
const flags: string[] = []
|
||||
|
||||
if ((video.nsfwFlags & NSFWFlag.VIOLENT) === NSFWFlag.VIOLENT) {
|
||||
flags.push($localize`violence`)
|
||||
}
|
||||
|
||||
if ((video.nsfwFlags & NSFWFlag.SHOCKING_DISTURBING) === NSFWFlag.SHOCKING_DISTURBING) {
|
||||
flags.push($localize`shocking content`)
|
||||
}
|
||||
|
||||
if ((video.nsfwFlags & NSFWFlag.EXPLICIT_SEX) === NSFWFlag.EXPLICIT_SEX) {
|
||||
flags.push($localize`explicit sex`)
|
||||
}
|
||||
|
||||
if (flags.length === 0) {
|
||||
return $localize`This video contains sensitive content`
|
||||
}
|
||||
|
||||
return $localize`This video contains sensitive content: ${flags.join(' - ')}`
|
||||
}
|
||||
|
||||
getHighestAvailablePrivacy (serverPrivacies: VideoConstant<VideoPrivacyType>[]) {
|
||||
// We do not add a password as this requires additional configuration.
|
||||
const order = [
|
||||
|
@ -517,64 +600,6 @@ export class VideoService {
|
|||
return 'videoChannel' as 'videoChannel'
|
||||
}
|
||||
|
||||
buildCommonVideosParams (options: CommonVideoParams & { params: HttpParams }) {
|
||||
const {
|
||||
params,
|
||||
videoPagination,
|
||||
sort,
|
||||
isLocal,
|
||||
include,
|
||||
categoryOneOf,
|
||||
languageOneOf,
|
||||
privacyOneOf,
|
||||
skipCount,
|
||||
isLive,
|
||||
nsfw,
|
||||
search,
|
||||
host,
|
||||
|
||||
...otherOptions
|
||||
} = options
|
||||
|
||||
const pagination = videoPagination
|
||||
? this.restService.componentToRestPagination(videoPagination)
|
||||
: undefined
|
||||
|
||||
let newParams = this.restService.addRestGetParams(params, pagination, this.buildListSort(sort))
|
||||
|
||||
if (skipCount) newParams = newParams.set('skipCount', skipCount + '')
|
||||
|
||||
if (isLocal !== undefined) newParams = newParams.set('isLocal', isLocal)
|
||||
if (include !== undefined) newParams = newParams.set('include', include)
|
||||
if (isLive !== undefined) newParams = newParams.set('isLive', isLive)
|
||||
if (nsfw !== undefined) newParams = newParams.set('nsfw', nsfw)
|
||||
if (languageOneOf !== undefined) newParams = this.restService.addArrayParams(newParams, 'languageOneOf', languageOneOf)
|
||||
if (categoryOneOf !== undefined) newParams = this.restService.addArrayParams(newParams, 'categoryOneOf', categoryOneOf)
|
||||
if (privacyOneOf !== undefined) newParams = this.restService.addArrayParams(newParams, 'privacyOneOf', privacyOneOf)
|
||||
if (search) newParams = newParams.set('search', search)
|
||||
if (host) newParams = newParams.set('host', host)
|
||||
|
||||
newParams = this.restService.addObjectParams(newParams, otherOptions)
|
||||
|
||||
return newParams
|
||||
}
|
||||
|
||||
private buildListSort (sortArg: VideoSortField | SortMeta) {
|
||||
const sort = this.restService.buildSortString(sortArg)
|
||||
|
||||
if (typeof sort === 'string') {
|
||||
// Silently use the best algorithm for logged in users if they chose the hot algorithm
|
||||
if (
|
||||
this.auth.isLoggedIn() &&
|
||||
(sort === 'hot' || sort === '-hot')
|
||||
) {
|
||||
return sort.replace('hot', 'best')
|
||||
}
|
||||
|
||||
return sort
|
||||
}
|
||||
}
|
||||
|
||||
private setVideoRate (id: string, rateType: UserVideoRateType, videoPassword?: string) {
|
||||
const url = `${VideoService.BASE_VIDEO_URL}/${id}/rate`
|
||||
const body: UserVideoRateUpdate = {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { splitIntoArray } from '@app/helpers'
|
||||
import {
|
||||
BooleanBothQuery,
|
||||
BooleanQuery,
|
||||
SearchTargetType,
|
||||
VideoChannelsSearchQuery,
|
||||
|
@ -17,8 +16,6 @@ export class AdvancedSearch {
|
|||
originallyPublishedStartDate: string // ISO 8601
|
||||
originallyPublishedEndDate: string // ISO 8601
|
||||
|
||||
nsfw: BooleanBothQuery
|
||||
|
||||
categoryOneOf: string
|
||||
|
||||
licenceOneOf: string
|
||||
|
@ -47,7 +44,6 @@ export class AdvancedSearch {
|
|||
endDate?: string
|
||||
originallyPublishedStartDate?: string
|
||||
originallyPublishedEndDate?: string
|
||||
nsfw?: BooleanBothQuery
|
||||
categoryOneOf?: string
|
||||
licenceOneOf?: string
|
||||
languageOneOf?: string
|
||||
|
@ -74,7 +70,6 @@ export class AdvancedSearch {
|
|||
this.originallyPublishedStartDate = options.originallyPublishedStartDate || undefined
|
||||
this.originallyPublishedEndDate = options.originallyPublishedEndDate || undefined
|
||||
|
||||
this.nsfw = options.nsfw || undefined
|
||||
this.isLive = options.isLive || undefined
|
||||
|
||||
this.categoryOneOf = options.categoryOneOf || undefined
|
||||
|
@ -112,7 +107,6 @@ export class AdvancedSearch {
|
|||
this.endDate = undefined
|
||||
this.originallyPublishedStartDate = undefined
|
||||
this.originallyPublishedEndDate = undefined
|
||||
this.nsfw = undefined
|
||||
this.categoryOneOf = undefined
|
||||
this.licenceOneOf = undefined
|
||||
this.languageOneOf = undefined
|
||||
|
@ -132,7 +126,6 @@ export class AdvancedSearch {
|
|||
endDate: this.endDate,
|
||||
originallyPublishedStartDate: this.originallyPublishedStartDate,
|
||||
originallyPublishedEndDate: this.originallyPublishedEndDate,
|
||||
nsfw: this.nsfw,
|
||||
categoryOneOf: this.categoryOneOf,
|
||||
licenceOneOf: this.licenceOneOf,
|
||||
languageOneOf: this.languageOneOf,
|
||||
|
@ -158,7 +151,6 @@ export class AdvancedSearch {
|
|||
endDate: this.endDate,
|
||||
originallyPublishedStartDate: this.originallyPublishedStartDate,
|
||||
originallyPublishedEndDate: this.originallyPublishedEndDate,
|
||||
nsfw: this.nsfw,
|
||||
categoryOneOf: splitIntoArray(this.categoryOneOf),
|
||||
licenceOneOf: splitIntoArray(this.licenceOneOf),
|
||||
languageOneOf: splitIntoArray(this.languageOneOf),
|
||||
|
@ -194,7 +186,6 @@ export class AdvancedSearch {
|
|||
if (this.isValidValue(this.startDate) || this.isValidValue(this.endDate)) acc++
|
||||
if (this.isValidValue(this.originallyPublishedStartDate) || this.isValidValue(this.originallyPublishedEndDate)) acc++
|
||||
|
||||
if (this.isValidValue(this.nsfw)) acc++
|
||||
if (this.isValidValue(this.categoryOneOf)) acc++
|
||||
if (this.isValidValue(this.licenceOneOf)) acc++
|
||||
if (this.isValidValue(this.languageOneOf)) acc++
|
||||
|
@ -221,7 +212,6 @@ export class AdvancedSearch {
|
|||
this.endDate !== undefined ||
|
||||
this.originallyPublishedStartDate !== undefined ||
|
||||
this.originallyPublishedEndDate !== undefined ||
|
||||
this.nsfw !== undefined ||
|
||||
this.categoryOneOf !== undefined ||
|
||||
this.licenceOneOf !== undefined ||
|
||||
this.languageOneOf !== undefined ||
|
||||
|
|
|
@ -57,9 +57,12 @@ export class SearchService {
|
|||
|
||||
if (advancedSearch) {
|
||||
const advancedSearchObject = advancedSearch.toVideosAPIObject()
|
||||
|
||||
params = this.restService.addObjectParams(params, advancedSearchObject)
|
||||
}
|
||||
|
||||
params = this.videoService.buildNSFWParams(params, {})
|
||||
|
||||
return this.authHttp
|
||||
.get<ResultList<VideoServerModel>>(url, { params })
|
||||
.pipe(
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
<div class="root" [ngClass]="{ 'small': size() === 'small' }">
|
||||
|
||||
<my-video-thumbnail
|
||||
*ngIf="thumbnail()"
|
||||
*ngIf="thumbnail()" blur="false"
|
||||
[video]="video()" [videoRouterLink]="getVideoUrl()" [ariaLabel]="video().name" playOverlay="false"
|
||||
></my-video-thumbnail>
|
||||
|
||||
<div *ngIf="title()" class="min-width-0" [ngClass]="{ ellipsis: ellipsis }">
|
||||
<div class="d-flex flex-wrap">
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||
<a class="name" [ngClass]="{ ellipsis: ellipsis }" [routerLink]="getVideoUrl()" [title]="video().name">
|
||||
{{ video().name }}
|
||||
</a>
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
}
|
||||
|
||||
<ng-template #aContent>
|
||||
<img alt="" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw() }" />
|
||||
<img alt="" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': blur() }" />
|
||||
|
||||
<div *ngIf="displayWatchLaterPlaylist()" class="actions-overlay">
|
||||
<button
|
||||
|
@ -25,8 +25,8 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div class="label-overlay warning"><ng-content select="label-warning"></ng-content></div>
|
||||
<div class="label-overlay danger"><ng-content select="label-danger"></ng-content></div>
|
||||
<div class="label-overlay pt-badge badge-warning"><ng-content select="label-warning"></ng-content></div>
|
||||
<div class="label-overlay pt-badge badge-danger"><ng-content select="label-danger"></ng-content></div>
|
||||
|
||||
@if (video().isLive) {
|
||||
<div class="live-overlay" [ngClass]="{ 'live-streaming': isLiveStreaming(), 'ended-live': isEndedLive() }">
|
||||
|
|
|
@ -22,7 +22,6 @@
|
|||
}
|
||||
|
||||
.watch-icon-overlay,
|
||||
.label-overlay,
|
||||
.duration-overlay,
|
||||
.live-overlay {
|
||||
font-size: 0.75rem;
|
||||
|
@ -33,15 +32,11 @@
|
|||
}
|
||||
|
||||
.label-overlay {
|
||||
border-radius: 3px;
|
||||
position: absolute;
|
||||
padding: 0 5px;
|
||||
left: 5px;
|
||||
top: 5px;
|
||||
font-weight: $font-bold;
|
||||
|
||||
&.warning { background-color: #ffa500; }
|
||||
&.danger { background-color: pvar(--red); }
|
||||
z-index: z(miniature);
|
||||
}
|
||||
|
||||
.duration-overlay,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { NgClass, NgIf, NgStyle, NgTemplateOutlet } from '@angular/common'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { booleanAttribute, Component, inject, input, OnChanges, output, viewChild } from '@angular/core'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { ScreenService } from '@app/core'
|
||||
|
@ -27,13 +27,12 @@ export type VideoThumbnailInput = Pick<
|
|||
selector: 'my-video-thumbnail',
|
||||
styleUrls: [ './video-thumbnail.component.scss' ],
|
||||
templateUrl: './video-thumbnail.component.html',
|
||||
imports: [ NgIf, RouterLink, NgTemplateOutlet, NgClass, NgbTooltip, GlobalIconComponent, NgStyle ]
|
||||
imports: [ CommonModule, RouterLink, NgbTooltip, GlobalIconComponent ]
|
||||
})
|
||||
export class VideoThumbnailComponent implements OnChanges {
|
||||
private screenService = inject(ScreenService)
|
||||
|
||||
readonly video = input.required<VideoThumbnailInput>()
|
||||
readonly nsfw = input(false)
|
||||
|
||||
readonly videoRouterLink = input<string | any[]>(undefined)
|
||||
readonly queryParams = input<{
|
||||
|
@ -47,6 +46,7 @@ export class VideoThumbnailComponent implements OnChanges {
|
|||
readonly playOverlay = input<boolean, boolean | string>(true, { transform: booleanAttribute })
|
||||
|
||||
readonly ariaLabel = input.required<string>()
|
||||
readonly blur = input.required({ transform: booleanAttribute })
|
||||
|
||||
readonly watchLaterTooltip = viewChild<NgbTooltip>('watchLaterTooltip')
|
||||
readonly watchLaterClick = output<boolean>()
|
||||
|
|
|
@ -2,23 +2,36 @@
|
|||
<div class="form-group">
|
||||
<div class="anchor" id="video-sensitive-content-policy"></div> <!-- video-sensitive-content-policy anchor -->
|
||||
|
||||
<div class="pt-label-container">
|
||||
<label i18n for="nsfwPolicy">Default policy on videos containing sensitive content</label>
|
||||
|
||||
<my-help>
|
||||
<ng-container i18n>
|
||||
With <strong>Hide</strong> or <strong>Blur thumbnails</strong>, a confirmation will be requested to watch the video.
|
||||
</ng-container>
|
||||
</my-help>
|
||||
<div class="form-group">
|
||||
<my-select-radio
|
||||
[items]="nsfwItems" inputId="nsfwPolicy" isGroup="true"
|
||||
i18n-label label="Policy on videos containing sensitive content"
|
||||
formControlName="nsfwPolicy"
|
||||
></my-select-radio>
|
||||
</div>
|
||||
|
||||
<div class="peertube-select-container">
|
||||
<select id="nsfwPolicy" formControlName="nsfwPolicy" class="form-control">
|
||||
<option i18n value="undefined" disabled>Policy for sensitive videos</option>
|
||||
<option i18n value="do_not_list">Hide</option>
|
||||
<option i18n value="blur">Blur thumbnails</option>
|
||||
<option i18n value="display">Display</option>
|
||||
</select>
|
||||
<div class="form-group mb-3">
|
||||
<my-select-radio
|
||||
[items]="nsfwFlagItems" inputId="nsfwFlagViolent" isGroup="true"
|
||||
labelSecondary="true" i18n-label label="Redefine policy for violent content"
|
||||
formControlName="nsfwFlagViolent"
|
||||
></my-select-radio>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-3">
|
||||
<my-select-radio
|
||||
[items]="nsfwFlagItems" inputId="nsfwFlagShocking" isGroup="true"
|
||||
labelSecondary="true" i18n-label label="Redefine policy for shocking or disturbing content"
|
||||
formControlName="nsfwFlagShocking"
|
||||
></my-select-radio>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-3">
|
||||
<my-select-radio
|
||||
[items]="nsfwFlagItems" inputId="nsfwFlagSex" isGroup="true"
|
||||
labelSecondary="true" i18n-label label="Redefine policy for sexually explicit material"
|
||||
formControlName="nsfwFlagSex"
|
||||
></my-select-radio>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -9,5 +9,6 @@
|
|||
my-select-languages {
|
||||
display: block;
|
||||
|
||||
@include responsive-width(340px);
|
||||
width: 340px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
|
|
@ -1,17 +1,33 @@
|
|||
import { NgIf } from '@angular/common'
|
||||
import { Component, OnDestroy, OnInit, inject, input } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { AuthService, Notifier, ServerService, User, UserService } from '@app/core'
|
||||
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
|
||||
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
|
||||
import { NSFWPolicyType, UserUpdateMe } from '@peertube/peertube-models'
|
||||
import { FormReactiveErrors, FormReactiveService, FormReactiveValidationMessages } from '@app/shared/shared-forms/form-reactive.service'
|
||||
import { NSFWFlag, NSFWFlagType, NSFWPolicyType, UserUpdateMe } from '@peertube/peertube-models'
|
||||
import { pick } from 'lodash-es'
|
||||
import { Subject, Subscription } from 'rxjs'
|
||||
import { first } from 'rxjs/operators'
|
||||
import { SelectOptionsItem } from 'src/types'
|
||||
import { BuildFormArgument } from '../form-validators/form-validator.model'
|
||||
import { PeertubeCheckboxComponent } from '../shared-forms/peertube-checkbox.component'
|
||||
import { SelectLanguagesComponent } from '../shared-forms/select/select-languages.component'
|
||||
import { SelectRadioComponent } from '../shared-forms/select/select-radio.component'
|
||||
import { HelpComponent } from '../shared-main/buttons/help.component'
|
||||
|
||||
type NSFWFlagPolicyType = NSFWPolicyType | 'default'
|
||||
|
||||
type Form = {
|
||||
nsfwPolicy: FormControl<NSFWPolicyType>
|
||||
nsfwFlagViolent: FormControl<NSFWFlagPolicyType>
|
||||
nsfwFlagShocking: FormControl<NSFWFlagPolicyType>
|
||||
nsfwFlagSex: FormControl<NSFWFlagPolicyType>
|
||||
|
||||
p2pEnabled: FormControl<boolean>
|
||||
autoPlayVideo: FormControl<boolean>
|
||||
autoPlayNextVideo: FormControl<boolean>
|
||||
videoLanguages: FormControl<string[]>
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'my-user-video-settings',
|
||||
templateUrl: './user-video-settings.component.html',
|
||||
|
@ -22,11 +38,12 @@ import { HelpComponent } from '../shared-main/buttons/help.component'
|
|||
HelpComponent,
|
||||
SelectLanguagesComponent,
|
||||
PeertubeCheckboxComponent,
|
||||
NgIf
|
||||
NgIf,
|
||||
SelectRadioComponent
|
||||
]
|
||||
})
|
||||
export class UserVideoSettingsComponent extends FormReactive implements OnInit, OnDestroy {
|
||||
protected formReactiveService = inject(FormReactiveService)
|
||||
export class UserVideoSettingsComponent implements OnInit, OnDestroy {
|
||||
private formReactiveService = inject(FormReactiveService)
|
||||
private authService = inject(AuthService)
|
||||
private notifier = inject(Notifier)
|
||||
private userService = inject(UserService)
|
||||
|
@ -37,26 +54,72 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
|
|||
readonly notifyOnUpdate = input(true)
|
||||
readonly userInformationLoaded = input<Subject<any>>(undefined)
|
||||
|
||||
defaultNSFWPolicy: NSFWPolicyType
|
||||
form: FormGroup<Form>
|
||||
formErrors: FormReactiveErrors = {}
|
||||
validationMessages: FormReactiveValidationMessages = {}
|
||||
|
||||
nsfwItems: SelectOptionsItem[] = [
|
||||
{
|
||||
id: 'do_not_list',
|
||||
label: $localize`Hide`
|
||||
},
|
||||
{
|
||||
id: 'warn',
|
||||
label: $localize`Warn`
|
||||
},
|
||||
{
|
||||
id: 'blur',
|
||||
label: $localize`Blur`
|
||||
},
|
||||
{
|
||||
id: 'display',
|
||||
label: $localize`Display`
|
||||
}
|
||||
]
|
||||
|
||||
nsfwFlagItems: SelectOptionsItem[] = [
|
||||
{
|
||||
id: 'default',
|
||||
label: $localize`Default`
|
||||
},
|
||||
{
|
||||
id: 'do_not_list',
|
||||
label: $localize`Hide`
|
||||
},
|
||||
{
|
||||
id: 'warn',
|
||||
label: $localize`Warn`
|
||||
},
|
||||
{
|
||||
id: 'blur',
|
||||
label: $localize`Blur`
|
||||
},
|
||||
{
|
||||
id: 'display',
|
||||
label: $localize`Display`
|
||||
}
|
||||
]
|
||||
|
||||
formValuesWatcher: Subscription
|
||||
|
||||
ngOnInit () {
|
||||
this.buildForm({
|
||||
nsfwPolicy: null,
|
||||
p2pEnabled: null,
|
||||
autoPlayVideo: null,
|
||||
autoPlayNextVideo: null,
|
||||
videoLanguages: null
|
||||
})
|
||||
this.buildForm()
|
||||
|
||||
this.updateNSFWDefaultLabel(this.user().nsfwPolicy)
|
||||
this.form.controls.nsfwPolicy.valueChanges.subscribe(nsfwPolicy => this.updateNSFWDefaultLabel(nsfwPolicy))
|
||||
|
||||
this.userInformationLoaded().pipe(first())
|
||||
.subscribe(
|
||||
() => {
|
||||
const serverConfig = this.serverService.getHTMLConfig()
|
||||
this.defaultNSFWPolicy = serverConfig.instance.defaultNSFWPolicy
|
||||
const defaultNSFWPolicy = serverConfig.instance.defaultNSFWPolicy
|
||||
|
||||
this.form.patchValue({
|
||||
nsfwPolicy: this.user().nsfwPolicy || this.defaultNSFWPolicy,
|
||||
nsfwPolicy: this.user().nsfwPolicy || defaultNSFWPolicy,
|
||||
nsfwFlagViolent: this.buildNSFWFormFlag(NSFWFlag.VIOLENT),
|
||||
nsfwFlagShocking: this.buildNSFWFormFlag(NSFWFlag.SHOCKING_DISTURBING),
|
||||
nsfwFlagSex: this.buildNSFWFormFlag(NSFWFlag.EXPLICIT_SEX),
|
||||
|
||||
p2pEnabled: this.user().p2pEnabled,
|
||||
autoPlayVideo: this.user().autoPlayVideo === true,
|
||||
autoPlayNextVideo: this.user().autoPlayNextVideo,
|
||||
|
@ -72,13 +135,32 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
|
|||
this.formValuesWatcher?.unsubscribe()
|
||||
}
|
||||
|
||||
updateDetails (onlyKeys?: string[]) {
|
||||
const nsfwPolicy = this.form.value['nsfwPolicy']
|
||||
const p2pEnabled = this.form.value['p2pEnabled']
|
||||
const autoPlayVideo = this.form.value['autoPlayVideo']
|
||||
const autoPlayNextVideo = this.form.value['autoPlayNextVideo']
|
||||
private buildForm () {
|
||||
const obj: BuildFormArgument = {
|
||||
nsfwPolicy: null,
|
||||
nsfwFlagViolent: null,
|
||||
nsfwFlagShocking: null,
|
||||
nsfwFlagSex: null,
|
||||
|
||||
const videoLanguages = this.form.value['videoLanguages']
|
||||
p2pEnabled: null,
|
||||
autoPlayVideo: null,
|
||||
autoPlayNextVideo: null,
|
||||
videoLanguages: null
|
||||
}
|
||||
|
||||
const {
|
||||
form,
|
||||
formErrors,
|
||||
validationMessages
|
||||
} = this.formReactiveService.buildForm<Form>(obj)
|
||||
|
||||
this.form = form
|
||||
this.formErrors = formErrors
|
||||
this.validationMessages = validationMessages
|
||||
}
|
||||
|
||||
updateDetails (onlyKeys?: string[]) {
|
||||
const videoLanguages = this.form.value.videoLanguages
|
||||
|
||||
if (Array.isArray(videoLanguages)) {
|
||||
if (videoLanguages.length > 20) {
|
||||
|
@ -87,19 +169,33 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
|
|||
}
|
||||
}
|
||||
|
||||
const value = this.form.value
|
||||
|
||||
let details: UserUpdateMe = {
|
||||
nsfwPolicy,
|
||||
p2pEnabled,
|
||||
autoPlayVideo,
|
||||
autoPlayNextVideo,
|
||||
nsfwPolicy: value.nsfwPolicy,
|
||||
p2pEnabled: value.p2pEnabled,
|
||||
autoPlayVideo: value.autoPlayVideo,
|
||||
autoPlayNextVideo: value.autoPlayNextVideo,
|
||||
|
||||
nsfwFlagsDisplayed: this.buildNSFWUpdateFlag('display'),
|
||||
nsfwFlagsHidden: this.buildNSFWUpdateFlag('do_not_list'),
|
||||
nsfwFlagsWarned: this.buildNSFWUpdateFlag('warn'),
|
||||
nsfwFlagsBlurred: this.buildNSFWUpdateFlag('blur'),
|
||||
|
||||
videoLanguages
|
||||
}
|
||||
|
||||
if (videoLanguages) {
|
||||
details = Object.assign(details, videoLanguages)
|
||||
}
|
||||
if (onlyKeys) {
|
||||
const hasNSFWFlags = onlyKeys.includes('nsfwFlagViolent') ||
|
||||
onlyKeys.includes('nsfwFlagShocking') ||
|
||||
onlyKeys.includes('nsfwFlagSex')
|
||||
|
||||
if (onlyKeys) details = pick(details, onlyKeys)
|
||||
const onlyKeysWithNSFW = hasNSFWFlags
|
||||
? [ ...onlyKeys, 'nsfwFlagsDisplayed', 'nsfwFlagsHidden', 'nsfwFlagsWarned', 'nsfwFlagsBlurred' ]
|
||||
: onlyKeys
|
||||
|
||||
details = pick(details, onlyKeysWithNSFW)
|
||||
}
|
||||
|
||||
if (this.authService.isLoggedIn()) {
|
||||
return this.updateLoggedProfile(details)
|
||||
|
@ -113,7 +209,7 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
|
|||
|
||||
this.formValuesWatcher = this.form.valueChanges.subscribe((formValue: any) => {
|
||||
const updatedKey = Object.keys(formValue)
|
||||
.find(k => formValue[k] !== oldForm[k])
|
||||
.find(k => formValue[k] !== ((oldForm as any)[k]))
|
||||
|
||||
oldForm = { ...this.form.value }
|
||||
|
||||
|
@ -141,4 +237,32 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
|
|||
this.notifier.success($localize`Display/Video settings updated.`)
|
||||
}
|
||||
}
|
||||
|
||||
private buildNSFWFormFlag (flag: NSFWFlagType): NSFWPolicyType | 'default' {
|
||||
const user = this.user()
|
||||
|
||||
if ((user.nsfwFlagsDisplayed & flag) === flag) return 'display'
|
||||
if ((user.nsfwFlagsWarned & flag) === flag) return 'warn'
|
||||
if ((user.nsfwFlagsBlurred & flag) === flag) return 'blur'
|
||||
if ((user.nsfwFlagsHidden & flag) === flag) return 'do_not_list'
|
||||
|
||||
return 'default'
|
||||
}
|
||||
|
||||
private buildNSFWUpdateFlag (type: NSFWPolicyType): number {
|
||||
let result = NSFWFlag.NONE
|
||||
|
||||
if (this.form.value.nsfwFlagViolent === type) result |= NSFWFlag.VIOLENT
|
||||
if (this.form.value.nsfwFlagShocking === type) result |= NSFWFlag.SHOCKING_DISTURBING
|
||||
if (this.form.value.nsfwFlagSex === type) result |= NSFWFlag.EXPLICIT_SEX
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private updateNSFWDefaultLabel (nsfwPolicy: NSFWPolicyType) {
|
||||
const defaultItem = this.nsfwFlagItems.find(item => item.id === 'default')
|
||||
const nsfwPolicyLabel = this.nsfwItems.find(i => i.id === nsfwPolicy).label
|
||||
|
||||
defaultItem.label = $localize`Default (${nsfwPolicyLabel})`
|
||||
}
|
||||
}
|
||||
|
|
|
@ -78,14 +78,12 @@
|
|||
<div class="form-group" role="radiogroup">
|
||||
<label for="nsfw" i18n>Sensitive content</label>
|
||||
|
||||
<div class="peertube-radio-container">
|
||||
<input formControlName="nsfw" type="radio" name="nsfw" id="nsfwBoth" value="both" />
|
||||
<label for="nsfwBoth">{{ filters().getNSFWDisplayLabel() }}</label>
|
||||
</div>
|
||||
<div class="form-group-description">
|
||||
<div>{{ filters().getNSFWSettingsLabel() }}</div>
|
||||
|
||||
<div class="peertube-radio-container">
|
||||
<input formControlName="nsfw" type="radio" name="nsfw" id="nsfwFalse" value="false" />
|
||||
<label for="nsfwFalse" i18n>Hide</label>
|
||||
<div i18n>
|
||||
Update your policy in <a routerLink="/my-account/settings" fragment="video-sensitive-content-policy" (click)="onAccountSettingsClick($event)">your settings</a>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -144,7 +142,7 @@
|
|||
<my-peertube-checkbox
|
||||
formControlName="allVideos"
|
||||
inputName="allVideos"
|
||||
i18n-labelText labelText="Display all videos (private, unlisted, password protected or not yet published)"
|
||||
i18n-labelText labelText="Display all videos (private, unlisted, password protected, not yet published or sensitive videos)"
|
||||
></my-peertube-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -44,6 +44,10 @@ $filters-background: pvar(--bg-secondary-400);
|
|||
}
|
||||
}
|
||||
|
||||
.form-group-description {
|
||||
color: pvar(--fg-300);
|
||||
}
|
||||
|
||||
.filters {
|
||||
--input-bg: #{pvar(--input-bg-in-secondary)};
|
||||
--input-border-color: #{pvar(--input-bg-in-secondary)};
|
||||
|
@ -134,6 +138,11 @@ my-select-options {
|
|||
}
|
||||
|
||||
@media screen and (max-width: $small-view) {
|
||||
.scope-select {
|
||||
min-width: auto;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.filters-toggle {
|
||||
margin-top: 0.5rem;
|
||||
|
||||
|
|
|
@ -81,7 +81,6 @@ export class VideoFiltersHeaderComponent implements OnInit {
|
|||
|
||||
this.form = this.fb.group({
|
||||
sort: [ '' ],
|
||||
nsfw: [ '' ],
|
||||
languageOneOf: [ '' ],
|
||||
categoryOneOf: [ '' ],
|
||||
scope: [ '' ],
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
import { User } from '@app/core'
|
||||
import { splitIntoArray, toBoolean } from '@app/helpers'
|
||||
import { getAllPrivacies } from '@peertube/peertube-core-utils'
|
||||
import {
|
||||
BooleanBothQuery,
|
||||
NSFWFlag,
|
||||
NSFWPolicyType,
|
||||
VideoInclude,
|
||||
VideoIncludeType,
|
||||
VideoPrivacyType,
|
||||
VideosCommonQuery,
|
||||
VideoSortField
|
||||
} from '@peertube/peertube-models'
|
||||
import { AttributesOnly } from '@peertube/peertube-typescript-utils'
|
||||
|
@ -29,7 +32,6 @@ export type VideoFilterActive = {
|
|||
|
||||
export class VideoFilters {
|
||||
sort: VideoSortField
|
||||
nsfw: BooleanBothQuery
|
||||
|
||||
languageOneOf: string[]
|
||||
categoryOneOf: number[]
|
||||
|
@ -41,9 +43,14 @@ export class VideoFilters {
|
|||
|
||||
search: string
|
||||
|
||||
private nsfwPolicy: NSFWPolicyType
|
||||
private nsfwFlagsDisplayed: number
|
||||
private nsfwFlagsHidden: number
|
||||
private nsfwFlagsWarned: number
|
||||
private nsfwFlagsBlurred: number
|
||||
|
||||
private defaultValues = new Map<keyof VideoFilters, any>([
|
||||
[ 'sort', '-publishedAt' ],
|
||||
[ 'nsfw', 'false' ],
|
||||
[ 'languageOneOf', undefined ],
|
||||
[ 'categoryOneOf', undefined ],
|
||||
[ 'scope', 'federated' ],
|
||||
|
@ -53,7 +60,6 @@ export class VideoFilters {
|
|||
])
|
||||
|
||||
private activeFilters: VideoFilterActive[] = []
|
||||
private defaultNSFWPolicy: NSFWPolicyType
|
||||
|
||||
private onChangeCallbacks: (() => void)[] = []
|
||||
private oldFormObjectString: string
|
||||
|
@ -68,7 +74,7 @@ export class VideoFilters {
|
|||
|
||||
this.hiddenFields = hiddenFields
|
||||
|
||||
this.reset(undefined, false)
|
||||
this.reset({ triggerChange: false })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
@ -106,21 +112,23 @@ export class VideoFilters {
|
|||
this.defaultValues.set('sort', sort)
|
||||
}
|
||||
|
||||
setNSFWPolicy (nsfwPolicy: NSFWPolicyType) {
|
||||
const nsfw = nsfwPolicy === 'do_not_list'
|
||||
? 'false'
|
||||
: 'both'
|
||||
|
||||
this.defaultValues.set('nsfw', nsfw)
|
||||
this.defaultNSFWPolicy = nsfwPolicy
|
||||
|
||||
return nsfw
|
||||
setNSFWPolicy (user: Pick<User, 'nsfwPolicy' | 'nsfwFlagsDisplayed' | 'nsfwFlagsHidden' | 'nsfwFlagsWarned' | 'nsfwFlagsBlurred'>) {
|
||||
this.nsfwPolicy = user.nsfwPolicy
|
||||
this.nsfwFlagsDisplayed = user.nsfwFlagsDisplayed
|
||||
this.nsfwFlagsHidden = user.nsfwFlagsHidden
|
||||
this.nsfwFlagsWarned = user.nsfwFlagsWarned
|
||||
this.nsfwFlagsBlurred = user.nsfwFlagsBlurred
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
reset (specificKey?: string, triggerChange = true) {
|
||||
debugLogger('Reset video filters', { specificKey, stack: new Error().stack })
|
||||
private reset (options: {
|
||||
specificKey?: string
|
||||
triggerChange?: boolean // default true
|
||||
}) {
|
||||
const { specificKey, triggerChange = true } = options
|
||||
|
||||
debugLogger('Reset video filters', { specificKey })
|
||||
|
||||
for (const [ key, value ] of this.defaultValues) {
|
||||
if (specificKey && specificKey !== key) continue
|
||||
|
@ -143,8 +151,6 @@ export class VideoFilters {
|
|||
|
||||
if (obj.sort !== undefined) this.sort = obj.sort
|
||||
|
||||
if (obj.nsfw !== undefined) this.nsfw = obj.nsfw
|
||||
|
||||
if (obj.languageOneOf !== undefined) this.languageOneOf = splitIntoArray(obj.languageOneOf)
|
||||
if (obj.categoryOneOf !== undefined) this.categoryOneOf = splitIntoArray(obj.categoryOneOf)
|
||||
|
||||
|
@ -163,7 +169,14 @@ export class VideoFilters {
|
|||
debugLogger('Cloning video filters', { videoFilters: this })
|
||||
|
||||
const cloned = new VideoFilters(this.defaultValues.get('sort'), this.defaultValues.get('scope'), this.hiddenFields)
|
||||
cloned.setNSFWPolicy(this.defaultNSFWPolicy)
|
||||
|
||||
cloned.setNSFWPolicy({
|
||||
nsfwPolicy: this.nsfwPolicy,
|
||||
nsfwFlagsDisplayed: this.nsfwFlagsDisplayed,
|
||||
nsfwFlagsHidden: this.nsfwFlagsHidden,
|
||||
nsfwFlagsWarned: this.nsfwFlagsWarned,
|
||||
nsfwFlagsBlurred: this.nsfwFlagsBlurred
|
||||
})
|
||||
|
||||
cloned.load(this.toUrlObject(), this.customizedByUser)
|
||||
|
||||
|
@ -290,8 +303,9 @@ export class VideoFilters {
|
|||
else if (this.live === 'false') isLive = false
|
||||
|
||||
return {
|
||||
...this.buildNSFWVideosAPIObject(),
|
||||
|
||||
sort: this.sort,
|
||||
nsfw: this.nsfw,
|
||||
languageOneOf: this.languageOneOf,
|
||||
categoryOneOf: this.categoryOneOf,
|
||||
search: this.search,
|
||||
|
@ -302,18 +316,66 @@ export class VideoFilters {
|
|||
}
|
||||
}
|
||||
|
||||
private buildNSFWVideosAPIObject (): Partial<Pick<VideosCommonQuery, 'nsfw' | 'nsfwFlagsExcluded' | 'nsfwFlagsIncluded'>> {
|
||||
if (this.allVideos) {
|
||||
return { nsfw: 'both', nsfwFlagsExcluded: NSFWFlag.NONE }
|
||||
}
|
||||
|
||||
const nsfw: BooleanBothQuery = this.nsfwPolicy === 'do_not_list'
|
||||
? 'false'
|
||||
: 'both'
|
||||
|
||||
let nsfwFlagsIncluded = NSFWFlag.NONE
|
||||
let nsfwFlagsExcluded = NSFWFlag.NONE
|
||||
|
||||
if (this.nsfwPolicy === 'do_not_list') {
|
||||
nsfwFlagsIncluded |= this.nsfwFlagsDisplayed
|
||||
nsfwFlagsIncluded |= this.nsfwFlagsWarned
|
||||
nsfwFlagsIncluded |= this.nsfwFlagsBlurred
|
||||
} else {
|
||||
nsfwFlagsExcluded |= this.nsfwFlagsHidden
|
||||
}
|
||||
|
||||
return { nsfw, nsfwFlagsIncluded, nsfwFlagsExcluded }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getNSFWDisplayLabel () {
|
||||
if (this.defaultNSFWPolicy === 'blur') return $localize`Blurred`
|
||||
getNSFWSettingsLabel () {
|
||||
let result = this.getGlobalNSFWLabel()
|
||||
|
||||
return $localize`Displayed`
|
||||
if (this.hasCustomNSFWFlags()) {
|
||||
result += $localize` Some videos with a specific sensitive content category have a different policy.`
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private getGlobalNSFWLabel () {
|
||||
if (this.nsfwPolicy === 'do_not_list') return $localize`Sensitive content hidden.`
|
||||
if (this.nsfwPolicy === 'warn') return $localize`Sensitive content has a warning.`
|
||||
if (this.nsfwPolicy === 'blur') return $localize`Sensitive content has a warning and the thumbnail is blurred.`
|
||||
|
||||
return $localize`Sensitive content is displayed.`
|
||||
}
|
||||
|
||||
private getNSFWValue () {
|
||||
if (this.nsfw === 'false') return $localize`hidden`
|
||||
if (this.defaultNSFWPolicy === 'blur') return $localize`blurred`
|
||||
if (this.hasCustomNSFWFlags()) {
|
||||
if (this.nsfwPolicy === 'do_not_list') return $localize`hidden (with exceptions)`
|
||||
if (this.nsfwPolicy === 'warn') return $localize`warned (with exceptions)`
|
||||
if (this.nsfwPolicy === 'blur') return $localize`blurred (with exceptions)`
|
||||
|
||||
return $localize`displayed (with exceptions)`
|
||||
}
|
||||
|
||||
if (this.nsfwPolicy === 'do_not_list') return $localize`hidden`
|
||||
if (this.nsfwPolicy === 'warn') return $localize`warned`
|
||||
if (this.nsfwPolicy === 'blur') return $localize`blurred`
|
||||
|
||||
return $localize`displayed`
|
||||
}
|
||||
|
||||
private hasCustomNSFWFlags () {
|
||||
return this.nsfwFlagsDisplayed || this.nsfwFlagsHidden || this.nsfwFlagsWarned || this.nsfwFlagsBlurred
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,16 +1,25 @@
|
|||
<div class="video-miniature" [ngClass]="getClasses()" (mouseenter)="loadActions()" (focusin)="loadActions()">
|
||||
|
||||
<my-video-thumbnail
|
||||
[ariaLabel]="getAriaLabel()"
|
||||
[video]="video()" [nsfw]="isVideoBlur" [videoRouterLink]="videoRouterLink" [videoHref]="videoHref" [videoTarget]="videoTarget"
|
||||
[ariaLabel]="getAriaLabel()" [blur]="hasNSFWBlur()"
|
||||
[video]="video()" [videoRouterLink]="videoRouterLink" [videoHref]="videoHref" [videoTarget]="videoTarget"
|
||||
[displayWatchLaterPlaylist]="isWatchLaterPlaylistDisplayed()" [inWatchLaterPlaylist]="inWatchLaterPlaylist" (watchLaterClick)="onWatchLaterClick($event)"
|
||||
>
|
||||
@if (displayOptions().privacyLabel) {
|
||||
<ng-container ngProjectAs="label-warning" *ngIf="isUnlistedVideo()" i18n>Unlisted</ng-container>
|
||||
<ng-container ngProjectAs="label-danger" *ngIf="isPrivateVideo()" i18n>Private</ng-container>
|
||||
<ng-container ngProjectAs="label-danger" *ngIf="isPasswordProtectedVideo()" i18n>Password protected</ng-container>
|
||||
}
|
||||
<!-- Don't use @if that seems broken with content projection (Angular 19.1) -->
|
||||
<ng-container ngProjectAs="label-warning" *ngIf="displayOptions().privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container>
|
||||
<ng-container ngProjectAs="label-danger" *ngIf="displayOptions().privacyLabel && isPrivateVideo()" i18n>Private</ng-container>
|
||||
<ng-container ngProjectAs="label-danger" *ngIf="displayOptions().privacyLabel && isPasswordProtectedVideo()" i18n>Password protected</ng-container>
|
||||
</my-video-thumbnail>
|
||||
|
||||
<ng-template #nsfwWarningButton>
|
||||
<button
|
||||
type="button" class="nsfw-warning button-unstyle ms-auto ps-1"
|
||||
*ngIf="hasNSFWWarning()" [attr.aria-label]="nsfwTooltip" [ngbTooltip]="nsfwTooltip"
|
||||
>
|
||||
<my-global-icon iconName="eye-close"></my-global-icon>
|
||||
</button>
|
||||
</ng-template>
|
||||
|
||||
<div class="video-info">
|
||||
<div *ngIf="displayOptions().avatar || displayOptions().by" class="owner min-width-0">
|
||||
@if (displayOptions().avatar) {
|
||||
|
@ -43,6 +52,10 @@
|
|||
</my-link>
|
||||
|
||||
<my-actor-host *ngIf="!video().isLocal" [host]="video().account.host"></my-actor-host>
|
||||
|
||||
@if (!displayAsRow()) {
|
||||
<ng-container *ngTemplateOutlet="nsfwWarningButton"></ng-container>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -50,7 +63,7 @@
|
|||
<my-link
|
||||
[internalLink]="videoRouterLink" [href]="videoHref" [target]="videoTarget" inheritParentStyle="true"
|
||||
[ariaLabel]="getAriaLabel()"
|
||||
[title]="video().name" class="video-name" className="ellipsis-multiline-2" [ngClass]="{ 'blur-filter': isVideoBlur }"
|
||||
[title]="video().name" class="video-name" className="ellipsis-multiline-2"
|
||||
>
|
||||
{{ video().name }}
|
||||
</my-link>
|
||||
|
@ -61,6 +74,10 @@
|
|||
(videoRemoved)="onVideoRemoved()" (videoBlocked)="onVideoBlocked()" (videoUnblocked)="onVideoUnblocked()" (videoAccountMuted)="onVideoAccountMuted()"
|
||||
></my-video-actions-dropdown>
|
||||
</div>
|
||||
|
||||
@if (displayAsRow() || (!displayOptions().avatar && !displayOptions().by)) {
|
||||
<ng-container *ngTemplateOutlet="nsfwWarningButton"></ng-container>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="date-and-views">
|
||||
|
@ -72,22 +89,5 @@
|
|||
<my-video-views-counter *ngIf="displayOptions().views" [isLive]="video().isLive" [viewers]="video().viewers" [views]="video().views"></my-video-views-counter>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="video-info-privacy fw-semibold">
|
||||
<ng-container *ngIf="displayOptions().privacyText">{{ video().privacy.label }}</ng-container>
|
||||
</div>
|
||||
|
||||
<div *ngIf="containedInPlaylists()" class="badges">
|
||||
<a *ngFor="let playlist of containedInPlaylists()" class="pt-badge badge-secondary" [routerLink]="['/w/p/', playlist.playlistShortUUID]">
|
||||
{{ playlist.playlistDisplayName }}
|
||||
</a>
|
||||
|
||||
<span *ngIf="displayOptions().blacklistInfo && video().blacklisted" class="pt-badge badge-danger video-info-blocked">
|
||||
<span class="fw-semibold" i18n>Blocked</span>
|
||||
<span *ngIf="video().blacklistedReason"> - {{ video().blacklistedReason }}</span>
|
||||
</span>
|
||||
|
||||
<span i18n *ngIf="displayOptions().nsfw && video().nsfw" class="pt-badge badge-danger video-info-nsfw">Sensitive</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -32,19 +32,21 @@ $more-button-width: 40px;
|
|||
font-size: var(--co-fs-medium);
|
||||
}
|
||||
|
||||
.date-and-views,
|
||||
.video-info-privacy,
|
||||
.badges {
|
||||
.date-and-views {
|
||||
font-size: var(--co-fs-small);
|
||||
color: pvar(--fg-200);
|
||||
}
|
||||
|
||||
.date-and-views {
|
||||
color: pvar(--fg-200);
|
||||
.nsfw-warning {
|
||||
my-global-icon {
|
||||
@include global-icon-size(18px);
|
||||
}
|
||||
}
|
||||
|
||||
.owner-container {
|
||||
display: flex;
|
||||
min-width: 1px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
my-actor-host {
|
||||
|
@ -144,14 +146,11 @@ my-actor-host {
|
|||
margin-bottom: 25px;
|
||||
|
||||
.video-info {
|
||||
margin: 0 10px;
|
||||
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.video-actions {
|
||||
margin: 0;
|
||||
top: -3px;
|
||||
width: auto;
|
||||
|
||||
|
@ -208,18 +207,6 @@ my-actor-host {
|
|||
my-actor-avatar {
|
||||
@include margin-right(0.5rem);
|
||||
}
|
||||
|
||||
.video-actions {
|
||||
margin-top: -3px;
|
||||
}
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
font-size: 1rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
@include on-small-main-col {
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import { NgClass, NgFor, NgIf } from '@angular/common'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
LOCALE_ID,
|
||||
OnInit,
|
||||
booleanAttribute,
|
||||
inject,
|
||||
|
@ -11,12 +10,13 @@ import {
|
|||
numberAttribute,
|
||||
output
|
||||
} from '@angular/core'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { AuthService, ScreenService, ServerService, User } from '@app/core'
|
||||
import { HTMLServerConfig, VideoExistInPlaylist, VideoPlaylistType, VideoPrivacy } from '@peertube/peertube-models'
|
||||
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { HTMLServerConfig, VideoPlaylistType, VideoPrivacy } from '@peertube/peertube-models'
|
||||
import { switchMap } from 'rxjs/operators'
|
||||
import { LinkType } from '../../../types/link.type'
|
||||
import { ActorAvatarComponent } from '../shared-actor-image/actor-avatar.component'
|
||||
import { GlobalIconComponent } from '../shared-icons/global-icon.component'
|
||||
import { LinkComponent } from '../shared-main/common/link.component'
|
||||
import { DateToggleComponent } from '../shared-main/date/date-toggle.component'
|
||||
import { Video } from '../shared-main/video/video.model'
|
||||
|
@ -32,9 +32,6 @@ export type MiniatureDisplayOptions = {
|
|||
views?: boolean
|
||||
avatar?: boolean
|
||||
privacyLabel?: boolean
|
||||
privacyText?: boolean
|
||||
blacklistInfo?: boolean
|
||||
nsfw?: boolean
|
||||
|
||||
by?: boolean
|
||||
forceChannelInBy?: boolean
|
||||
|
@ -46,17 +43,16 @@ export type MiniatureDisplayOptions = {
|
|||
templateUrl: './video-miniature.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
NgClass,
|
||||
CommonModule,
|
||||
VideoThumbnailComponent,
|
||||
NgIf,
|
||||
ActorAvatarComponent,
|
||||
LinkComponent,
|
||||
DateToggleComponent,
|
||||
VideoViewsCounterComponent,
|
||||
RouterLink,
|
||||
NgFor,
|
||||
VideoActionsDropdownComponent,
|
||||
ActorHostComponent
|
||||
ActorHostComponent,
|
||||
GlobalIconComponent,
|
||||
NgbTooltipModule
|
||||
]
|
||||
})
|
||||
export class VideoMiniatureComponent implements OnInit {
|
||||
|
@ -66,11 +62,9 @@ export class VideoMiniatureComponent implements OnInit {
|
|||
private videoPlaylistService = inject(VideoPlaylistService)
|
||||
private videoService = inject(VideoService)
|
||||
private cd = inject(ChangeDetectorRef)
|
||||
private localeId = inject(LOCALE_ID)
|
||||
|
||||
readonly user = input<User>(undefined)
|
||||
readonly video = input<Video>(undefined)
|
||||
readonly containedInPlaylists = input<VideoExistInPlaylist[]>(undefined)
|
||||
readonly user = input.required<User>()
|
||||
readonly video = input.required<Video>()
|
||||
|
||||
readonly displayOptions = input<MiniatureDisplayOptions>({
|
||||
date: true,
|
||||
|
@ -78,8 +72,6 @@ export class VideoMiniatureComponent implements OnInit {
|
|||
by: true,
|
||||
avatar: true,
|
||||
privacyLabel: false,
|
||||
privacyText: false,
|
||||
blacklistInfo: false,
|
||||
forceChannelInBy: false
|
||||
})
|
||||
|
||||
|
@ -127,25 +119,27 @@ export class VideoMiniatureComponent implements OnInit {
|
|||
ownerHref: string
|
||||
ownerTarget: string
|
||||
|
||||
nsfwTooltip: string
|
||||
|
||||
private ownerDisplayType: 'account' | 'videoChannel'
|
||||
private actionsLoaded = false
|
||||
|
||||
get authorAccount () {
|
||||
get preferAuthorDisplayName () {
|
||||
return this.serverConfig.client.videos.miniature.preferAuthorDisplayName
|
||||
}
|
||||
|
||||
get authorAccount () {
|
||||
return this.preferAuthorDisplayName
|
||||
? this.video().account.displayName
|
||||
: this.video().account.name
|
||||
}
|
||||
|
||||
get authorChannel () {
|
||||
return this.serverConfig.client.videos.miniature.preferAuthorDisplayName
|
||||
return this.preferAuthorDisplayName
|
||||
? this.video().channel.displayName
|
||||
: this.video().channel.name
|
||||
}
|
||||
|
||||
get isVideoBlur () {
|
||||
return this.video().isVideoNSFWForUser(this.user(), this.serverConfig)
|
||||
}
|
||||
|
||||
ngOnInit () {
|
||||
this.serverConfig = this.serverService.getHTMLConfig()
|
||||
|
||||
|
@ -154,6 +148,7 @@ export class VideoMiniatureComponent implements OnInit {
|
|||
|
||||
this.setUpBy()
|
||||
|
||||
this.nsfwTooltip = this.videoService.buildNSFWTooltip(this.video())
|
||||
this.channelLinkTitle = $localize`${this.video().channel.name} (channel page)`
|
||||
|
||||
// We rely on mouseenter to lazy load actions
|
||||
|
@ -162,7 +157,7 @@ export class VideoMiniatureComponent implements OnInit {
|
|||
}
|
||||
}
|
||||
|
||||
buildVideoLink () {
|
||||
private buildVideoLink () {
|
||||
const videoLinkType = this.videoLinkType()
|
||||
const video = this.video()
|
||||
if (videoLinkType === 'internal' || !video.url) {
|
||||
|
@ -181,7 +176,7 @@ export class VideoMiniatureComponent implements OnInit {
|
|||
this.videoRouterLink = [ '/search/lazy-load-video', { url: video.url } ]
|
||||
}
|
||||
|
||||
buildOwnerLink () {
|
||||
private buildOwnerLink () {
|
||||
const video = this.video()
|
||||
|
||||
const linkType = this.videoLinkType()
|
||||
|
@ -302,6 +297,18 @@ export class VideoMiniatureComponent implements OnInit {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
hasNSFWWarning () {
|
||||
return this.video().isVideoNSFWWarnedForUser(this.user(), this.serverConfig)
|
||||
}
|
||||
|
||||
hasNSFWBlur () {
|
||||
return this.video().isVideoNSFWBlurForUser(this.user(), this.serverConfig)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private setUpBy () {
|
||||
if (this.displayOptions().forceChannelInBy) {
|
||||
this.ownerDisplayType = 'videoChannel'
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { NgClass, NgFor, NgIf } from '@angular/common'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Component, OnDestroy, OnInit, booleanAttribute, inject, input, output } from '@angular/core'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import {
|
||||
|
@ -50,9 +50,7 @@ enum GroupDate {
|
|||
templateUrl: './videos-list.component.html',
|
||||
styleUrls: [ './videos-list.component.scss' ],
|
||||
imports: [
|
||||
NgIf,
|
||||
NgClass,
|
||||
NgFor,
|
||||
CommonModule,
|
||||
ButtonComponent,
|
||||
ButtonComponent,
|
||||
VideoFiltersHeaderComponent,
|
||||
|
@ -109,11 +107,9 @@ export class VideosListComponent implements OnInit, OnDestroy {
|
|||
views: true,
|
||||
by: true,
|
||||
avatar: true,
|
||||
privacyLabel: true,
|
||||
privacyText: false,
|
||||
blacklistInfo: false
|
||||
privacyLabel: true
|
||||
}
|
||||
displayModerationBlock = false
|
||||
displayModerationBlock = true
|
||||
|
||||
private routeSub: Subscription
|
||||
private userSub: Subscription
|
||||
|
@ -268,9 +264,9 @@ export class VideosListComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
private loadUserSettings (user: User) {
|
||||
const nsfw = this.filters.setNSFWPolicy(user.nsfwPolicy)
|
||||
this.filters.setNSFWPolicy(user)
|
||||
|
||||
this.filters.load({ languageOneOf: user.videoLanguages, nsfw })
|
||||
this.filters.load({ languageOneOf: user.videoLanguages })
|
||||
}
|
||||
|
||||
private reloadSyndicationItems () {
|
||||
|
@ -298,6 +294,8 @@ export class VideosListComponent implements OnInit, OnDestroy {
|
|||
.subscribe(user => {
|
||||
debugLogger('User changed', { user })
|
||||
|
||||
this.user = user
|
||||
|
||||
if (this.loadUserVideoPreferences()) {
|
||||
this.loadUserSettings(user)
|
||||
}
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
</div>
|
||||
|
||||
<my-video-miniature
|
||||
[containedInPlaylists]="videosContainedInPlaylists() ? videosContainedInPlaylists()[video.id] : undefined"
|
||||
[video]="video" [displayAsRow]="true" [displayOptions]="miniatureDisplayOptions()"
|
||||
[displayVideoActions]="false" [user]="user()"
|
||||
></my-video-miniature>
|
||||
|
|
|
@ -3,7 +3,7 @@ import { AfterContentInit, Component, contentChildren, inject, input, model, Tem
|
|||
import { FormsModule } from '@angular/forms'
|
||||
import { ComponentPagination, Notifier, resetCurrentPage, User } from '@app/core'
|
||||
import { objectKeysTyped } from '@peertube/peertube-core-utils'
|
||||
import { ResultList, VideosExistInPlaylists, VideoSortField } from '@peertube/peertube-models'
|
||||
import { ResultList, VideoSortField } from '@peertube/peertube-models'
|
||||
import { logger } from '@root-helpers/logger'
|
||||
import { Observable, Subject } from 'rxjs'
|
||||
import { PeertubeCheckboxComponent } from '../shared-forms/peertube-checkbox.component'
|
||||
|
@ -23,7 +23,6 @@ export type SelectionType = { [id: number]: boolean }
|
|||
export class VideosSelectionComponent implements AfterContentInit {
|
||||
private notifier = inject(Notifier)
|
||||
|
||||
readonly videosContainedInPlaylists = input<VideosExistInPlaylists>(undefined)
|
||||
readonly user = input<User>(undefined)
|
||||
readonly pagination = input<ComponentPagination>(undefined)
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
<my-video-thumbnail
|
||||
*ngIf="playlistElement().video"
|
||||
[video]="playlistElement().video" [nsfw]="isVideoBlur(playlistElement().video)"
|
||||
[video]="playlistElement().video" [blur]="hasNSFWBlur(playlistElement().video)"
|
||||
[videoRouterLink]="buildRouterLink()" [queryParams]="buildRouterQuery()" [ariaLabel]="getVideoAriaLabel()"
|
||||
></my-video-thumbnail>
|
||||
|
||||
|
@ -21,8 +21,18 @@
|
|||
[routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()"
|
||||
>{{ playlistElement().video.name }}</a>
|
||||
|
||||
<span i18n *ngIf="isVideoPrivate()" class="pt-badge badge-yellow">Private</span>
|
||||
<span i18n *ngIf="isVideoPasswordProtected()" class="pt-badge badge-yellow">Password protected</span>
|
||||
@if (isVideoPrivate()) {
|
||||
<span i18n class="pt-badge badge-yellow">Private</span>
|
||||
} @else if(isVideoPasswordProtected()) {
|
||||
<span i18n class="pt-badge badge-yellow">Password protected</span>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button" class="nsfw-warning button-unstyle ms-2"
|
||||
*ngIf="hasNSFWWarning(playlistElement().video)" [attr.aria-label]="getNSFWTooltip(playlistElement().video)" [ngbTooltip]="getNSFWTooltip(playlistElement().video)"
|
||||
>
|
||||
<my-global-icon iconName="eye-close"></my-global-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="date-and-views">
|
||||
|
|
|
@ -26,6 +26,12 @@ my-video-thumbnail,
|
|||
@include margin-right(10px);
|
||||
}
|
||||
|
||||
.nsfw-warning {
|
||||
my-global-icon {
|
||||
@include global-icon-size(18px);
|
||||
}
|
||||
}
|
||||
|
||||
.video {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
|
|
|
@ -1,23 +1,22 @@
|
|||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, inject, input, output, viewChild } from '@angular/core'
|
||||
import { AuthService, Notifier, ServerService } from '@app/core'
|
||||
import { NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownButtonItem, NgbDropdownItem } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { Notifier, ServerService, User, UserService } from '@app/core'
|
||||
import { NgbDropdown, NgbDropdownModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { secondsToTime } from '@peertube/peertube-core-utils'
|
||||
import { HTMLServerConfig, VideoPlaylistElementType, VideoPlaylistElementUpdate, VideoPrivacy } from '@peertube/peertube-models'
|
||||
import { PeertubeCheckboxComponent } from '../shared-forms/peertube-checkbox.component'
|
||||
import { TimestampInputComponent } from '../shared-forms/timestamp-input.component'
|
||||
import { GlobalIconComponent } from '../shared-icons/global-icon.component'
|
||||
import { DateToggleComponent } from '../shared-main/date/date-toggle.component'
|
||||
import { Video } from '../shared-main/video/video.model'
|
||||
import { VideoService } from '../shared-main/video/video.service'
|
||||
import { VideoThumbnailComponent } from '../shared-thumbnail/video-thumbnail.component'
|
||||
import { VideoViewsCounterComponent } from '../shared-video/video-views-counter.component'
|
||||
import { VideoPlaylistElement } from './video-playlist-element.model'
|
||||
import { VideoPlaylist } from './video-playlist.model'
|
||||
import { VideoPlaylistService } from './video-playlist.service'
|
||||
import { TimestampInputComponent } from '../shared-forms/timestamp-input.component'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { PeertubeCheckboxComponent } from '../shared-forms/peertube-checkbox.component'
|
||||
|
||||
import { VideoViewsCounterComponent } from '../shared-video/video-views-counter.component'
|
||||
import { DateToggleComponent } from '../shared-main/date/date-toggle.component'
|
||||
import { VideoThumbnailComponent } from '../shared-thumbnail/video-thumbnail.component'
|
||||
import { GlobalIconComponent } from '../shared-icons/global-icon.component'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { NgClass, NgIf } from '@angular/common'
|
||||
import { Video } from '../shared-main/video/video.model'
|
||||
import { VideoService } from '../shared-main/video/video.service'
|
||||
|
||||
@Component({
|
||||
selector: 'my-video-playlist-element-miniature',
|
||||
|
@ -25,25 +24,21 @@ import { VideoService } from '../shared-main/video/video.service'
|
|||
templateUrl: './video-playlist-element-miniature.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
NgClass,
|
||||
CommonModule,
|
||||
RouterLink,
|
||||
NgIf,
|
||||
GlobalIconComponent,
|
||||
VideoThumbnailComponent,
|
||||
DateToggleComponent,
|
||||
VideoViewsCounterComponent,
|
||||
NgbDropdown,
|
||||
NgbDropdownToggle,
|
||||
NgbDropdownMenu,
|
||||
NgbDropdownButtonItem,
|
||||
NgbDropdownItem,
|
||||
NgbDropdownModule,
|
||||
PeertubeCheckboxComponent,
|
||||
FormsModule,
|
||||
TimestampInputComponent
|
||||
TimestampInputComponent,
|
||||
NgbTooltipModule
|
||||
]
|
||||
})
|
||||
export class VideoPlaylistElementMiniatureComponent implements OnInit {
|
||||
private authService = inject(AuthService)
|
||||
private userService = inject(UserService)
|
||||
private serverService = inject(ServerService)
|
||||
private notifier = inject(Notifier)
|
||||
private videoPlaylistService = inject(VideoPlaylistService)
|
||||
|
@ -73,9 +68,14 @@ export class VideoPlaylistElementMiniatureComponent implements OnInit {
|
|||
} = {} as any
|
||||
|
||||
private serverConfig: HTMLServerConfig
|
||||
private user: User
|
||||
|
||||
ngOnInit (): void {
|
||||
this.serverConfig = this.serverService.getHTMLConfig()
|
||||
|
||||
this.userService.getAnonymousOrLoggedUser().subscribe(user => {
|
||||
this.user = user
|
||||
})
|
||||
}
|
||||
|
||||
getVideoAriaLabel () {
|
||||
|
@ -125,8 +125,16 @@ export class VideoPlaylistElementMiniatureComponent implements OnInit {
|
|||
}
|
||||
}
|
||||
|
||||
isVideoBlur (video: Video) {
|
||||
return video.isVideoNSFWForUser(this.authService.getUser(), this.serverConfig)
|
||||
hasNSFWBlur (video: Video) {
|
||||
return video.isVideoNSFWBlurForUser(this.user, this.serverConfig)
|
||||
}
|
||||
|
||||
hasNSFWWarning (video: Video) {
|
||||
return video.isVideoNSFWWarnedForUser(this.user, this.serverConfig)
|
||||
}
|
||||
|
||||
getNSFWTooltip (video: Video) {
|
||||
return this.videoService.buildNSFWTooltip(video)
|
||||
}
|
||||
|
||||
removeFromPlaylist (playlistElement: VideoPlaylistElement) {
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
<span
|
||||
*ngIf="video().nsfw"
|
||||
class="button-unstyle pt-badge" [ngClass]="badgeClass"
|
||||
[attr.aria-label]="tooltip" [ngbTooltip]="tooltip" i18n
|
||||
>Sensitive</span>
|
|
@ -0,0 +1,32 @@
|
|||
import { CommonModule } from '@angular/common'
|
||||
import { Component, inject, input, OnInit } from '@angular/core'
|
||||
import { Video } from '@peertube/peertube-models'
|
||||
import { VideoService } from '../shared-main/video/video.service'
|
||||
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
|
||||
@Component({
|
||||
selector: 'my-video-nsfw-badge',
|
||||
templateUrl: './video-nsfw-badge.component.html',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgbTooltipModule
|
||||
]
|
||||
})
|
||||
export class VideoNSFWBadgeComponent implements OnInit {
|
||||
private videoService = inject(VideoService)
|
||||
|
||||
readonly video = input.required<Pick<Video, 'nsfw' | 'nsfwFlags'>>()
|
||||
readonly theme = input<'yellow' | 'red'>('yellow')
|
||||
|
||||
tooltip: string
|
||||
badgeClass: string
|
||||
|
||||
ngOnInit () {
|
||||
this.tooltip = this.videoService.buildNSFWTooltip(this.video())
|
||||
|
||||
this.badgeClass = this.theme() === 'yellow'
|
||||
? 'badge-warning'
|
||||
: 'badge-danger'
|
||||
}
|
||||
}
|
1
client/src/assets/images/feather/circle-alert.svg
Normal file
1
client/src/assets/images/feather/circle-alert.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-alert-icon lucide-circle-alert"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></svg>
|
After Width: | Height: | Size: 360 B |
|
@ -4,6 +4,7 @@ export * from './images'
|
|||
export * from './local-storage-utils'
|
||||
export * from './logger'
|
||||
export * from './peertube-web-storage'
|
||||
export * from './theme-manager'
|
||||
export * from './plugins-manager'
|
||||
export * from './string'
|
||||
export * from './url'
|
||||
|
|
|
@ -1,10 +1,15 @@
|
|||
function getBoolOrDefault (value: string, defaultValue: boolean) {
|
||||
export function getBoolOrDefault (value: string, defaultValue: boolean) {
|
||||
if (value === 'true') return true
|
||||
if (value === 'false') return false
|
||||
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
export {
|
||||
getBoolOrDefault
|
||||
export function getNumberOrDefault (value: string, defaultValue: number) {
|
||||
if (!value) return defaultValue
|
||||
|
||||
const result = parseInt(value, 10)
|
||||
if (isNaN(result)) return defaultValue
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
213
client/src/root-helpers/theme-manager.ts
Normal file
213
client/src/root-helpers/theme-manager.ts
Normal file
|
@ -0,0 +1,213 @@
|
|||
import { sortBy } from '@peertube/peertube-core-utils'
|
||||
import { getLuminance, parse, toHSLA } from 'color-bits'
|
||||
import { ServerConfigTheme } from '@peertube/peertube-models'
|
||||
import { logger } from './logger'
|
||||
import debug from 'debug'
|
||||
|
||||
const debugLogger = debug('peertube:theme')
|
||||
|
||||
export class ThemeManager {
|
||||
private oldInjectedProperties: string[] = []
|
||||
|
||||
injectTheme (theme: ServerConfigTheme, apiUrl: string) {
|
||||
const head = this.getHeadElement()
|
||||
|
||||
const result: HTMLLinkElement[] = []
|
||||
|
||||
for (const css of theme.css) {
|
||||
const link = document.createElement('link')
|
||||
|
||||
const href = apiUrl + `/themes/${theme.name}/${theme.version}/css/${css}`
|
||||
link.setAttribute('href', href)
|
||||
link.setAttribute('rel', 'alternate stylesheet')
|
||||
link.setAttribute('type', 'text/css')
|
||||
link.setAttribute('title', theme.name)
|
||||
link.setAttribute('disabled', '')
|
||||
|
||||
head.appendChild(link)
|
||||
|
||||
result.push(link)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
loadThemeStyle (name: string) {
|
||||
const links = document.getElementsByTagName('link')
|
||||
|
||||
for (let i = 0; i < links.length; i++) {
|
||||
const link = links[i]
|
||||
if (link.getAttribute('rel').includes('style') && link.getAttribute('title')) {
|
||||
link.disabled = link.getAttribute('title') !== name
|
||||
|
||||
if (!link.disabled) {
|
||||
link.onload = () => this.injectColorPalette()
|
||||
} else {
|
||||
link.onload = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.body.dataset.ptTheme = name
|
||||
}
|
||||
|
||||
injectCoreColorPalette (iteration = 0) {
|
||||
if (iteration > 10) {
|
||||
logger.error('Cannot inject core color palette: too many iterations')
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.canInjectCoreColorPalette()) {
|
||||
return setTimeout(() => this.injectCoreColorPalette(iteration + 1))
|
||||
}
|
||||
|
||||
return this.injectColorPalette()
|
||||
}
|
||||
|
||||
removeThemeLink (linkEl: HTMLLinkElement) {
|
||||
this.getHeadElement().removeChild(linkEl)
|
||||
}
|
||||
|
||||
private canInjectCoreColorPalette () {
|
||||
const computedStyle = getComputedStyle(document.body)
|
||||
const isDark = computedStyle.getPropertyValue('--is-dark')
|
||||
|
||||
return isDark === '0' || isDark === '1'
|
||||
}
|
||||
|
||||
private injectColorPalette () {
|
||||
console.log(`Injecting color palette`)
|
||||
|
||||
const rootStyle = document.body.style
|
||||
const computedStyle = getComputedStyle(document.body)
|
||||
|
||||
// FIXME: Remove previously injected properties
|
||||
for (const property of this.oldInjectedProperties) {
|
||||
rootStyle.removeProperty(property)
|
||||
}
|
||||
|
||||
this.oldInjectedProperties = []
|
||||
|
||||
const isGlobalDarkTheme = () => {
|
||||
return this.isDarkTheme({
|
||||
fg: computedStyle.getPropertyValue('--fg') || computedStyle.getPropertyValue('--mainForegroundColor'),
|
||||
bg: computedStyle.getPropertyValue('--bg') || computedStyle.getPropertyValue('--mainBackgroundColor'),
|
||||
isDarkVar: computedStyle.getPropertyValue('--is-dark')
|
||||
})
|
||||
}
|
||||
|
||||
const isMenuDarkTheme = () => {
|
||||
return this.isDarkTheme({
|
||||
fg: computedStyle.getPropertyValue('--menu-fg'),
|
||||
bg: computedStyle.getPropertyValue('--menu-bg'),
|
||||
isDarkVar: computedStyle.getPropertyValue('--is-menu-dark')
|
||||
})
|
||||
}
|
||||
|
||||
const toProcess = [
|
||||
{ prefix: 'primary', invertIfDark: true, step: 5, darkTheme: isGlobalDarkTheme },
|
||||
{ prefix: 'on-primary', invertIfDark: true, step: 5, darkTheme: isGlobalDarkTheme },
|
||||
{ prefix: 'bg-secondary', invertIfDark: true, step: 5, darkTheme: isGlobalDarkTheme },
|
||||
{ prefix: 'fg', invertIfDark: true, fallbacks: { '--fg-300': '--greyForegroundColor' }, step: 5, darkTheme: isGlobalDarkTheme },
|
||||
|
||||
{ prefix: 'input-bg', invertIfDark: true, step: 5, darkTheme: isGlobalDarkTheme },
|
||||
|
||||
{ prefix: 'menu-fg', invertIfDark: true, step: 5, darkTheme: isMenuDarkTheme },
|
||||
{ prefix: 'menu-bg', invertIfDark: true, step: 5, darkTheme: isMenuDarkTheme }
|
||||
] as { prefix: string, invertIfDark: boolean, step: number, darkTheme: () => boolean, fallbacks?: Record<string, string> }[]
|
||||
|
||||
for (const { prefix, invertIfDark, step, darkTheme, fallbacks = {} } of toProcess) {
|
||||
const mainColor = computedStyle.getPropertyValue('--' + prefix)
|
||||
|
||||
const darkInverter = invertIfDark && darkTheme()
|
||||
? -1
|
||||
: 1
|
||||
|
||||
if (!mainColor) {
|
||||
console.error(`Cannot create palette of nonexistent "--${prefix}" CSS body variable`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Use trim for some web browsers: https://github.com/Chocobozzz/PeerTube/issues/6952
|
||||
const mainColorHSL = toHSLA(parse(mainColor.trim()))
|
||||
debugLogger(`Theme main variable ${mainColor} -> ${this.toHSLStr(mainColorHSL)}`)
|
||||
|
||||
// Inject in alphabetical order for easy debug
|
||||
const toInject: { id: number, key: string, value: string }[] = [
|
||||
{ id: 500, key: `--${prefix}-500`, value: this.toHSLStr(mainColorHSL) }
|
||||
]
|
||||
|
||||
for (const j of [ -1, 1 ]) {
|
||||
let lastColorHSL = { ...mainColorHSL }
|
||||
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
const suffix = 500 + (50 * i * j)
|
||||
const key = `--${prefix}-${suffix}`
|
||||
|
||||
const existingValue = computedStyle.getPropertyValue(key)
|
||||
if (!existingValue || existingValue === '0') {
|
||||
const newLuminance = this.buildNewLuminance(lastColorHSL, j * step, darkInverter)
|
||||
const newColorHSL = { ...lastColorHSL, l: newLuminance }
|
||||
|
||||
const newColorStr = this.toHSLStr(newColorHSL)
|
||||
|
||||
const value = fallbacks[key]
|
||||
? `var(${fallbacks[key]}, ${newColorStr})`
|
||||
: newColorStr
|
||||
|
||||
toInject.push({ id: suffix, key, value })
|
||||
|
||||
lastColorHSL = newColorHSL
|
||||
|
||||
debugLogger(`Injected theme palette ${key} -> ${value}`)
|
||||
} else {
|
||||
lastColorHSL = toHSLA(parse(existingValue))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const { key, value } of sortBy(toInject, 'id')) {
|
||||
rootStyle.setProperty(key, value)
|
||||
this.oldInjectedProperties.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
document.body.dataset.bsTheme = isGlobalDarkTheme()
|
||||
? 'dark'
|
||||
: ''
|
||||
}
|
||||
|
||||
private buildNewLuminance (base: { l: number }, factor: number, darkInverter: number) {
|
||||
return Math.max(Math.min(100, Math.round(base.l + (factor * -1 * darkInverter))), 0)
|
||||
}
|
||||
|
||||
private toHSLStr (c: { h: number, s: number, l: number, a: number }) {
|
||||
return `hsl(${Math.round(c.h)} ${Math.round(c.s)}% ${Math.round(c.l)}% / ${Math.round(c.a)})`
|
||||
}
|
||||
|
||||
private isDarkTheme (options: {
|
||||
fg: string
|
||||
bg: string
|
||||
isDarkVar: string
|
||||
}) {
|
||||
const { fg, bg, isDarkVar } = options
|
||||
|
||||
if (isDarkVar === '1') {
|
||||
return true
|
||||
} else if (fg && bg) {
|
||||
try {
|
||||
if (getLuminance(parse(bg)) < getLuminance(parse(fg))) {
|
||||
return true
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Cannot parse deprecated CSS variables', err)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private getHeadElement () {
|
||||
return document.getElementsByTagName('head')[0]
|
||||
}
|
||||
}
|
|
@ -5,6 +5,12 @@ export const UserLocalStorageKeys = {
|
|||
EMAIL: 'email',
|
||||
|
||||
NSFW_POLICY: 'nsfw_policy',
|
||||
|
||||
NSFW_FLAGS_DISPLAYED: 'nsfw_flags_displayed',
|
||||
NSFW_FLAGS_HIDDEN: 'nsfw_flags_hidden',
|
||||
NSFW_FLAGS_WARNED: 'nsfw_flags_warned',
|
||||
NSFW_FLAGS_BLURRED: 'nsfw_flags_blurred',
|
||||
|
||||
P2P_ENABLED: 'peertube-videojs-webtorrent_enabled',
|
||||
|
||||
AUTO_PLAY_VIDEO: 'auto_play_video',
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { HTMLServerConfig, Video, VideoPrivacy, VideoPrivacyType } from '@peertube/peertube-models'
|
||||
import { HTMLServerConfig, User, Video, VideoPrivacy, VideoPrivacyType } from '@peertube/peertube-models'
|
||||
|
||||
function buildVideoOrPlaylistEmbed (options: {
|
||||
export function buildVideoOrPlaylistEmbed (options: {
|
||||
embedUrl: string
|
||||
embedTitle: string
|
||||
aspectRatio?: number
|
||||
|
@ -37,30 +37,71 @@ function buildVideoOrPlaylistEmbed (options: {
|
|||
return iframe.outerHTML
|
||||
}
|
||||
|
||||
function isP2PEnabled (video: Video, config: HTMLServerConfig, userP2PEnabled: boolean) {
|
||||
export function isP2PEnabled (video: Video, config: HTMLServerConfig, userP2PEnabled: boolean) {
|
||||
if (video.isLocal && config.tracker.enabled === false) return false
|
||||
if (isWebRTCDisabled()) return false
|
||||
|
||||
return userP2PEnabled
|
||||
}
|
||||
|
||||
function videoRequiresUserAuth (video: Video, videoPassword?: string) {
|
||||
export function videoRequiresUserAuth (video: Video, videoPassword?: string) {
|
||||
return new Set<VideoPrivacyType>([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]).has(video.privacy.id) ||
|
||||
(video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED && !videoPassword)
|
||||
|
||||
}
|
||||
|
||||
function videoRequiresFileToken (video: Video) {
|
||||
export function videoRequiresFileToken (video: Video) {
|
||||
return new Set<VideoPrivacyType>([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL, VideoPrivacy.PASSWORD_PROTECTED ]).has(video.privacy.id)
|
||||
}
|
||||
|
||||
export {
|
||||
buildVideoOrPlaylistEmbed,
|
||||
isP2PEnabled,
|
||||
videoRequiresUserAuth,
|
||||
videoRequiresFileToken
|
||||
export function isVideoNSFWWarnedForUser (video: Video, config: HTMLServerConfig, user: User) {
|
||||
if (video.nsfw === false) return false
|
||||
// Don't display NSFW warning for the owner of the video
|
||||
if (user?.account?.id === video.account.id) return false
|
||||
|
||||
if (!user) {
|
||||
return config.instance.defaultNSFWPolicy === 'warn' || config.instance.defaultNSFWPolicy === 'blur'
|
||||
}
|
||||
|
||||
if ((user.nsfwFlagsWarned & video.nsfwFlags) !== 0) return true
|
||||
if ((user.nsfwFlagsBlurred & video.nsfwFlags) !== 0) return true
|
||||
if ((user.nsfwFlagsDisplayed & video.nsfwFlags) !== 0) return false
|
||||
if ((user.nsfwFlagsHidden & video.nsfwFlags) !== 0) return false
|
||||
|
||||
return user.nsfwPolicy === 'warn' || user.nsfwPolicy === 'blur'
|
||||
}
|
||||
|
||||
export function isVideoNSFWBlurForUser (video: Video, config: HTMLServerConfig, user: User) {
|
||||
if (video.nsfw === false) return false
|
||||
// Don't display NSFW warning for the owner of the video
|
||||
if (user?.account?.id === video.account.id) return false
|
||||
|
||||
if (!user) return config.instance.defaultNSFWPolicy === 'blur'
|
||||
|
||||
if ((user.nsfwFlagsBlurred & video.nsfwFlags) !== 0) return true
|
||||
if ((user.nsfwFlagsWarned & video.nsfwFlags) !== 0) return false
|
||||
if ((user.nsfwFlagsDisplayed & video.nsfwFlags) !== 0) return false
|
||||
if ((user.nsfwFlagsHidden & video.nsfwFlags) !== 0) return false
|
||||
|
||||
return user.nsfwPolicy === 'blur'
|
||||
}
|
||||
|
||||
export function isVideoNSFWHiddenForUser (video: Video, config: HTMLServerConfig, user: User) {
|
||||
if (video.nsfw === false) return false
|
||||
// Video is not hidden for the owner of the video
|
||||
if (user?.account?.id === video.account.id) return false
|
||||
|
||||
if (!user) return config.instance.defaultNSFWPolicy === 'do_not_list'
|
||||
|
||||
if ((user.nsfwFlagsHidden & video.nsfwFlags) !== 0) return true
|
||||
if ((user.nsfwFlagsBlurred & video.nsfwFlags) !== 0) return false
|
||||
if ((user.nsfwFlagsWarned & video.nsfwFlags) !== 0) return false
|
||||
if ((user.nsfwFlagsDisplayed & video.nsfwFlags) !== 0) return false
|
||||
|
||||
return user.nsfwPolicy === 'do_not_list'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isWebRTCDisabled () {
|
||||
|
|
33
client/src/sass/bootstrap.scss
vendored
33
client/src/sass/bootstrap.scss
vendored
|
@ -68,16 +68,24 @@ body {
|
|||
font-family: $main-fonts;
|
||||
}
|
||||
|
||||
.btn-outline-secondary {
|
||||
--bs-btn-color: #{pvar(--fg-300)};
|
||||
}
|
||||
|
||||
.btn {
|
||||
--bs-btn-active-color: inherit;
|
||||
--bs-btn-active-bg: inherit;
|
||||
--bs-btn-active-border-color: inherit;
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
--bs-btn-color: #{pvar(--fg)};
|
||||
--bs-btn-border-color: #{pvar(--bg-secondary-450)};
|
||||
--bs-btn-hover-color: #fff;
|
||||
--bs-btn-hover-bg: #{pvar(--bg-secondary-450)};
|
||||
--bs-btn-hover-border-color: #{pvar(--bg-secondary-450)};
|
||||
|
||||
--bs-btn-active-color: #{pvar(--on-primary)};
|
||||
--bs-btn-active-bg: #{pvar(--primary)};
|
||||
--bs-btn-active-border-color: #{pvar(--primary)};
|
||||
}
|
||||
|
||||
.flex-auto {
|
||||
flex: auto;
|
||||
}
|
||||
|
@ -86,6 +94,12 @@ body {
|
|||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
.btn-group-vertical {
|
||||
label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dropdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
@ -285,17 +299,6 @@ body {
|
|||
font-size: $button-font-size;
|
||||
}
|
||||
|
||||
.btn-outline-secondary {
|
||||
border-color: pvar(--input-border-color);
|
||||
|
||||
&:focus-within,
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: #fff;
|
||||
background-color: #6c757d;
|
||||
}
|
||||
}
|
||||
|
||||
.form-control {
|
||||
color: pvar(--fg);
|
||||
background-color: pvar(--input-bg);
|
||||
|
|
|
@ -40,6 +40,11 @@ $badge-grey-dark: #2D3448;
|
|||
font-size: 100%;
|
||||
}
|
||||
|
||||
&.badge-small {
|
||||
font-size: 10px;
|
||||
padding: 1px 3px;
|
||||
}
|
||||
|
||||
&.badge-primary {
|
||||
color: pvar(--on-primary);
|
||||
background-color: pvar(--primary);
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue