1
0
Fork 0
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:
Chocobozzz 2025-04-24 14:51:07 +02:00
parent fac6b15ada
commit dd4027a10f
No known key found for this signature in database
GPG key ID: 583A612D890159BE
181 changed files with 5081 additions and 2061 deletions

View file

@ -1,3 +1,4 @@
import { NSFWPolicyType } from '@peertube/peertube-models'
import { browserSleep, go, setCheckboxEnabled } from '../utils' import { browserSleep, go, setCheckboxEnabled } from '../utils'
export class AdminConfigPage { export class AdminConfigPage {
@ -18,16 +19,16 @@ export class AdminConfigPage {
await $('h2=' + waitTitles[tab]).waitForDisplayed() await $('h2=' + waitTitles[tab]).waitForDisplayed()
} }
async updateNSFWSetting (newValue: 'do_not_list' | 'blur' | 'display') { async updateNSFWSetting (newValue: NSFWPolicyType) {
await this.navigateTo('instance-information') await this.navigateTo('instance-information')
const elem = $('#instanceDefaultNSFWPolicy') const elem = $(`#instanceDefaultNSFWPolicy-${newValue} + label`)
await elem.waitForDisplayed() await elem.waitForDisplayed()
await elem.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header await elem.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header
await elem.waitForClickable() await elem.waitForClickable()
return elem.selectByAttribute('value', newValue) return elem.click()
} }
async updateHomepage (newValue: string) { async updateHomepage (newValue: string) {

View file

@ -1,8 +1,7 @@
import { browserSleep, findParentElement, go } from '../utils' import { browserSleep, findParentElement, go } from '../utils'
export class AdminRegistrationPage { export class AdminRegistrationPage {
async navigateToRegistrationsList () {
async navigateToRegistratonsList () {
await go('/admin/moderation/registrations/list') await go('/admin/moderation/registrations/list')
await $('my-registration-list').waitForDisplayed() await $('my-registration-list').waitForDisplayed()
@ -31,5 +30,4 @@ export class AdminRegistrationPage {
await browserSleep(1000) await browserSleep(1000)
} }
} }

View file

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

View file

@ -1,7 +1,7 @@
import { NSFWPolicyType } from '@peertube/peertube-models'
import { getCheckbox } from '../utils' import { getCheckbox } from '../utils'
export class AnonymousSettingsPage { export class AnonymousSettingsPage {
async openSettings () { async openSettings () {
const link = await $('my-header .settings-button') const link = await $('my-header .settings-button')
await link.waitForClickable() await link.waitForClickable()
@ -10,10 +10,36 @@ export class AnonymousSettingsPage {
await $('my-user-video-settings').waitForDisplayed() 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 () { async clickOnP2PCheckbox () {
const p2p = await getCheckbox('p2pEnabled') const p2p = await getCheckbox('p2pEnabled')
await p2p.waitForClickable() await p2p.waitForClickable()
await p2p.click() await p2p.click()
} }
async updateNSFW (newValue: NSFWPolicyType) {
const nsfw = $(`#nsfwPolicy-${newValue} + label`)
await nsfw.waitForClickable()
await nsfw.click()
await $(`#nsfwPolicy-${newValue}:checked`).waitForExist()
}
async updateViolentFlag (newValue: NSFWPolicyType) {
const nsfw = $(`#nsfwFlagViolent-${newValue} + label`)
await nsfw.waitForClickable()
await nsfw.click()
await $(`#nsfwFlagViolent-${newValue}:checked`).waitForExist()
}
} }

View file

@ -1,3 +1,4 @@
import { NSFWPolicyType } from '@peertube/peertube-models'
import { getCheckbox, go, selectCustomSelect } from '../utils' import { getCheckbox, go, selectCustomSelect } from '../utils'
export class MyAccountPage { export class MyAccountPage {
@ -21,14 +22,26 @@ export class MyAccountPage {
return $('a[href="/my-account"]').click() return $('a[href="/my-account"]').click()
} }
async updateNSFW (newValue: 'do_not_list' | 'blur' | 'display') { async updateNSFW (newValue: NSFWPolicyType) {
const nsfw = $('#nsfwPolicy') const nsfw = $(`#nsfwPolicy-${newValue} + label`)
await nsfw.waitForDisplayed() await nsfw.waitForDisplayed()
await nsfw.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header await nsfw.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header
await nsfw.waitForClickable() 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() await this.submitVideoSettings()
} }
@ -55,6 +68,7 @@ export class MyAccountPage {
async updateEmail (email: string, password: string) { async updateEmail (email: string, password: string) {
const emailInput = $('my-account-change-email #new-email') const emailInput = $('my-account-change-email #new-email')
await emailInput.waitForDisplayed() await emailInput.waitForDisplayed()
await emailInput.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header
await emailInput.setValue(email) await emailInput.setValue(email)
const passwordInput = $('my-account-change-email #password') const passwordInput = $('my-account-change-email #password')

View file

@ -1,7 +1,6 @@
import { browserSleep, isIOS, isMobileDevice, isSafari } from '../utils' import { browserSleep, isIOS, isMobileDevice, isSafari } from '../utils'
export class PlayerPage { export class PlayerPage {
getWatchVideoPlayerCurrentTime () { getWatchVideoPlayerCurrentTime () {
const elem = $('video') const elem = $('video')
@ -10,7 +9,7 @@ export class PlayerPage {
: elem.getProperty('currentTime') : elem.getProperty('currentTime')
return p.then(t => parseInt(t + '', 10)) return p.then(t => parseInt(t + '', 10))
.then(t => Math.ceil(t)) .then(t => Math.ceil(t))
} }
waitUntilPlaylistInfo (text: string, maxTime: number) { 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) { async playAndPauseVideo (isAutoplay: boolean, waitUntilSec: number) {
// Autoplay is disabled on mobile and Safari // Autoplay is disabled on mobile and Safari
if (isIOS() || isSafari() || isMobileDevice() || isAutoplay === false) { if (isIOS() || isSafari() || isMobileDevice() || isAutoplay === false) {
@ -66,11 +69,31 @@ export class PlayerPage {
return this.clickOnPlayButton() return this.clickOnPlayButton()
} }
private async clickOnPlayButton () { getPlayButton () {
const playButton = () => $('.vjs-big-play-button') return $('.vjs-big-play-button')
}
await playButton().waitForClickable() getNSFWContentText () {
await playButton().click() 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) { async fillEmbedVideoPassword (videoPassword: string) {

View file

@ -1,7 +1,6 @@
import { getCheckbox } from '../utils' import { getCheckbox } from '../utils'
export class SignupPage { export class SignupPage {
getRegisterMenuButton () { getRegisterMenuButton () {
return $('.create-account-button') return $('.create-account-button')
} }
@ -47,7 +46,7 @@ export class SignupPage {
await $('#displayName').setValue(options.displayName || `${options.username} display name`) await $('#displayName').setValue(options.displayName || `${options.username} display name`)
await $('#username').setValue(options.username) 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` // Fix weird bug on firefox that "cannot scroll into view" when using just `setValue`
await $('#email').scrollIntoView({ block: 'center' }) await $('#email').scrollIntoView({ block: 'center' })

View file

@ -1,9 +1,7 @@
import { browserSleep, go } from '../utils' import { browserSleep, findParentElement, go } from '../utils'
export class VideoListPage { export class VideoListPage {
constructor (private isMobileDevice: boolean, private isSafari: boolean) { constructor (private isMobileDevice: boolean, private isSafari: boolean) {
} }
async goOnVideosList () { async goOnVideosList () {
@ -53,11 +51,12 @@ export class VideoListPage {
await this.waitForList() await this.waitForList()
} }
async getNSFWFilter () { async getNSFWFilterText () {
const el = $('.active-filter*=Sensitive') const el = $('.active-filter*=Sensitive')
await el.waitForDisplayed() await el.waitForDisplayed()
return el return el.getText()
} }
async getVideosListName () { async getVideosListName () {
@ -67,16 +66,39 @@ export class VideoListPage {
return texts.map(t => t.trim()) return texts.map(t => t.trim())
} }
videoExists (name: string) { isVideoDisplayed (name: string) {
return $('.video-name=' + name).isDisplayed() return $('.video-name=' + name).isDisplayed()
} }
async videoIsBlurred (name: string) { async isVideoBlurred (name: string) {
const filter = await $('.video-name=' + name).getCSSProperty('filter') const miniature = await this.getVideoMiniature(name)
const filter = await miniature.$('my-video-thumbnail img').getCSSProperty('filter')
return filter.value !== 'none' 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) { async clickOnVideo (videoName: string) {
const video = async () => { const video = async () => {
const videos = await $$('.videos .video-miniature .video-name').filter(async e => { const videos = await $$('.videos .video-miniature .video-name').filter(async e => {
@ -92,9 +114,8 @@ export class VideoListPage {
const elem = await video() const elem = await video()
return elem?.isClickable() return elem?.isClickable()
}); })
;(await video()).click()
(await video()).click()
await browser.waitUntil(async () => (await browser.getUrl()).includes('/w/')) await browser.waitUntil(async () => (await browser.getUrl()).includes('/w/'))
} }
@ -116,8 +137,4 @@ export class VideoListPage {
private waitForList () { private waitForList () {
return $('.videos .video-miniature .video-name').waitForDisplayed() return $('.videos .video-miniature .video-name').waitForDisplayed()
} }
private waitForTitle (title: string) {
return $('h1=' + title).waitForDisplayed()
}
} }

View file

@ -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 { export abstract class VideoManage {
async clickOnSave () { async clickOnSave () {
@ -19,13 +19,24 @@ export abstract class VideoManage {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async setAsNSFW () { async setAsNSFW (options: {
violent?: boolean
summary?: string
} = {}) {
await this.goOnPage('Moderation') await this.goOnPage('Moderation')
const checkbox = await getCheckbox('nsfw') const checkbox = await getCheckbox('nsfw')
await checkbox.waitForClickable() 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.waitForClickable()
await input.click() await input.click()
const nextDay = $('.p-datepicker-today + td > span') const nextMonth = $('.p-datepicker-next')
await nextDay.waitForClickable() await nextMonth.click()
await nextDay.click()
await nextDay.waitForDisplayed({ reverse: true }) await $('.p-datepicker-calendar td[aria-label="1"] > span').click()
await $('.p-datepicker-calendar').waitForDisplayed({ reverse: true, timeout: 15000 }) // Can be slow
} }
getScheduleInput () { getScheduleInput () {
@ -92,7 +104,7 @@ export abstract class VideoManage {
async getLiveState () { async getLiveState () {
await this.goOnPage('Live settings') await this.goOnPage('Live settings')
if (await isRadioSelected('#permanentLiveTrue')) return 'permanent' if (await isRadioSelected('permanentLiveTrue')) return 'permanent'
return 'normal' return 'normal'
} }

View file

@ -52,7 +52,7 @@ export class VideoPublishPage extends VideoManage {
await submit.click() await submit.click()
// Wait for the import to finish // Wait for the import to finish
await this.getSaveButton().waitForClickable() await this.getSaveButton().waitForClickable({ timeout: 15000 }) // Can be slow
} }
async publishLive () { async publishLive () {
@ -71,6 +71,7 @@ export class VideoPublishPage extends VideoManage {
await this.goOnPage('Main information') await this.goOnPage('Main information')
const nameInput = $('input#name') const nameInput = $('input#name')
await nameInput.scrollIntoView()
await nameInput.clearValue() await nameInput.clearValue()
await nameInput.setValue(videoName) await nameInput.setValue(videoName)

View file

@ -40,7 +40,7 @@ export class VideoWatchPage {
} }
isPrivacyWarningDisplayed () { isPrivacyWarningDisplayed () {
return $('my-privacy-concerns').isDisplayed() return $('.privacy-concerns-text').isDisplayed()
} }
async goOnAssociatedEmbed (passwordProtected = false) { async goOnAssociatedEmbed (passwordProtected = false) {
@ -74,6 +74,79 @@ export class VideoWatchPage {
return go(FIXTURE_URLS.HLS_PLAYLIST_EMBED) 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 () { async clickOnManage () {
await this.clickOnMoreDropdownIcon() 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 () { clickOnSave () {
return $('.action-button-save').click() return $('.action-button-save').click()
} }
@ -116,69 +200,9 @@ export class VideoWatchPage {
return playlist().click() return playlist().click()
} }
async clickOnMoreDropdownIcon () { // ---------------------------------------------------------------------------
const dropdown = $('my-video-actions-dropdown .action-button') // Comments
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)
}
}
async createThread (comment: string) { async createThread (comment: string) {
const textarea = await $('my-video-comment-add textarea') const textarea = await $('my-video-comment-add textarea')

View file

@ -0,0 +1,393 @@
import { NSFWPolicyType } from '@peertube/peertube-models'
import { AdminConfigPage } from '../po/admin-config.po'
import { AdminUserPage } from '../po/admin-user.po'
import { AnonymousSettingsPage } from '../po/anonymous-settings.po'
import { LoginPage } from '../po/login.po'
import { MyAccountPage } from '../po/my-account.po'
import { PlayerPage } from '../po/player.po'
import { VideoListPage } from '../po/video-list.po'
import { VideoPublishPage } from '../po/video-publish.po'
import { VideoSearchPage } from '../po/video-search.po'
import { VideoWatchPage } from '../po/video-watch.po'
import { getScreenshotPath, go, isMobileDevice, isSafari, 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()
})
})
})
})

View file

@ -3,7 +3,7 @@ import { LoginPage } from '../po/login.po'
import { MyAccountPage } from '../po/my-account.po' import { MyAccountPage } from '../po/my-account.po'
import { VideoPublishPage } from '../po/video-publish.po' import { VideoPublishPage } from '../po/video-publish.po'
import { VideoWatchPage } from '../po/video-watch.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', () => { describe('Player settings', () => {
let videoPublishPage: VideoPublishPage let videoPublishPage: VideoPublishPage
@ -80,4 +80,8 @@ describe('Player settings', () => {
await checkP2P(false) await checkP2P(false)
}) })
}) })
after(async () => {
await browser.saveScreenshot(getScreenshotPath('after-test.png'))
})
}) })

View file

@ -1,22 +1,41 @@
import { LoginPage } from '../po/login.po' import { LoginPage } from '../po/login.po'
import { VideoPublishPage } from '../po/video-publish.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', () => { describe('Publish video', () => {
let videoPublishPage: VideoPublishPage let videoPublishPage: VideoPublishPage
let loginPage: LoginPage let loginPage: LoginPage
let videoWatchPage: VideoWatchPage
before(async () => { before(async () => {
await waitServerUp() await waitServerUp()
loginPage = new LoginPage(isMobileDevice()) loginPage = new LoginPage(isMobileDevice())
videoPublishPage = new VideoPublishPage() videoPublishPage = new VideoPublishPage()
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
await browser.maximizeWindow() await browser.maximizeWindow()
await loginPage.loginAsRootUser() 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 () { describe('Common', function () {
it('Should upload a video and on refresh being redirected to the manage page', async function () { it('Should upload a video and on refresh being redirected to the manage page', async function () {
await videoPublishPage.navigateTo() await videoPublishPage.navigateTo()

View file

@ -190,7 +190,7 @@ describe('Signup', () => {
}) })
it('Should validate the third step (account)', async function () { 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() await signupPage.validateStep()
}) })
@ -213,7 +213,7 @@ describe('Signup', () => {
}) })
it('Should display a message when trying to login with this account', async function () { 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') expect(error).toContain('awaiting approval')
}) })
@ -221,14 +221,14 @@ describe('Signup', () => {
it('Should accept the registration', async function () { it('Should accept the registration', async function () {
await loginPage.loginAsRootUser() await loginPage.loginAsRootUser()
await adminRegistrationPage.navigateToRegistratonsList() await adminRegistrationPage.navigateToRegistrationsList()
await adminRegistrationPage.accept('user_2', 'moderation response') await adminRegistrationPage.accept('user_2', 'moderation response')
await loginPage.logout() await loginPage.logout()
}) })
it('Should be able to login with this new account', async function () { 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() await loginPage.logout()
}) })
@ -335,7 +335,7 @@ describe('Signup', () => {
username: 'user_4', username: 'user_4',
displayName: 'user_4 display name', displayName: 'user_4 display name',
email: 'user_4@example.com', email: 'user_4@example.com',
password: 'password' password: 'superpassword'
}) })
await signupPage.validateStep() await signupPage.validateStep()
}) })
@ -359,7 +359,7 @@ describe('Signup', () => {
}) })
it('Should display a message when trying to login with this account', async function () { 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') expect(error).toContain('awaiting approval')
}) })
@ -367,7 +367,7 @@ describe('Signup', () => {
it('Should accept the registration', async function () { it('Should accept the registration', async function () {
await loginPage.loginAsRootUser() await loginPage.loginAsRootUser()
await adminRegistrationPage.navigateToRegistratonsList() await adminRegistrationPage.navigateToRegistrationsList()
await adminRegistrationPage.accept('user_4', 'moderation response 2') await adminRegistrationPage.accept('user_4', 'moderation response 2')
await loginPage.logout() await loginPage.logout()
@ -399,4 +399,8 @@ describe('Signup', () => {
MockSMTPServer.Instance.kill() MockSMTPServer.Instance.kill()
}) })
}) })
after(async () => {
await browser.saveScreenshot(getScreenshotPath('after-test.png'))
})
}) })

View file

@ -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()
})
})
})

View file

@ -1 +0,0 @@
export type NSFWPolicy = 'do_not_list' | 'blur' | 'display'

View file

@ -41,19 +41,30 @@ export async function selectCustomSelect (id: string, valueLabel: string) {
await wrapper.waitForClickable() await wrapper.waitForClickable()
await wrapper.click() await wrapper.click()
const option = await $$(`[formcontrolname=${id}] li[role=option]`).filter(async o => { const getOption = async () => {
const text = await o.getText() const options = await $$(`[formcontrolname=${id}] li[role=option]`).filter(async o => {
const text = await o.getText()
return text.trimStart().startsWith(valueLabel) return text.trimStart().startsWith(valueLabel)
}).then(options => options[0]) })
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 ( export async function findParentElement (
el: WebdriverIO.Element, el: ChainablePromiseElement,
finder: (el: WebdriverIO.Element) => Promise<boolean> finder: (el: WebdriverIO.Element) => Promise<boolean>
) { ) {
if (await finder(el) === true) return el if (await finder(el) === true) return el

View file

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

View file

@ -102,13 +102,7 @@ export const config = {
bail: true bail: true
}, },
autoCompileOpts: { tsConfigPath: require('path').join(__dirname, './tsconfig.json'),
autoCompile: true,
tsNodeOpts: {
project: require('path').join(__dirname, './tsconfig.json')
}
},
before: function () { before: function () {
require('./src/commands/upload') require('./src/commands/upload')

View file

@ -75,12 +75,12 @@
"@types/video.js": "^7.3.40", "@types/video.js": "^7.3.40",
"@typescript-eslint/eslint-plugin": "^7.0.2", "@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2", "@typescript-eslint/parser": "^7.0.2",
"@wdio/browserstack-service": "^8.10.5", "@wdio/browserstack-service": "^9.12.7",
"@wdio/cli": "^8.10.5", "@wdio/cli": "^9.12.7",
"@wdio/local-runner": "^8.10.5", "@wdio/local-runner": "^9.12.7",
"@wdio/mocha-framework": "^8.10.4", "@wdio/mocha-framework": "^9.12.6",
"@wdio/shared-store-service": "^8.10.5", "@wdio/shared-store-service": "^9.12.7",
"@wdio/spec-reporter": "^8.10.5", "@wdio/spec-reporter": "^9.12.6",
"angularx-qrcode": "19.0.0", "angularx-qrcode": "19.0.0",
"bootstrap": "^5.1.3", "bootstrap": "^5.1.3",
"buffer": "^6.0.3", "buffer": "^6.0.3",

View file

@ -11,7 +11,7 @@
<span *ngFor="let category of categories" class="pt-badge badge-secondary me-1">{{ category }}</span> <span *ngFor="let category of categories" class="pt-badge badge-secondary me-1">{{ category }}</span>
</div> </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>
<div class="block description"> <div class="block description">

View file

@ -59,9 +59,7 @@ export class AccountVideoChannelsComponent implements OnInit, OnDestroy {
views: true, views: true,
by: false, by: false,
avatar: false, avatar: false,
privacyLabel: false, privacyLabel: false
privacyText: false,
blacklistInfo: false
} }
private accountSub: Subscription private accountSub: Subscription
@ -109,7 +107,7 @@ export class AccountVideoChannelsComponent implements OnInit, OnDestroy {
nsfw: this.videoService.nsfwPolicyToParam(this.nsfwPolicy) 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 }))) .pipe(map(data => ({ videoChannel, videos: data.data, total: data.total })))
}) })
) )

View file

@ -59,7 +59,7 @@ export class AccountVideosComponent implements OnInit, OnDestroy, DisableForReus
skipCount: true skipCount: true
} }
return this.videoService.getAccountVideos(options) return this.videoService.listAccountVideos(options)
} }
getSyndicationItems () { getSyndicationItems () {

View file

@ -238,7 +238,7 @@ export class AccountsComponent implements OnInit, OnDestroy {
} }
private loadAccountVideosCount () { private loadAccountVideosCount () {
this.videoService.getAccountVideos({ this.videoService.listAccountVideos({
account: this.account, account: this.account,
videoPagination: { videoPagination: {
currentPage: 1, currentPage: 1,

View file

@ -174,9 +174,9 @@
</div> </div>
<div class="pt-two-cols mt-4"> <!-- moderation & nsfw grid --> <div class="pt-two-cols mt-4"> <!-- moderation grid -->
<div class="title-col"> <div class="title-col">
<h2 i18n>MODERATION & NSFW</h2> <h2 i18n>MODERATION & SENSITIVE CONTENT</h2>
<div i18n class="inner-form-description"> <div i18n class="inner-form-description">
Manage <a class="link-primary" routerLink="/admin/overview/users">users</a> to build a moderation team. Manage <a class="link-primary" routerLink="/admin/overview/users">users</a> to build a moderation team.
</div> </div>
@ -186,34 +186,24 @@
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox inputName="instanceIsNSFW" formControlName="isNSFW"> <my-peertube-checkbox inputName="instanceIsNSFW" formControlName="isNSFW">
<ng-template ptTemplate="label"> <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>
<ng-template ptTemplate="help"> <ng-template ptTemplate="help">
<ng-container i18n> <ng-container i18n>
Enabling it will allow other administrators to know that you are mainly federating sensitive content.<br /> 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-container>
</ng-template> </ng-template>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
<div class="form-group"> <div class="form-group">
<label i18n for="instanceDefaultNSFWPolicy">Policy on videos containing sensitive content</label> <my-select-radio
[items]="nsfwItems" inputId="instanceDefaultNSFWPolicy" isGroup="true"
<my-help> i18n-label label="Policy on videos containing sensitive content"
<ng-container i18n> formControlName="defaultNSFWPolicy"
With <strong>Hide</strong> or <strong>Blur thumbnails</strong>, a confirmation will be requested to watch the video. ></my-select-radio>
</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>
<div *ngIf="formErrors().instance.defaultNSFWPolicy" class="form-error" role="alert">{{ formErrors().instance.defaultNSFWPolicy }}</div> <div *ngIf="formErrors().instance.defaultNSFWPolicy" class="form-error" role="alert">{{ formErrors().instance.defaultNSFWPolicy }}</div>
</div> </div>
@ -238,7 +228,7 @@
<div class="form-group"> <div class="form-group">
<label i18n for="instanceModerationInformation">Moderation information</label><my-help helpType="markdownText"></my-help> <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 <my-markdown-textarea
inputId="instanceModerationInformation" formControlName="moderationInformation" markdownType="enhanced" inputId="instanceModerationInformation" formControlName="moderationInformation" markdownType="enhanced"

View file

@ -1,4 +1,4 @@
import { NgClass, NgIf } from '@angular/common' import { CommonModule } from '@angular/common'
import { HttpErrorResponse } from '@angular/common/http' import { HttpErrorResponse } from '@angular/common/http'
import { Component, OnInit, inject, input } from '@angular/core' import { Component, OnInit, inject, input } from '@angular/core'
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
@ -6,6 +6,7 @@ import { RouterLink } from '@angular/router'
import { Notifier, ServerService } from '@app/core' import { Notifier, ServerService } from '@app/core'
import { genericUploadErrorHandler } from '@app/helpers' import { genericUploadErrorHandler } from '@app/helpers'
import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service' 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 { InstanceService } from '@app/shared/shared-main/instance/instance.service'
import { maxBy } from '@peertube/peertube-core-utils' import { maxBy } from '@peertube/peertube-core-utils'
import { ActorImage, HTMLServerConfig } from '@peertube/peertube-models' 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 { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component'
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component' import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
import { SelectCheckboxComponent } from '../../../shared/shared-forms/select/select-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 { HelpComponent } from '../../../shared/shared-main/buttons/help.component'
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/common/peertube-template.directive'
@Component({ @Component({
selector: 'my-edit-instance-information', selector: 'my-edit-instance-information',
@ -28,8 +29,8 @@ import { HelpComponent } from '../../../shared/shared-main/buttons/help.componen
ReactiveFormsModule, ReactiveFormsModule,
ActorAvatarEditComponent, ActorAvatarEditComponent,
ActorBannerEditComponent, ActorBannerEditComponent,
NgClass, SelectRadioComponent,
NgIf, CommonModule,
CustomMarkupHelpComponent, CustomMarkupHelpComponent,
MarkdownTextareaComponent, MarkdownTextareaComponent,
SelectCheckboxComponent, SelectCheckboxComponent,
@ -54,6 +55,25 @@ export class EditInstanceInformationComponent implements OnInit {
instanceBannerUrl: string instanceBannerUrl: string
instanceAvatars: ActorImage[] = [] 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 private serverConfig: HTMLServerConfig
get instanceName () { get instanceName () {

View file

@ -48,16 +48,15 @@
<td> <td>
<my-video-cell [video]="videoBlock.video" size="small"> <my-video-cell [video]="videoBlock.video" size="small">
<div> <div>
<span @if (videoBlock.type === 2) {
*ngIf="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>
i18n-title title="The video was blocked due to automatic blocking of new videos" class="pt-badge badge-info" i18n }
>Auto block</span>
</div> </div>
</my-video-cell> </my-video-cell>
</td> </td>
<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>
<td> <td>

View file

@ -19,6 +19,7 @@ import { AutoColspanDirective } from '../../../shared/shared-main/common/auto-co
import { EmbedComponent } from '../../../shared/shared-main/video/embed.component' import { EmbedComponent } from '../../../shared/shared-main/video/embed.component'
import { TableExpanderIconComponent } from '../../../shared/shared-tables/table-expander-icon.component' import { TableExpanderIconComponent } from '../../../shared/shared-tables/table-expander-icon.component'
import { VideoCellComponent } from '../../../shared/shared-tables/video-cell.component' import { VideoCellComponent } from '../../../shared/shared-tables/video-cell.component'
import { VideoNSFWBadgeComponent } from '../../../shared/shared-video/video-nsfw-badge.component'
@Component({ @Component({
selector: 'my-video-block-list', selector: 'my-video-block-list',
@ -36,7 +37,8 @@ import { VideoCellComponent } from '../../../shared/shared-tables/video-cell.com
VideoCellComponent, VideoCellComponent,
AutoColspanDirective, AutoColspanDirective,
EmbedComponent, EmbedComponent,
PTDatePipe PTDatePipe,
VideoNSFWBadgeComponent
] ]
}) })
export class VideoBlockListComponent extends RestTable implements OnInit { export class VideoBlockListComponent extends RestTable implements OnInit {

View file

@ -74,7 +74,7 @@
<my-video-privacy-badge [video]="video"></my-video-privacy-badge> <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> <span *ngIf="isUnpublished(video)" class="pt-badge badge-yellow">{{ video.state.label }}</span>

View file

@ -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 { VideoBlockService } from '@app/shared/shared-moderation/video-block.service'
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap' import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
import { getAllFiles } from '@peertube/peertube-core-utils' 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 { videoRequiresFileToken } from '@root-helpers/video'
import { SharedModule, SortMeta } from 'primeng/api' import { SharedModule, SortMeta } from 'primeng/api'
import { TableModule, TableRowExpandEvent } from 'primeng/table' import { TableModule, TableRowExpandEvent } from 'primeng/table'
@ -31,6 +31,7 @@ import {
VideoActionsDisplayType, VideoActionsDisplayType,
VideoActionsDropdownComponent VideoActionsDropdownComponent
} from '../../../shared/shared-video-miniature/video-actions-dropdown.component' } 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 { VideoPrivacyBadgeComponent } from '../../../shared/shared-video/video-privacy-badge.component'
import { VideoAdminService } from './video-admin.service' import { VideoAdminService } from './video-admin.service'
@ -58,7 +59,8 @@ import { VideoAdminService } from './video-admin.service'
PTDatePipe, PTDatePipe,
RouterLink, RouterLink,
BytesPipe, BytesPipe,
VideoPrivacyBadgeComponent VideoPrivacyBadgeComponent,
VideoNSFWBadgeComponent
] ]
}) })
export class VideoListComponent extends RestTable<Video> implements OnInit { export class VideoListComponent extends RestTable<Video> implements OnInit {
@ -305,7 +307,11 @@ export class VideoListComponent extends RestTable<Video> implements OnInit {
this.videoAdminService.getAdminVideos({ this.videoAdminService.getAdminVideos({
pagination: this.pagination, pagination: this.pagination,
sort: this.sort, 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 search: this.search
}).pipe(finalize(() => this.loading = false)) }).pipe(finalize(() => this.loading = false))
.subscribe({ .subscribe({

View file

@ -28,7 +28,7 @@
color: pvar(--fg-400); color: pvar(--fg-400);
&[iconName=npm] { &[iconName=npm] {
@include fill-svg-color(pvar(--fg-400)); @include fill-path-svg-color(pvar(--fg-400));
} }
} }
} }

View file

@ -57,9 +57,7 @@ export class MyHistoryComponent implements OnInit, DisableForReuseHook {
date: true, date: true,
views: true, views: true,
by: true, by: true,
privacyLabel: false, privacyLabel: false
privacyText: true,
blacklistInfo: true
} }
getVideosObservableFunction = this.getVideosObservable.bind(this) getVideosObservableFunction = this.getVideosObservable.bind(this)

View file

@ -145,7 +145,7 @@
</td> </td>
<td *ngIf="isSelected('sensitive')"> <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>
<td *ngIf="isSelected('playlists')"> <td *ngIf="isSelected('playlists')">

View file

@ -49,6 +49,7 @@ import {
VideoActionsDisplayType, VideoActionsDisplayType,
VideoActionsDropdownComponent VideoActionsDropdownComponent
} from '../../shared/shared-video-miniature/video-actions-dropdown.component' } 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 { VideoPrivacyBadgeComponent } from '../../shared/shared-video/video-privacy-badge.component'
import { VideoStateBadgeComponent } from '../../shared/shared-video/video-state-badge.component' import { VideoStateBadgeComponent } from '../../shared/shared-video/video-state-badge.component'
import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component' import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component'
@ -95,7 +96,8 @@ type QueryParams = {
ChannelToggleComponent, ChannelToggleComponent,
AutoColspanDirective, AutoColspanDirective,
SelectCheckboxComponent, SelectCheckboxComponent,
PTDatePipe PTDatePipe,
VideoNSFWBadgeComponent
] ]
}) })
export class MyVideosComponent extends RestTable<Video> implements OnInit, OnDestroy { export class MyVideosComponent extends RestTable<Video> implements OnInit, OnDestroy {

View file

@ -35,25 +35,6 @@
</div> </div>
</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="form-group">
<div class="radio-label label-container"> <div class="radio-label label-container">
<label for="publishedDateRange" i18n>Published date</label> <label for="publishedDateRange" i18n>Published date</label>

View file

@ -74,9 +74,7 @@ export class SearchComponent implements OnInit, OnDestroy {
views: true, views: true,
by: true, by: true,
avatar: true, avatar: true,
privacyLabel: false, privacyLabel: false
privacyText: false,
blacklistInfo: false
} }
errorMessage: string errorMessage: string

View file

@ -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 { AfterViewInit, Component, OnDestroy, OnInit, inject, viewChild } from '@angular/core'
import { ComponentPaginationLight, DisableForReuseHook, HooksService, ScreenService } from '@app/core' import { ComponentPaginationLight, DisableForReuseHook, HooksService, ScreenService } from '@app/core'
import { VideoChannel } from '@app/shared/shared-main/channel/video-channel.model' import { VideoChannel } from '@app/shared/shared-main/channel/video-channel.model'
@ -12,7 +12,7 @@ import { VideosListComponent } from '../../shared/shared-video-miniature/videos-
@Component({ @Component({
selector: 'my-video-channel-videos', selector: 'my-video-channel-videos',
templateUrl: './video-channel-videos.component.html', templateUrl: './video-channel-videos.component.html',
imports: [ NgIf, VideosListComponent ] imports: [ CommonModule, VideosListComponent ]
}) })
export class VideoChannelVideosComponent implements OnInit, AfterViewInit, OnDestroy, DisableForReuseHook { export class VideoChannelVideosComponent implements OnInit, AfterViewInit, OnDestroy, DisableForReuseHook {
private screenService = inject(ScreenService) private screenService = inject(ScreenService)
@ -65,7 +65,7 @@ export class VideoChannelVideosComponent implements OnInit, AfterViewInit, OnDes
skipCount: true skipCount: true
} }
return this.videoService.getVideoChannelVideos(params) return this.videoService.listChannelVideos(params)
} }
getSyndicationItems () { getSyndicationItems () {

View file

@ -176,7 +176,7 @@ export class VideoChannelsComponent implements OnInit, OnDestroy {
} }
private loadChannelVideosCount () { private loadChannelVideosCount () {
this.videoService.getVideoChannelVideos({ this.videoService.listChannelVideos({
videoChannel: this.videoChannel, videoChannel: this.videoChannel,
videoPagination: { videoPagination: {
currentPage: 1, currentPage: 1,

View file

@ -58,7 +58,7 @@ export class VideosListAllComponent implements OnInit, OnDestroy, DisableForReus
} }
return this.hooks.wrapObsFun( return this.hooks.wrapObsFun(
this.videoService.getVideos.bind(this.videoService), this.videoService.listVideos.bind(this.videoService),
params, params,
'common', 'common',
'filter:api.browse-videos.videos.list.params', 'filter:api.browse-videos.videos.list.params',

View file

@ -14,6 +14,6 @@
my-global-icon { my-global-icon {
color: pvar(--active-icon-color) !important; color: pvar(--active-icon-color) !important;
@include fill-svg-color(pvar(--active-icon-bg)); @include fill-path-svg-color(pvar(--active-icon-bg));
} }
} }

View file

@ -59,15 +59,10 @@ export class VideoRecommendationService {
return this.userService.getAnonymousOrLoggedUser() return this.userService.getAnonymousOrLoggedUser()
.pipe( .pipe(
switchMap(user => { switchMap(user => {
const nsfw = user.nsfwPolicy const defaultSubscription = this.videos.listVideos({
? this.videos.nsfwPolicyToParam(user.nsfwPolicy)
: undefined
const defaultSubscription = this.videos.getVideos({
skipCount: true, skipCount: true,
videoPagination: pagination, videoPagination: pagination,
sort: '-publishedAt', sort: '-publishedAt'
nsfw
}).pipe(map(v => v.data)) }).pipe(map(v => v.data))
const searchIndexConfig = this.config.search.searchIndex const searchIndexConfig = this.config.search.searchIndex
@ -83,7 +78,6 @@ export class VideoRecommendationService {
tagsOneOf: currentVideo.tags.join(','), tagsOneOf: currentVideo.tags.join(','),
sort: '-publishedAt', sort: '-publishedAt',
searchTarget: 'local', searchTarget: 'local',
nsfw,
excludeAlreadyWatched: user.id excludeAlreadyWatched: user.id
? true ? true
: undefined : undefined

View file

@ -32,7 +32,7 @@
<my-plugin-placeholder pluginId="player-next"></my-plugin-placeholder> <my-plugin-placeholder pluginId="player-next"></my-plugin-placeholder>
</div> </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> </div>
<!-- Video information --> <!-- Video information -->
@ -110,7 +110,7 @@
class="border-top pt-3" class="border-top pt-3"
[video]="video" [video]="video"
[videoPassword]="videoPassword" [videoPassword]="videoPassword"
[user]="user" [user]="authUser"
(timestampClicked)="handleTimestampClicked($event)" (timestampClicked)="handleTimestampClicked($event)"
></my-video-comments> ></my-video-comments>
</div> </div>

View file

@ -185,7 +185,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
private hotkeys: Hotkey[] = [] private hotkeys: Hotkey[] = []
get user () { get authUser () {
return this.authService.getUser() return this.authService.getUser()
} }
@ -276,11 +276,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
} }
isUserOwner () { isUserOwner () {
return this.video.isLocal === true && this.video.account.name === this.user?.username return this.video.isLocal === true && this.video.account.name === this.authUser?.username
}
isVideoBlur (video: Video) {
return video.isVideoNSFWForUser(this.user, this.serverConfig)
} }
isChannelDisplayNameGeneric () { isChannelDisplayNameGeneric () {
@ -526,7 +522,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.transcriptionWidgetOpened = false this.transcriptionWidgetOpened = false
} }
if (this.isVideoBlur(this.video)) { if (this.video.isVideoNSFWHiddenForUser(loggedInOrAnonymousUser, this.serverConfig)) {
const res = await this.confirmService.confirm( const res = await this.confirmService.confirm(
$localize`This video contains mature or explicit content. Are you sure you want to watch it?`, $localize`This video contains mature or explicit content. Are you sure you want to watch it?`,
$localize`Mature or explicit content` $localize`Mature or explicit content`
@ -556,7 +552,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
const videoState = this.video.state.id const videoState = this.video.state.id
if (videoState === VideoState.LIVE_ENDED || videoState === VideoState.WAITING_FOR_LIVE) { if (videoState === VideoState.LIVE_ENDED || videoState === VideoState.WAITING_FOR_LIVE) {
this.updatePlayerOnNoLive() this.updatePlayerOnNoLive({ hasPlayed: false })
return return
} }
@ -573,7 +569,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
urlOptions: this.getUrlOptions(), urlOptions: this.getUrlOptions(),
loggedInOrAnonymousUser, loggedInOrAnonymousUser,
forceAutoplay, forceAutoplay,
user: this.user user: this.authUser
} }
const loadOptions = await this.hooks.wrapFun( 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 // We'll jump to the thread id, so do not play the video
if (this.route.snapshot.params['threadId']) return false 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') 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: { private buildPeerTubePlayerConstructorOptions (options: {
urlOptions: URLOptions urlOptions: URLOptions
}): PeerTubePlayerConstructorOptions { }): PeerTubePlayerConstructorOptions {
@ -821,11 +804,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
return { return {
mode, mode,
autoplay: this.isAutoplay(), autoplay: this.isAutoplay(loggedInOrAnonymousUser),
forceAutoplay, forceAutoplay,
duration: this.video.duration, duration: this.video.duration,
poster: video.previewUrl,
p2pEnabled: isP2PEnabled(video, this.serverConfig, loggedInOrAnonymousUser.p2pEnabled), p2pEnabled: isP2PEnabled(video, this.serverConfig, loggedInOrAnonymousUser.p2pEnabled),
startTime, startTime,
@ -846,9 +828,20 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
videoFileToken: () => videoFileToken, videoFileToken: () => videoFileToken,
requiresUserAuth: videoRequiresUserAuth(video, videoPassword), requiresUserAuth: videoRequiresUserAuth(video, videoPassword),
requiresPassword: video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED && requiresPassword: video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED &&
!video.canBypassPassword(this.user), !video.canBypassPassword(this.authUser),
videoPassword: () => videoPassword, 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, videoCaptions: playerCaptions,
videoChapters, videoChapters,
storyboard, storyboard,
@ -877,9 +870,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
upnext: { upnext: {
isEnabled: () => { isEnabled: () => {
if (this.playlist) return this.isPlaylistAutoPlayNext() if (this.playlist) return loggedInOrAnonymousUser?.autoPlayNextVideoPlaylist
return this.isAutoPlayNext() return loggedInOrAnonymousUser?.autoPlayNextVideo
}, },
isSuspended: (player: videojs.Player) => { isSuspended: (player: videojs.Player) => {
@ -943,10 +936,13 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.video.viewers = newViewers this.video.viewers = newViewers
} }
private updatePlayerOnNoLive () { private updatePlayerOnNoLive ({ hasPlayed }: { hasPlayed: boolean }) {
this.peertubePlayer.unload() this.peertubePlayer.unload()
this.peertubePlayer.disable() 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) { private buildHotkeysHelp (video: Video) {
@ -1039,6 +1035,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.video.state.id = VideoState.LIVE_ENDED this.video.state.id = VideoState.LIVE_ENDED
this.updatePlayerOnNoLive() this.updatePlayerOnNoLive({ hasPlayed: true })
} }
} }

View file

@ -5,6 +5,7 @@ import {
LiveVideo, LiveVideo,
LiveVideoCreate, LiveVideoCreate,
LiveVideoUpdate, LiveVideoUpdate,
NSFWFlag,
VideoCaption, VideoCaption,
VideoChapter, VideoChapter,
VideoCreate, VideoCreate,
@ -31,12 +32,19 @@ const debugLogger = debug('peertube:video-manage:video-edit')
export type VideoEditPrivacyType = VideoPrivacyType | typeof VideoEdit.SPECIAL_SCHEDULED_PRIVACY export type VideoEditPrivacyType = VideoPrivacyType | typeof VideoEdit.SPECIAL_SCHEDULED_PRIVACY
type CommonUpdateForm = type CommonUpdateForm =
& Omit<VideoUpdate, 'privacy' | 'videoPasswords' | 'thumbnailfile' | 'scheduleUpdate' | 'commentsEnabled' | 'originallyPublishedAt'> & Omit<
VideoUpdate,
'privacy' | 'videoPasswords' | 'thumbnailfile' | 'scheduleUpdate' | 'commentsEnabled' | 'originallyPublishedAt' | 'nsfwFlags'
>
& { & {
schedulePublicationAt?: Date schedulePublicationAt?: Date
originallyPublishedAt?: Date originallyPublishedAt?: Date
privacy?: VideoEditPrivacyType privacy?: VideoEditPrivacyType
videoPassword?: string videoPassword?: string
nsfwFlagViolent?: boolean
nsfwFlagSex?: boolean
nsfwFlagShocking?: boolean
} }
type LiveUpdateForm = Omit<LiveVideoUpdate, 'replaySettings'> & { type LiveUpdateForm = Omit<LiveVideoUpdate, 'replaySettings'> & {
@ -82,6 +90,8 @@ type UpdateFromAPIOptions = {
| 'description' | 'description'
| 'tags' | 'tags'
| 'nsfw' | 'nsfw'
| 'nsfwFlags'
| 'nsfwSummary'
| 'waitTranscoding' | 'waitTranscoding'
| 'support' | 'support'
| 'commentsPolicy' | 'commentsPolicy'
@ -317,6 +327,8 @@ export class VideoEdit {
description: video.description ?? '', description: video.description ?? '',
tags: video.tags ?? [], tags: video.tags ?? [],
nsfw: video.nsfw ?? null, nsfw: video.nsfw ?? null,
nsfwSummary: video.nsfwSummary ?? null,
nsfwFlags: video.nsfwFlags ?? NSFWFlag.NONE,
waitTranscoding: video.waitTranscoding ?? null, waitTranscoding: video.waitTranscoding ?? null,
support: video.support ?? '', support: video.support ?? '',
commentsPolicy: video.commentsPolicy?.id ?? null, commentsPolicy: video.commentsPolicy?.id ?? null,
@ -430,7 +442,6 @@ export class VideoEdit {
if (values.language !== undefined) this.common.language = values.language if (values.language !== undefined) this.common.language = values.language
if (values.description !== undefined) this.common.description = values.description if (values.description !== undefined) this.common.description = values.description
if (values.tags !== undefined) this.common.tags = values.tags 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.waitTranscoding !== undefined) this.common.waitTranscoding = values.waitTranscoding
if (values.support !== undefined) this.common.support = values.support if (values.support !== undefined) this.common.support = values.support
if (values.commentsPolicy !== undefined) this.common.commentsPolicy = values.commentsPolicy 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.previewfile !== undefined) this.common.previewfile = values.previewfile
if (values.pluginData !== undefined) this.common.pluginData = values.pluginData 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) { if (values.videoPassword !== undefined) {
this.common.videoPasswords = values.privacy === VideoPrivacy.PASSWORD_PROTECTED && values.videoPassword this.common.videoPasswords = values.privacy === VideoPrivacy.PASSWORD_PROTECTED && values.videoPassword
? [ values.videoPassword ] ? [ values.videoPassword ]
@ -483,7 +529,13 @@ export class VideoEdit {
support: this.common.support, support: this.common.support,
name: this.common.name, name: this.common.name,
tags: this.common.tags, tags: this.common.tags,
nsfw: this.common.nsfw, 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, commentsPolicy: this.common.commentsPolicy,
waitTranscoding: this.common.waitTranscoding, waitTranscoding: this.common.waitTranscoding,
channelId: this.common.channelId, channelId: this.common.channelId,
@ -550,6 +602,8 @@ export class VideoEdit {
tags: this.common.tags, tags: this.common.tags,
nsfw: this.common.nsfw, nsfw: this.common.nsfw,
nsfwFlags: this.common.nsfwFlags,
nsfwSummary: this.common.nsfwSummary || null,
waitTranscoding: this.common.waitTranscoding, waitTranscoding: this.common.waitTranscoding,
commentsPolicy: this.common.commentsPolicy, commentsPolicy: this.common.commentsPolicy,
downloadEnabled: this.common.downloadEnabled, downloadEnabled: this.common.downloadEnabled,

View file

@ -9,27 +9,54 @@
<div class="form-columns"> <div class="form-columns">
<div> <div>
<div class="form-group"> <div class="form-group">
<label i18n for="commentsPolicy">Comments policy</label> <my-select-radio
i18n-label label="Comments policy"
<div class="form-group-description" i18n> [items]="commentPolicies"
You can require comments to be approved depending on <a routerLink="/my-account/auto-tag-policies" target="_blank">your auto-tags policies</a> inputId="commentsPolicy"
</div> formControlName="commentsPolicy"
>
<my-select-options inputId="commentsPolicy" [items]="commentPolicies" formControlName="commentsPolicy"></my-select-options> <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 *ngIf="formErrors.commentsPolicy" class="form-error" role="alert"> </div>
{{ formErrors.commentsPolicy }} </my-select-radio>
</div>
</div> </div>
<my-peertube-checkbox inputName="nsfw" formControlName="nsfw" helpPlacement="bottom-right"> <my-peertube-checkbox inputName="nsfw" formControlName="nsfw">
<ng-template ptTemplate="label"> <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>
<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-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> </my-peertube-checkbox>
</div> </div>
</div> </div>

View file

@ -1,16 +1,17 @@
import { NgIf } from '@angular/common' import { CommonModule } from '@angular/common'
import { Component, OnDestroy, OnInit, inject } from '@angular/core' import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterLink } from '@angular/router' import { RouterLink } from '@angular/router'
import { ServerService } from '@app/core' import { ServerService } from '@app/core'
import { BuildFormArgument } from '@app/shared/form-validators/form-validator.model' 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 { FormReactiveErrors, FormReactiveService, FormReactiveValidationMessages } from '@app/shared/shared-forms/form-reactive.service'
import { HTMLServerConfig, VideoCommentPolicyType, VideoConstant } from '@peertube/peertube-models' import { HTMLServerConfig, VideoCommentPolicyType, VideoConstant } from '@peertube/peertube-models'
import debug from 'debug' import debug from 'debug'
import { CalendarModule } from 'primeng/calendar' import { CalendarModule } from 'primeng/calendar'
import { Subscription } from 'rxjs' import { Subscription } from 'rxjs'
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component' 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 { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component'
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/common/peertube-template.directive' import { PeerTubeTemplateDirective } from '../../../shared/shared-main/common/peertube-template.directive'
import { VideoManageController } from '../video-manage-controller.service' import { VideoManageController } from '../video-manage-controller.service'
@ -19,6 +20,12 @@ const debugLogger = debug('peertube:video-manage')
type Form = { type Form = {
nsfw: FormControl<boolean> nsfw: FormControl<boolean>
nsfwFlagViolent: FormControl<boolean>
nsfwFlagShocking: FormControl<boolean>
nsfwFlagSex: FormControl<boolean>
nsfwSummary: FormControl<string>
commentPolicies: FormControl<VideoCommentPolicyType> commentPolicies: FormControl<VideoCommentPolicyType>
} }
@ -29,15 +36,15 @@ type Form = {
], ],
templateUrl: './video-moderation.component.html', templateUrl: './video-moderation.component.html',
imports: [ imports: [
CommonModule,
RouterLink, RouterLink,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
NgIf,
PeerTubeTemplateDirective, PeerTubeTemplateDirective,
SelectOptionsComponent,
CalendarModule, CalendarModule,
PeertubeCheckboxComponent, PeertubeCheckboxComponent,
GlobalIconComponent GlobalIconComponent,
SelectRadioComponent
] ]
}) })
export class VideoModerationComponent implements OnInit, OnDestroy { export class VideoModerationComponent implements OnInit, OnDestroy {
@ -71,7 +78,14 @@ export class VideoModerationComponent implements OnInit, OnDestroy {
const videoEdit = this.manageController.getStore().videoEdit const videoEdit = this.manageController.getStore().videoEdit
const defaultValues = videoEdit.toCommonFormPatch() 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 { const {
form, form,
@ -97,5 +111,35 @@ export class VideoModerationComponent implements OnInit, OnDestroy {
this.updatedSub = this.manageController.getUpdatedObs().subscribe(() => { this.updatedSub = this.manageController.getUpdatedObs().subscribe(() => {
this.form.patchValue(videoEdit.toCommonFormPatch()) 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()
}
} }
} }

View file

@ -1,11 +1,9 @@
import { Injectable, inject } from '@angular/core' import { Injectable, inject } from '@angular/core'
import { sortBy } from '@peertube/peertube-core-utils'
import { HTMLServerConfig, ServerConfig, ServerConfigTheme } from '@peertube/peertube-models' import { HTMLServerConfig, ServerConfig, ServerConfigTheme } from '@peertube/peertube-models'
import { logger } from '@root-helpers/logger' import { logger } from '@root-helpers/logger'
import { capitalizeFirstLetter } from '@root-helpers/string' import { capitalizeFirstLetter } from '@root-helpers/string'
import { ThemeManager } from '@root-helpers/theme-manager'
import { UserLocalStorageKeys } from '@root-helpers/users' import { UserLocalStorageKeys } from '@root-helpers/users'
import { getLuminance, parse, toHSLA } from 'color-bits'
import debug from 'debug'
import { environment } from '../../../environments/environment' import { environment } from '../../../environments/environment'
import { AuthService } from '../auth' import { AuthService } from '../auth'
import { PluginService } from '../plugins/plugin.service' import { PluginService } from '../plugins/plugin.service'
@ -13,8 +11,6 @@ import { ServerService } from '../server'
import { UserService } from '../users/user.service' import { UserService } from '../users/user.service'
import { LocalStorageService } from '../wrappers/storage.service' import { LocalStorageService } from '../wrappers/storage.service'
const debugLogger = debug('peertube:theme')
@Injectable() @Injectable()
export class ThemeService { export class ThemeService {
private auth = inject(AuthService) private auth = inject(AuthService)
@ -23,7 +19,6 @@ export class ThemeService {
private server = inject(ServerService) private server = inject(ServerService)
private localStorageService = inject(LocalStorageService) private localStorageService = inject(LocalStorageService)
private oldInjectedProperties: string[] = []
private oldThemeName: string private oldThemeName: string
private internalThemes: string[] = [] private internalThemes: string[] = []
@ -34,6 +29,8 @@ export class ThemeService {
private serverConfig: HTMLServerConfig private serverConfig: HTMLServerConfig
private themeManager = new ThemeManager()
initialize () { initialize () {
this.serverConfig = this.server.getHTMLConfig() this.serverConfig = this.server.getHTMLConfig()
this.internalThemes = this.serverConfig.theme.builtIn.map(t => t.name) this.internalThemes = this.serverConfig.theme.builtIn.map(t => t.name)
@ -80,30 +77,19 @@ export class ThemeService {
logger.info(`Injecting ${this.themes.length} themes.`) logger.info(`Injecting ${this.themes.length} themes.`)
const head = this.getHeadElement()
for (const theme of this.themes) { for (const theme of this.themes) {
// Already added this theme? // Already added this theme?
if (fromLocalStorage === false && this.themeFromLocalStorage && this.themeFromLocalStorage.name === theme.name) continue if (fromLocalStorage === false && this.themeFromLocalStorage && this.themeFromLocalStorage.name === theme.name) continue
for (const css of theme.css) { const links = this.themeManager.injectTheme(theme, environment.apiUrl)
const link = document.createElement('link')
const href = environment.apiUrl + `/themes/${theme.name}/${theme.version}/css/${css}` if (fromLocalStorage === true) {
link.setAttribute('href', href) this.themeDOMLinksFromLocalStorage = [ ...this.themeDOMLinksFromLocalStorage, ...links ]
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)
} }
} }
} }
private getCurrentTheme () { private getCurrentThemeName () {
if (this.themeFromLocalStorage) return this.themeFromLocalStorage.name if (this.themeFromLocalStorage) return this.themeFromLocalStorage.name
const theme = this.auth.isLoggedIn() const theme = this.auth.isLoggedIn()
@ -123,43 +109,24 @@ export class ThemeService {
return instanceTheme 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 () { private updateCurrentTheme () {
const currentTheme = this.getCurrentTheme() const currentThemeName = this.getCurrentThemeName()
if (this.oldThemeName === currentTheme) return if (this.oldThemeName === currentThemeName) return
if (this.oldThemeName) this.removeThemePlugins(this.oldThemeName) 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)) { if (this.internalThemes.includes(currentThemeName)) {
logger.info(`Enabling internal theme ${currentTheme}`) 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) { } else if (theme) {
logger.info(`Adding scripts of theme ${currentTheme}`) logger.info(`Adding scripts of theme ${currentThemeName}`)
this.pluginService.addPlugin(theme, true) this.pluginService.addPlugin(theme, true)
@ -170,161 +137,9 @@ export class ThemeService {
this.localStorageService.removeItem(UserLocalStorageKeys.LAST_ACTIVE_THEME, false) this.localStorageService.removeItem(UserLocalStorageKeys.LAST_ACTIVE_THEME, false)
} }
this.injectCoreColorPalette() this.themeManager.injectCoreColorPalette()
this.oldThemeName = currentTheme this.oldThemeName = currentThemeName
}
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
} }
private listenUserTheme () { private listenUserTheme () {
@ -381,9 +196,8 @@ export class ThemeService {
this.removeThemePlugins(this.themeFromLocalStorage.name) this.removeThemePlugins(this.themeFromLocalStorage.name)
this.oldThemeName = undefined this.oldThemeName = undefined
const head = this.getHeadElement()
for (const htmlLinkElement of this.themeDOMLinksFromLocalStorage) { for (const htmlLinkElement of this.themeDOMLinksFromLocalStorage) {
head.removeChild(htmlLinkElement) this.themeManager.removeThemeLink(htmlLinkElement)
} }
this.themeFromLocalStorage = undefined this.themeFromLocalStorage = undefined
@ -391,10 +205,6 @@ export class ThemeService {
} }
} }
private getHeadElement () {
return document.getElementsByTagName('head')[0]
}
private getTheme (name: string) { private getTheme (name: string) {
return this.themes.find(t => t.name === name) return this.themes.find(t => t.name === name)
} }

View file

@ -1,11 +1,11 @@
import { filter, throttleTime } from 'rxjs'
import { Injectable, inject } from '@angular/core' import { Injectable, inject } from '@angular/core'
import { AuthService, AuthStatus } from '@app/core/auth' import { AuthService, AuthStatus } from '@app/core/auth'
import { objectKeysTyped } from '@peertube/peertube-core-utils' import { objectKeysTyped } from '@peertube/peertube-core-utils'
import { NSFWPolicyType, UserRoleType, UserUpdateMe } from '@peertube/peertube-models' 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 { logger } from '@root-helpers/logger'
import { OAuthUserTokens, UserLocalStorageKeys } from '@root-helpers/users' import { OAuthUserTokens, UserLocalStorageKeys } from '@root-helpers/users'
import { filter, throttleTime } from 'rxjs'
import { ServerService } from '../server' import { ServerService } from '../server'
import { LocalStorageService } from '../wrappers/storage.service' import { LocalStorageService } from '../wrappers/storage.service'
@ -110,6 +110,11 @@ export class UserLocalStorageService {
return { return {
nsfwPolicy: this.localStorageService.getItem<NSFWPolicyType>(UserLocalStorageKeys.NSFW_POLICY) || defaultNSFWPolicy, 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), p2pEnabled: getBoolOrDefault(this.localStorageService.getItem(UserLocalStorageKeys.P2P_ENABLED), defaultP2PEnabled),
theme: this.localStorageService.getItem(UserLocalStorageKeys.THEME) || 'instance-default', theme: this.localStorageService.getItem(UserLocalStorageKeys.THEME) || 'instance-default',
videoLanguages, videoLanguages,
@ -123,6 +128,10 @@ export class UserLocalStorageService {
setUserInfo (profile: UserUpdateMe) { setUserInfo (profile: UserUpdateMe) {
const localStorageKeys = { const localStorageKeys = {
nsfwPolicy: UserLocalStorageKeys.NSFW_POLICY, 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, p2pEnabled: UserLocalStorageKeys.P2P_ENABLED,
autoPlayVideo: UserLocalStorageKeys.AUTO_PLAY_VIDEO, autoPlayVideo: UserLocalStorageKeys.AUTO_PLAY_VIDEO,
autoPlayNextVideo: UserLocalStorageKeys.AUTO_PLAY_NEXT_VIDEO, autoPlayNextVideo: UserLocalStorageKeys.AUTO_PLAY_NEXT_VIDEO,
@ -131,7 +140,7 @@ export class UserLocalStorageService {
videoLanguages: UserLocalStorageKeys.VIDEO_LANGUAGES 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) .filter(key => key in profile)
.map(key => [ localStorageKeys[key], profile[key] ]) .map(key => [ localStorageKeys[key], profile[key] ])
@ -155,6 +164,10 @@ export class UserLocalStorageService {
flushUserInfo () { flushUserInfo () {
this.localStorageService.removeItem(UserLocalStorageKeys.NSFW_POLICY) 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.P2P_ENABLED)
this.localStorageService.removeItem(UserLocalStorageKeys.AUTO_PLAY_VIDEO) this.localStorageService.removeItem(UserLocalStorageKeys.AUTO_PLAY_VIDEO)
this.localStorageService.removeItem(UserLocalStorageKeys.AUTO_PLAY_VIDEO_PLAYLIST) this.localStorageService.removeItem(UserLocalStorageKeys.AUTO_PLAY_VIDEO_PLAYLIST)
@ -165,6 +178,10 @@ export class UserLocalStorageService {
listenUserInfoChange () { listenUserInfoChange () {
return this.localStorageService.watch([ return this.localStorageService.watch([
UserLocalStorageKeys.NSFW_POLICY, UserLocalStorageKeys.NSFW_POLICY,
UserLocalStorageKeys.NSFW_FLAGS_DISPLAYED,
UserLocalStorageKeys.NSFW_FLAGS_WARNED,
UserLocalStorageKeys.NSFW_FLAGS_BLURRED,
UserLocalStorageKeys.NSFW_FLAGS_HIDDEN,
UserLocalStorageKeys.P2P_ENABLED, UserLocalStorageKeys.P2P_ENABLED,
UserLocalStorageKeys.AUTO_PLAY_VIDEO, UserLocalStorageKeys.AUTO_PLAY_VIDEO,
UserLocalStorageKeys.AUTO_PLAY_NEXT_VIDEO, UserLocalStorageKeys.AUTO_PLAY_NEXT_VIDEO,

View file

@ -22,7 +22,12 @@ export class User implements UserServerModel {
emailVerified: boolean emailVerified: boolean
emailPublic: boolean emailPublic: boolean
nsfwPolicy: NSFWPolicyType nsfwPolicy: NSFWPolicyType
nsfwFlagsDisplayed: number
nsfwFlagsHidden: number
nsfwFlagsWarned: number
nsfwFlagsBlurred: number
adminFlags?: UserAdminFlagType adminFlags?: UserAdminFlagType
@ -89,7 +94,7 @@ export class User implements UserServerModel {
patch (obj: UserServerModel) { patch (obj: UserServerModel) {
for (const key of objectKeysTyped(obj)) { for (const key of objectKeysTyped(obj)) {
(this as any)[key] = obj[key] ;(this as any)[key] = obj[key]
} }
if (obj.account !== undefined) { if (obj.account !== undefined) {

View file

@ -1,7 +1,7 @@
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { Component, OnDestroy, OnInit, inject, output, viewChild } from '@angular/core' import { Component, OnDestroy, OnInit, inject, output, viewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute } from '@angular/router'
import { AuthService, AuthStatus, LocalStorageService, User, UserService } from '@app/core' import { AuthService, AuthStatus, LocalStorageService, PeerTubeRouterService, User, UserService } from '@app/core'
import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component' import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
import { ButtonComponent } from '@app/shared/shared-main/buttons/button.component' import { ButtonComponent } from '@app/shared/shared-main/buttons/button.component'
import { AlertComponent } from '@app/shared/shared-main/common/alert.component' import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
@ -30,7 +30,7 @@ export class QuickSettingsModalComponent implements OnInit, OnDestroy {
private authService = inject(AuthService) private authService = inject(AuthService)
private localStorageService = inject(LocalStorageService) private localStorageService = inject(LocalStorageService)
private route = inject(ActivatedRoute) private route = inject(ActivatedRoute)
private router = inject(Router) private peertubeRouter = inject(PeerTubeRouterService)
private static readonly QUERY_MODAL_NAME = 'quick-settings' private static readonly QUERY_MODAL_NAME = 'quick-settings'
@ -99,6 +99,6 @@ export class QuickSettingsModalComponent implements OnInit, OnDestroy {
? QuickSettingsModalComponent.QUERY_MODAL_NAME ? QuickSettingsModalComponent.QUERY_MODAL_NAME
: null : null
this.router.navigate([], { queryParams: { modal }, queryParamsHandling: 'merge' }) this.peertubeRouter.silentNavigate([], { modal }, this.route)
} }
} }

View file

@ -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 = { export const VIDEO_SCHEDULE_PUBLICATION_AT_VALIDATOR: BuildFormValidator = {
VALIDATORS: [], // Required is set dynamically VALIDATORS: [], // Required is set dynamically
MESSAGES: { MESSAGES: {

View file

@ -92,7 +92,7 @@ export class ChannelMiniatureMarkupComponent implements CustomMarkupComponent, O
.pipe( .pipe(
map(user => user.nsfwPolicy), map(user => user.nsfwPolicy),
switchMap(nsfwPolicy => { switchMap(nsfwPolicy => {
return this.videoService.getVideoChannelVideos({ ...videoOptions, nsfw: this.videoService.nsfwPolicyToParam(nsfwPolicy) }) return this.videoService.listChannelVideos({ ...videoOptions, nsfw: this.videoService.nsfwPolicyToParam(nsfwPolicy) })
}) })
) )
} }

View file

@ -35,9 +35,7 @@ export class PlaylistMiniatureMarkupComponent implements CustomMarkupComponent,
views: true, views: true,
by: true, by: true,
avatar: true, avatar: true,
privacyLabel: false, privacyLabel: false
privacyText: false,
blacklistInfo: false
} }
ngOnInit () { ngOnInit () {

View file

@ -1,6 +1,6 @@
<my-video-miniature <my-video-miniature
*ngIf="video()" *ngIf="video()"
[video]="video()" [user]="getUser()" [displayAsRow]="false" [video]="video()" [user]="user" [displayAsRow]="false"
[displayVideoActions]="true" [displayOptions]="displayOptions" [displayVideoActions]="true" [displayOptions]="displayOptions"
> >
</my-video-miniature> </my-video-miniature>

View file

@ -1,6 +1,6 @@
import { NgIf } from '@angular/common' import { NgIf } from '@angular/common'
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, inject, input, model, output } from '@angular/core' 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 { Video } from '@app/shared/shared-main/video/video.model'
import { FindInBulkService } from '@app/shared/shared-search/find-in-bulk.service' import { FindInBulkService } from '@app/shared/shared-search/find-in-bulk.service'
import { objectKeysTyped } from '@peertube/peertube-core-utils' import { objectKeysTyped } from '@peertube/peertube-core-utils'
@ -23,6 +23,7 @@ export class VideoMiniatureMarkupComponent implements CustomMarkupComponent, OnI
private auth = inject(AuthService) private auth = inject(AuthService)
private findInBulk = inject(FindInBulkService) private findInBulk = inject(FindInBulkService)
private notifier = inject(Notifier) private notifier = inject(Notifier)
private userService = inject(UserService)
private cd = inject(ChangeDetectorRef) private cd = inject(ChangeDetectorRef)
readonly uuid = input<string>(undefined) readonly uuid = input<string>(undefined)
@ -36,14 +37,10 @@ export class VideoMiniatureMarkupComponent implements CustomMarkupComponent, OnI
views: true, views: true,
by: true, by: true,
avatar: true, avatar: true,
privacyLabel: false, privacyLabel: false
privacyText: false,
blacklistInfo: false
} }
getUser () { user: User
return this.auth.getUser()
}
ngOnInit () { ngOnInit () {
if (this.onlyDisplayTitle()) { 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 if (this.video()) return
this.findInBulk.getVideo(this.uuid()) this.findInBulk.getVideo(this.uuid())

View file

@ -3,7 +3,7 @@
<div class="video-wrapper" *ngFor="let video of videos"> <div class="video-wrapper" *ngFor="let video of videos">
<my-video-miniature <my-video-miniature
[video]="video" [user]="getUser()" [displayAsRow]="false" [video]="video" [user]="user" [displayAsRow]="false"
[displayVideoActions]="true" [displayOptions]="displayOptions" [displayVideoActions]="true" [displayOptions]="displayOptions"
> >
</my-video-miniature> </my-video-miniature>

View file

@ -1,6 +1,6 @@
import { NgFor, NgStyle } from '@angular/common' import { NgFor, NgStyle } from '@angular/common'
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, inject, input, output } from '@angular/core' 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 { Video } from '@app/shared/shared-main/video/video.model'
import { CommonVideoParams, VideoService } from '@app/shared/shared-main/video/video.service' import { CommonVideoParams, VideoService } from '@app/shared/shared-main/video/video.service'
import { objectKeysTyped } from '@peertube/peertube-core-utils' import { objectKeysTyped } from '@peertube/peertube-core-utils'
@ -25,6 +25,7 @@ export class VideosListMarkupComponent implements CustomMarkupComponent, OnInit
private auth = inject(AuthService) private auth = inject(AuthService)
private videoService = inject(VideoService) private videoService = inject(VideoService)
private notifier = inject(Notifier) private notifier = inject(Notifier)
private userService = inject(UserService)
private cd = inject(ChangeDetectorRef) private cd = inject(ChangeDetectorRef)
readonly sort = input<string>(undefined) readonly sort = input<string>(undefined)
@ -42,19 +43,14 @@ export class VideosListMarkupComponent implements CustomMarkupComponent, OnInit
readonly loaded = output<boolean>() readonly loaded = output<boolean>()
videos: Video[] videos: Video[]
user: User
displayOptions: MiniatureDisplayOptions = { displayOptions: MiniatureDisplayOptions = {
date: false, date: false,
views: true, views: true,
by: true, by: true,
avatar: true, avatar: true,
privacyLabel: false, privacyLabel: false
privacyText: false,
blacklistInfo: false
}
getUser () {
return this.auth.getUser()
} }
limitRowsStyle () { 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))) .pipe(finalize(() => this.loaded.emit(true)))
.subscribe({ .subscribe({
next: data => { next: data => {
@ -106,19 +105,19 @@ export class VideosListMarkupComponent implements CustomMarkupComponent, OnInit
const channelHandle = this.channelHandle() const channelHandle = this.channelHandle()
const accountHandle = this.accountHandle() const accountHandle = this.accountHandle()
if (channelHandle) { if (channelHandle) {
obs = this.videoService.getVideoChannelVideos({ obs = this.videoService.listChannelVideos({
...options, ...options,
videoChannel: { nameWithHost: channelHandle } videoChannel: { nameWithHost: channelHandle }
}) })
} else if (accountHandle) { } else if (accountHandle) {
obs = this.videoService.getAccountVideos({ obs = this.videoService.listAccountVideos({
...options, ...options,
account: { nameWithHost: accountHandle } account: { nameWithHost: accountHandle }
}) })
} else { } else {
obs = this.videoService.getVideos(options) obs = this.videoService.listVideos(options)
} }
return obs.pipe(map(({ data }) => data)) return obs.pipe(map(({ data }) => data))

View file

@ -6,7 +6,7 @@
[(ngModel)]="checked" [(ngModel)]="checked"
(ngModelChange)="onModelChange()" (ngModelChange)="onModelChange()"
[id]="inputName()" [id]="inputName()"
[disabled]="disabled()" [disabled]="disabled"
[attr.aria-describedby]="inputName() + '-description'" [attr.aria-describedby]="inputName() + '-description'"
/> />
<span></span> <span></span>

View file

@ -23,9 +23,9 @@ export class PeertubeCheckboxComponent implements ControlValueAccessor, AfterCon
readonly labelText = input<string>(undefined) readonly labelText = input<string>(undefined)
readonly labelInnerHTML = input<string>(undefined) readonly labelInnerHTML = input<string>(undefined)
readonly helpPlacement = input('top auto') readonly helpPlacement = input('top auto')
readonly disabled = model(false)
readonly recommended = input(false) readonly recommended = input(false)
disabled = false
describedby: string describedby: string
readonly templates = contentChildren(PeerTubeTemplateDirective) readonly templates = contentChildren(PeerTubeTemplateDirective)
@ -66,6 +66,6 @@ export class PeertubeCheckboxComponent implements ControlValueAccessor, AfterCon
} }
setDisabledState (isDisabled: boolean) { setDisabledState (isDisabled: boolean) {
this.disabled.set(isDisabled) this.disabled = isDisabled
} }
} }

View file

@ -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>

View file

@ -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;
}

View file

@ -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()
}
}

View file

@ -79,6 +79,7 @@ const icons = {
'ownership-change': require('../../../assets/images/feather/share.svg'), 'ownership-change': require('../../../assets/images/feather/share.svg'),
'p2p': require('../../../assets/images/feather/airplay.svg'), 'p2p': require('../../../assets/images/feather/airplay.svg'),
'play': require('../../../assets/images/feather/play.svg'), 'play': require('../../../assets/images/feather/play.svg'),
'circle-alert': require('../../../assets/images/feather/circle-alert.svg'),
'playlists': require('../../../assets/images/feather/playlists.svg'), 'playlists': require('../../../assets/images/feather/playlists.svg'),
'refresh': require('../../../assets/images/feather/refresh-cw.svg'), 'refresh': require('../../../assets/images/feather/refresh-cw.svg'),
'repeat': require('../../../assets/images/feather/repeat.svg'), 'repeat': require('../../../assets/images/feather/repeat.svg'),

View file

@ -10,7 +10,7 @@
<tr> <tr>
<th class="t-label" scope="row"> <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> <span i18n class="fs-7 fw-normal fst-italic">can be redefined by the users</span>
</th> </th>

View file

@ -66,7 +66,8 @@ export class InstanceFeaturesTableComponent implements OnInit {
const policy = this.serverConfig().instance.defaultNSFWPolicy const policy = this.serverConfig().instance.defaultNSFWPolicy
if (policy === 'do_not_list') return $localize`Hidden` 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` if (policy === 'display') return $localize`Displayed`
} }

View file

@ -19,6 +19,7 @@ import {
VideoStreamingPlaylist, VideoStreamingPlaylist,
VideoStreamingPlaylistType VideoStreamingPlaylistType
} from '@peertube/peertube-models' } from '@peertube/peertube-models'
import { isVideoNSFWBlurForUser, isVideoNSFWHiddenForUser, isVideoNSFWWarnedForUser } from '@root-helpers/video'
export class Video implements VideoServerModel { export class Video implements VideoServerModel {
byVideoChannel: string byVideoChannel: string
@ -68,7 +69,10 @@ export class Video implements VideoServerModel {
likes: number likes: number
dislikes: number dislikes: number
nsfw: boolean nsfw: boolean
nsfwFlags: number
nsfwSummary: string
originInstanceUrl: string originInstanceUrl: string
originInstanceHost: string originInstanceHost: string
@ -176,6 +180,8 @@ export class Video implements VideoServerModel {
this.dislikes = hash.dislikes this.dislikes = hash.dislikes
this.nsfw = hash.nsfw this.nsfw = hash.nsfw
this.nsfwFlags = hash.nsfwFlags
this.nsfwSummary = hash.nsfwSummary
this.account = hash.account this.account = hash.account
this.channel = hash.channel this.channel = hash.channel
@ -217,15 +223,16 @@ export class Video implements VideoServerModel {
this.comments = hash.comments this.comments = hash.comments
} }
isVideoNSFWForUser (user: User, serverConfig: HTMLServerConfig) { isVideoNSFWWarnedForUser (user: User, serverConfig: HTMLServerConfig) {
// Video is not NSFW, skip return isVideoNSFWWarnedForUser(this, serverConfig, user)
if (this.nsfw === false) return false }
// Return user setting if logged in isVideoNSFWBlurForUser (user: User, serverConfig: HTMLServerConfig) {
if (user) return user.nsfwPolicy !== 'display' return isVideoNSFWBlurForUser(this, serverConfig, user)
}
// Return default instance config isVideoNSFWHiddenForUser (user: User, serverConfig: HTMLServerConfig) {
return serverConfig.instance.defaultNSFWPolicy !== 'display' return isVideoNSFWHiddenForUser(this, serverConfig, user)
} }
isRemovableBy (user: AuthUser) { isRemovableBy (user: AuthUser) {

View file

@ -18,6 +18,7 @@ import {
FeedFormatType, FeedFormatType,
FeedType, FeedType,
FeedType_Type, FeedType_Type,
NSFWFlag,
NSFWPolicyType, NSFWPolicyType,
ResultList, ResultList,
ServerErrorCode, ServerErrorCode,
@ -30,7 +31,6 @@ import {
VideoDetails as VideoDetailsServerModel, VideoDetails as VideoDetailsServerModel,
VideoFile, VideoFile,
VideoFileMetadata, VideoFileMetadata,
VideoIncludeType,
VideoPrivacy, VideoPrivacy,
VideoPrivacyType, VideoPrivacyType,
VideosCommonQuery, VideosCommonQuery,
@ -45,26 +45,14 @@ import { from, Observable, of, throwError } from 'rxjs'
import { catchError, concatMap, map, switchMap, toArray } from 'rxjs/operators' import { catchError, concatMap, map, switchMap, toArray } from 'rxjs/operators'
import { environment } from '../../../../environments/environment' import { environment } from '../../../../environments/environment'
import { Account } from '../account/account.model' import { Account } from '../account/account.model'
import { AccountService } from '../account/account.service'
import { VideoChannel } from '../channel/video-channel.model' import { VideoChannel } from '../channel/video-channel.model'
import { VideoChannelService } from '../channel/video-channel.service'
import { VideoDetails } from './video-details.model' import { VideoDetails } from './video-details.model'
import { VideoPasswordService } from './video-password.service' import { VideoPasswordService } from './video-password.service'
import { Video } from './video.model' import { Video } from './video.model'
export type CommonVideoParams = { export type CommonVideoParams = Omit<VideosCommonQuery, 'start' | 'count' | 'sort'> & {
videoPagination?: ComponentPaginationLight videoPagination?: ComponentPaginationLight
sort: VideoSortField | SortMeta sort: VideoSortField | SortMeta
include?: VideoIncludeType
isLocal?: boolean
categoryOneOf?: number[]
languageOneOf?: string[]
privacyOneOf?: VideoPrivacyType[]
isLive?: boolean
skipCount?: boolean
nsfw?: BooleanBothQuery
host?: string
search?: string
} }
@Injectable() @Injectable()
@ -75,6 +63,7 @@ export class VideoService {
private restService = inject(RestService) private restService = inject(RestService)
private serverService = inject(ServerService) private serverService = inject(ServerService)
private confirmService = inject(ConfirmService) private confirmService = inject(ConfirmService)
private userService = inject(UserService)
static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos' static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos'
static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.' static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
@ -113,6 +102,17 @@ export class VideoService {
.pipe(catchError(err => this.restExtractor.handleError(err))) .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: { listMyVideos (options: {
videoPagination?: ComponentPaginationLight videoPagination?: ComponentPaginationLight
restPagination?: RestPagination restPagination?: RestPagination
@ -154,45 +154,30 @@ export class VideoService {
) )
} }
getAccountVideos ( listAccountVideos (
options: CommonVideoParams & { options: CommonVideoParams & {
account: Pick<Account, 'nameWithHost'> account: Pick<Account, 'nameWithHost'>
} }
): Observable<ResultList<Video>> { ): Observable<ResultList<Video>> {
const { account, ...parameters } = options return this.listVideos({ ...options, videoChannel: options.account })
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))
)
} }
getVideoChannelVideos ( listChannelVideos (
parameters: CommonVideoParams & { options: CommonVideoParams & {
videoChannel: Pick<VideoChannel, 'nameWithHost'> videoChannel: Pick<VideoChannel, 'nameWithHost'>
} }
): Observable<ResultList<Video>> { ): Observable<ResultList<Video>> {
const { videoChannel } = parameters return this.listVideos({ ...options, videoChannel: options.videoChannel })
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))
)
} }
getVideos (parameters: CommonVideoParams): Observable<ResultList<Video>> { listVideos (
options: CommonVideoParams & {
videoChannel?: Pick<VideoChannel, 'nameWithHost'>
account?: Pick<Account, 'nameWithHost'>
}
): Observable<ResultList<Video>> {
let params = new HttpParams() let params = new HttpParams()
params = this.buildCommonVideosParams({ params, ...parameters }) params = this.buildCommonVideosParams({ params, ...options })
return this.authHttp return this.authHttp
.get<ResultList<Video>>(VideoService.BASE_VIDEO_URL, { params }) .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) { buildBaseFeedUrls (params: HttpParams, base = VideoService.BASE_FEEDS_URL) {
const feeds: { type: FeedType_Type, format: FeedFormatType, label: string, url: string }[] = [ 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) return this.buildBaseFeedUrls(params, VideoService.BASE_SUBSCRIPTION_FEEDS_URL)
} }
// ---------------------------------------------------------------------------
// Video files
// ---------------------------------------------------------------------------
getVideoFileMetadata (metadataUrl: string) { getVideoFileMetadata (metadataUrl: string) {
return this.authHttp return this.authHttp
.get<VideoFileMetadata>(metadataUrl) .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') { removeVideoFiles (videoIds: (number | string)[], type: 'hls' | 'web-videos') {
return from(videoIds) return from(videoIds)
.pipe( .pipe(
@ -316,6 +375,8 @@ export class VideoService {
.pipe(catchError(err => this.restExtractor.handleError(err))) .pipe(catchError(err => this.restExtractor.handleError(err)))
} }
// ---------------------------------------------------------------------------
runTranscoding (options: { runTranscoding (options: {
videos: Video[] videos: Video[]
type: 'hls' | 'web-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>[]) { getHighestAvailablePrivacy (serverPrivacies: VideoConstant<VideoPrivacyType>[]) {
// We do not add a password as this requires additional configuration. // We do not add a password as this requires additional configuration.
const order = [ const order = [
@ -517,64 +600,6 @@ export class VideoService {
return 'videoChannel' as 'videoChannel' 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) { private setVideoRate (id: string, rateType: UserVideoRateType, videoPassword?: string) {
const url = `${VideoService.BASE_VIDEO_URL}/${id}/rate` const url = `${VideoService.BASE_VIDEO_URL}/${id}/rate`
const body: UserVideoRateUpdate = { const body: UserVideoRateUpdate = {

View file

@ -1,6 +1,5 @@
import { splitIntoArray } from '@app/helpers' import { splitIntoArray } from '@app/helpers'
import { import {
BooleanBothQuery,
BooleanQuery, BooleanQuery,
SearchTargetType, SearchTargetType,
VideoChannelsSearchQuery, VideoChannelsSearchQuery,
@ -17,8 +16,6 @@ export class AdvancedSearch {
originallyPublishedStartDate: string // ISO 8601 originallyPublishedStartDate: string // ISO 8601
originallyPublishedEndDate: string // ISO 8601 originallyPublishedEndDate: string // ISO 8601
nsfw: BooleanBothQuery
categoryOneOf: string categoryOneOf: string
licenceOneOf: string licenceOneOf: string
@ -47,7 +44,6 @@ export class AdvancedSearch {
endDate?: string endDate?: string
originallyPublishedStartDate?: string originallyPublishedStartDate?: string
originallyPublishedEndDate?: string originallyPublishedEndDate?: string
nsfw?: BooleanBothQuery
categoryOneOf?: string categoryOneOf?: string
licenceOneOf?: string licenceOneOf?: string
languageOneOf?: string languageOneOf?: string
@ -74,7 +70,6 @@ export class AdvancedSearch {
this.originallyPublishedStartDate = options.originallyPublishedStartDate || undefined this.originallyPublishedStartDate = options.originallyPublishedStartDate || undefined
this.originallyPublishedEndDate = options.originallyPublishedEndDate || undefined this.originallyPublishedEndDate = options.originallyPublishedEndDate || undefined
this.nsfw = options.nsfw || undefined
this.isLive = options.isLive || undefined this.isLive = options.isLive || undefined
this.categoryOneOf = options.categoryOneOf || undefined this.categoryOneOf = options.categoryOneOf || undefined
@ -112,7 +107,6 @@ export class AdvancedSearch {
this.endDate = undefined this.endDate = undefined
this.originallyPublishedStartDate = undefined this.originallyPublishedStartDate = undefined
this.originallyPublishedEndDate = undefined this.originallyPublishedEndDate = undefined
this.nsfw = undefined
this.categoryOneOf = undefined this.categoryOneOf = undefined
this.licenceOneOf = undefined this.licenceOneOf = undefined
this.languageOneOf = undefined this.languageOneOf = undefined
@ -132,7 +126,6 @@ export class AdvancedSearch {
endDate: this.endDate, endDate: this.endDate,
originallyPublishedStartDate: this.originallyPublishedStartDate, originallyPublishedStartDate: this.originallyPublishedStartDate,
originallyPublishedEndDate: this.originallyPublishedEndDate, originallyPublishedEndDate: this.originallyPublishedEndDate,
nsfw: this.nsfw,
categoryOneOf: this.categoryOneOf, categoryOneOf: this.categoryOneOf,
licenceOneOf: this.licenceOneOf, licenceOneOf: this.licenceOneOf,
languageOneOf: this.languageOneOf, languageOneOf: this.languageOneOf,
@ -158,7 +151,6 @@ export class AdvancedSearch {
endDate: this.endDate, endDate: this.endDate,
originallyPublishedStartDate: this.originallyPublishedStartDate, originallyPublishedStartDate: this.originallyPublishedStartDate,
originallyPublishedEndDate: this.originallyPublishedEndDate, originallyPublishedEndDate: this.originallyPublishedEndDate,
nsfw: this.nsfw,
categoryOneOf: splitIntoArray(this.categoryOneOf), categoryOneOf: splitIntoArray(this.categoryOneOf),
licenceOneOf: splitIntoArray(this.licenceOneOf), licenceOneOf: splitIntoArray(this.licenceOneOf),
languageOneOf: splitIntoArray(this.languageOneOf), 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.startDate) || this.isValidValue(this.endDate)) acc++
if (this.isValidValue(this.originallyPublishedStartDate) || this.isValidValue(this.originallyPublishedEndDate)) 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.categoryOneOf)) acc++
if (this.isValidValue(this.licenceOneOf)) acc++ if (this.isValidValue(this.licenceOneOf)) acc++
if (this.isValidValue(this.languageOneOf)) acc++ if (this.isValidValue(this.languageOneOf)) acc++
@ -221,7 +212,6 @@ export class AdvancedSearch {
this.endDate !== undefined || this.endDate !== undefined ||
this.originallyPublishedStartDate !== undefined || this.originallyPublishedStartDate !== undefined ||
this.originallyPublishedEndDate !== undefined || this.originallyPublishedEndDate !== undefined ||
this.nsfw !== undefined ||
this.categoryOneOf !== undefined || this.categoryOneOf !== undefined ||
this.licenceOneOf !== undefined || this.licenceOneOf !== undefined ||
this.languageOneOf !== undefined || this.languageOneOf !== undefined ||

View file

@ -57,9 +57,12 @@ export class SearchService {
if (advancedSearch) { if (advancedSearch) {
const advancedSearchObject = advancedSearch.toVideosAPIObject() const advancedSearchObject = advancedSearch.toVideosAPIObject()
params = this.restService.addObjectParams(params, advancedSearchObject) params = this.restService.addObjectParams(params, advancedSearchObject)
} }
params = this.videoService.buildNSFWParams(params, {})
return this.authHttp return this.authHttp
.get<ResultList<VideoServerModel>>(url, { params }) .get<ResultList<VideoServerModel>>(url, { params })
.pipe( .pipe(

View file

@ -1,12 +1,12 @@
<div class="root" [ngClass]="{ 'small': size() === 'small' }"> <div class="root" [ngClass]="{ 'small': size() === 'small' }">
<my-video-thumbnail <my-video-thumbnail
*ngIf="thumbnail()" *ngIf="thumbnail()" blur="false"
[video]="video()" [videoRouterLink]="getVideoUrl()" [ariaLabel]="video().name" playOverlay="false" [video]="video()" [videoRouterLink]="getVideoUrl()" [ariaLabel]="video().name" playOverlay="false"
></my-video-thumbnail> ></my-video-thumbnail>
<div *ngIf="title()" class="min-width-0" [ngClass]="{ ellipsis: ellipsis }"> <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"> <a class="name" [ngClass]="{ ellipsis: ellipsis }" [routerLink]="getVideoUrl()" [title]="video().name">
{{ video().name }} {{ video().name }}
</a> </a>

View file

@ -9,7 +9,7 @@
} }
<ng-template #aContent> <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"> <div *ngIf="displayWatchLaterPlaylist()" class="actions-overlay">
<button <button
@ -25,8 +25,8 @@
</button> </button>
</div> </div>
<div class="label-overlay warning"><ng-content select="label-warning"></ng-content></div> <div class="label-overlay pt-badge badge-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-danger"><ng-content select="label-danger"></ng-content></div>
@if (video().isLive) { @if (video().isLive) {
<div class="live-overlay" [ngClass]="{ 'live-streaming': isLiveStreaming(), 'ended-live': isEndedLive() }"> <div class="live-overlay" [ngClass]="{ 'live-streaming': isLiveStreaming(), 'ended-live': isEndedLive() }">

View file

@ -22,7 +22,6 @@
} }
.watch-icon-overlay, .watch-icon-overlay,
.label-overlay,
.duration-overlay, .duration-overlay,
.live-overlay { .live-overlay {
font-size: 0.75rem; font-size: 0.75rem;
@ -33,15 +32,11 @@
} }
.label-overlay { .label-overlay {
border-radius: 3px;
position: absolute; position: absolute;
padding: 0 5px; padding: 0 5px;
left: 5px; left: 5px;
top: 5px; top: 5px;
font-weight: $font-bold; z-index: z(miniature);
&.warning { background-color: #ffa500; }
&.danger { background-color: pvar(--red); }
} }
.duration-overlay, .duration-overlay,

View file

@ -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 { booleanAttribute, Component, inject, input, OnChanges, output, viewChild } from '@angular/core'
import { RouterLink } from '@angular/router' import { RouterLink } from '@angular/router'
import { ScreenService } from '@app/core' import { ScreenService } from '@app/core'
@ -27,13 +27,12 @@ export type VideoThumbnailInput = Pick<
selector: 'my-video-thumbnail', selector: 'my-video-thumbnail',
styleUrls: [ './video-thumbnail.component.scss' ], styleUrls: [ './video-thumbnail.component.scss' ],
templateUrl: './video-thumbnail.component.html', templateUrl: './video-thumbnail.component.html',
imports: [ NgIf, RouterLink, NgTemplateOutlet, NgClass, NgbTooltip, GlobalIconComponent, NgStyle ] imports: [ CommonModule, RouterLink, NgbTooltip, GlobalIconComponent ]
}) })
export class VideoThumbnailComponent implements OnChanges { export class VideoThumbnailComponent implements OnChanges {
private screenService = inject(ScreenService) private screenService = inject(ScreenService)
readonly video = input.required<VideoThumbnailInput>() readonly video = input.required<VideoThumbnailInput>()
readonly nsfw = input(false)
readonly videoRouterLink = input<string | any[]>(undefined) readonly videoRouterLink = input<string | any[]>(undefined)
readonly queryParams = input<{ readonly queryParams = input<{
@ -47,6 +46,7 @@ export class VideoThumbnailComponent implements OnChanges {
readonly playOverlay = input<boolean, boolean | string>(true, { transform: booleanAttribute }) readonly playOverlay = input<boolean, boolean | string>(true, { transform: booleanAttribute })
readonly ariaLabel = input.required<string>() readonly ariaLabel = input.required<string>()
readonly blur = input.required({ transform: booleanAttribute })
readonly watchLaterTooltip = viewChild<NgbTooltip>('watchLaterTooltip') readonly watchLaterTooltip = viewChild<NgbTooltip>('watchLaterTooltip')
readonly watchLaterClick = output<boolean>() readonly watchLaterClick = output<boolean>()

View file

@ -2,23 +2,36 @@
<div class="form-group"> <div class="form-group">
<div class="anchor" id="video-sensitive-content-policy"></div> <!-- video-sensitive-content-policy anchor --> <div class="anchor" id="video-sensitive-content-policy"></div> <!-- video-sensitive-content-policy anchor -->
<div class="pt-label-container"> <div class="form-group">
<label i18n for="nsfwPolicy">Default policy on videos containing sensitive content</label> <my-select-radio
[items]="nsfwItems" inputId="nsfwPolicy" isGroup="true"
<my-help> i18n-label label="Policy on videos containing sensitive content"
<ng-container i18n> formControlName="nsfwPolicy"
With <strong>Hide</strong> or <strong>Blur thumbnails</strong>, a confirmation will be requested to watch the video. ></my-select-radio>
</ng-container>
</my-help>
</div> </div>
<div class="peertube-select-container"> <div class="form-group mb-3">
<select id="nsfwPolicy" formControlName="nsfwPolicy" class="form-control"> <my-select-radio
<option i18n value="undefined" disabled>Policy for sensitive videos</option> [items]="nsfwFlagItems" inputId="nsfwFlagViolent" isGroup="true"
<option i18n value="do_not_list">Hide</option> labelSecondary="true" i18n-label label="Redefine policy for violent content"
<option i18n value="blur">Blur thumbnails</option> formControlName="nsfwFlagViolent"
<option i18n value="display">Display</option> ></my-select-radio>
</select> </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>
</div> </div>

View file

@ -9,5 +9,6 @@
my-select-languages { my-select-languages {
display: block; display: block;
@include responsive-width(340px); width: 340px;
max-width: 100%;
} }

View file

@ -1,17 +1,33 @@
import { NgIf } from '@angular/common' import { NgIf } from '@angular/common'
import { Component, OnDestroy, OnInit, inject, input } from '@angular/core' 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 { AuthService, Notifier, ServerService, User, UserService } from '@app/core'
import { FormReactive } from '@app/shared/shared-forms/form-reactive' import { FormReactiveErrors, FormReactiveService, FormReactiveValidationMessages } from '@app/shared/shared-forms/form-reactive.service'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service' import { NSFWFlag, NSFWFlagType, NSFWPolicyType, UserUpdateMe } from '@peertube/peertube-models'
import { NSFWPolicyType, UserUpdateMe } from '@peertube/peertube-models'
import { pick } from 'lodash-es' import { pick } from 'lodash-es'
import { Subject, Subscription } from 'rxjs' import { Subject, Subscription } from 'rxjs'
import { first } from 'rxjs/operators' 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 { PeertubeCheckboxComponent } from '../shared-forms/peertube-checkbox.component'
import { SelectLanguagesComponent } from '../shared-forms/select/select-languages.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' 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({ @Component({
selector: 'my-user-video-settings', selector: 'my-user-video-settings',
templateUrl: './user-video-settings.component.html', templateUrl: './user-video-settings.component.html',
@ -22,11 +38,12 @@ import { HelpComponent } from '../shared-main/buttons/help.component'
HelpComponent, HelpComponent,
SelectLanguagesComponent, SelectLanguagesComponent,
PeertubeCheckboxComponent, PeertubeCheckboxComponent,
NgIf NgIf,
SelectRadioComponent
] ]
}) })
export class UserVideoSettingsComponent extends FormReactive implements OnInit, OnDestroy { export class UserVideoSettingsComponent implements OnInit, OnDestroy {
protected formReactiveService = inject(FormReactiveService) private formReactiveService = inject(FormReactiveService)
private authService = inject(AuthService) private authService = inject(AuthService)
private notifier = inject(Notifier) private notifier = inject(Notifier)
private userService = inject(UserService) private userService = inject(UserService)
@ -37,26 +54,72 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
readonly notifyOnUpdate = input(true) readonly notifyOnUpdate = input(true)
readonly userInformationLoaded = input<Subject<any>>(undefined) 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 formValuesWatcher: Subscription
ngOnInit () { ngOnInit () {
this.buildForm({ this.buildForm()
nsfwPolicy: null,
p2pEnabled: null, this.updateNSFWDefaultLabel(this.user().nsfwPolicy)
autoPlayVideo: null, this.form.controls.nsfwPolicy.valueChanges.subscribe(nsfwPolicy => this.updateNSFWDefaultLabel(nsfwPolicy))
autoPlayNextVideo: null,
videoLanguages: null
})
this.userInformationLoaded().pipe(first()) this.userInformationLoaded().pipe(first())
.subscribe( .subscribe(
() => { () => {
const serverConfig = this.serverService.getHTMLConfig() const serverConfig = this.serverService.getHTMLConfig()
this.defaultNSFWPolicy = serverConfig.instance.defaultNSFWPolicy const defaultNSFWPolicy = serverConfig.instance.defaultNSFWPolicy
this.form.patchValue({ 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, p2pEnabled: this.user().p2pEnabled,
autoPlayVideo: this.user().autoPlayVideo === true, autoPlayVideo: this.user().autoPlayVideo === true,
autoPlayNextVideo: this.user().autoPlayNextVideo, autoPlayNextVideo: this.user().autoPlayNextVideo,
@ -72,13 +135,32 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
this.formValuesWatcher?.unsubscribe() this.formValuesWatcher?.unsubscribe()
} }
updateDetails (onlyKeys?: string[]) { private buildForm () {
const nsfwPolicy = this.form.value['nsfwPolicy'] const obj: BuildFormArgument = {
const p2pEnabled = this.form.value['p2pEnabled'] nsfwPolicy: null,
const autoPlayVideo = this.form.value['autoPlayVideo'] nsfwFlagViolent: null,
const autoPlayNextVideo = this.form.value['autoPlayNextVideo'] 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 (Array.isArray(videoLanguages)) {
if (videoLanguages.length > 20) { if (videoLanguages.length > 20) {
@ -87,19 +169,33 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
} }
} }
const value = this.form.value
let details: UserUpdateMe = { let details: UserUpdateMe = {
nsfwPolicy, nsfwPolicy: value.nsfwPolicy,
p2pEnabled, p2pEnabled: value.p2pEnabled,
autoPlayVideo, autoPlayVideo: value.autoPlayVideo,
autoPlayNextVideo, autoPlayNextVideo: value.autoPlayNextVideo,
nsfwFlagsDisplayed: this.buildNSFWUpdateFlag('display'),
nsfwFlagsHidden: this.buildNSFWUpdateFlag('do_not_list'),
nsfwFlagsWarned: this.buildNSFWUpdateFlag('warn'),
nsfwFlagsBlurred: this.buildNSFWUpdateFlag('blur'),
videoLanguages videoLanguages
} }
if (videoLanguages) { if (onlyKeys) {
details = Object.assign(details, videoLanguages) 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()) { if (this.authService.isLoggedIn()) {
return this.updateLoggedProfile(details) return this.updateLoggedProfile(details)
@ -113,7 +209,7 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
this.formValuesWatcher = this.form.valueChanges.subscribe((formValue: any) => { this.formValuesWatcher = this.form.valueChanges.subscribe((formValue: any) => {
const updatedKey = Object.keys(formValue) const updatedKey = Object.keys(formValue)
.find(k => formValue[k] !== oldForm[k]) .find(k => formValue[k] !== ((oldForm as any)[k]))
oldForm = { ...this.form.value } oldForm = { ...this.form.value }
@ -141,4 +237,32 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
this.notifier.success($localize`Display/Video settings updated.`) 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})`
}
} }

View file

@ -78,14 +78,12 @@
<div class="form-group" role="radiogroup"> <div class="form-group" role="radiogroup">
<label for="nsfw" i18n>Sensitive content</label> <label for="nsfw" i18n>Sensitive content</label>
<div class="peertube-radio-container"> <div class="form-group-description">
<input formControlName="nsfw" type="radio" name="nsfw" id="nsfwBoth" value="both" /> <div>{{ filters().getNSFWSettingsLabel() }}</div>
<label for="nsfwBoth">{{ filters().getNSFWDisplayLabel() }}</label>
</div>
<div class="peertube-radio-container"> <div i18n>
<input formControlName="nsfw" type="radio" name="nsfw" id="nsfwFalse" value="false" /> Update your policy in <a routerLink="/my-account/settings" fragment="video-sensitive-content-policy" (click)="onAccountSettingsClick($event)">your settings</a>.
<label for="nsfwFalse" i18n>Hide</label> </div>
</div> </div>
</div> </div>
</div> </div>
@ -144,7 +142,7 @@
<my-peertube-checkbox <my-peertube-checkbox
formControlName="allVideos" formControlName="allVideos"
inputName="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> ></my-peertube-checkbox>
</div> </div>
</div> </div>

View file

@ -44,6 +44,10 @@ $filters-background: pvar(--bg-secondary-400);
} }
} }
.form-group-description {
color: pvar(--fg-300);
}
.filters { .filters {
--input-bg: #{pvar(--input-bg-in-secondary)}; --input-bg: #{pvar(--input-bg-in-secondary)};
--input-border-color: #{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) { @media screen and (max-width: $small-view) {
.scope-select {
min-width: auto;
max-width: 90%;
}
.filters-toggle { .filters-toggle {
margin-top: 0.5rem; margin-top: 0.5rem;

View file

@ -81,7 +81,6 @@ export class VideoFiltersHeaderComponent implements OnInit {
this.form = this.fb.group({ this.form = this.fb.group({
sort: [ '' ], sort: [ '' ],
nsfw: [ '' ],
languageOneOf: [ '' ], languageOneOf: [ '' ],
categoryOneOf: [ '' ], categoryOneOf: [ '' ],
scope: [ '' ], scope: [ '' ],

View file

@ -1,11 +1,14 @@
import { User } from '@app/core'
import { splitIntoArray, toBoolean } from '@app/helpers' import { splitIntoArray, toBoolean } from '@app/helpers'
import { getAllPrivacies } from '@peertube/peertube-core-utils' import { getAllPrivacies } from '@peertube/peertube-core-utils'
import { import {
BooleanBothQuery, BooleanBothQuery,
NSFWFlag,
NSFWPolicyType, NSFWPolicyType,
VideoInclude, VideoInclude,
VideoIncludeType, VideoIncludeType,
VideoPrivacyType, VideoPrivacyType,
VideosCommonQuery,
VideoSortField VideoSortField
} from '@peertube/peertube-models' } from '@peertube/peertube-models'
import { AttributesOnly } from '@peertube/peertube-typescript-utils' import { AttributesOnly } from '@peertube/peertube-typescript-utils'
@ -29,7 +32,6 @@ export type VideoFilterActive = {
export class VideoFilters { export class VideoFilters {
sort: VideoSortField sort: VideoSortField
nsfw: BooleanBothQuery
languageOneOf: string[] languageOneOf: string[]
categoryOneOf: number[] categoryOneOf: number[]
@ -41,9 +43,14 @@ export class VideoFilters {
search: string search: string
private nsfwPolicy: NSFWPolicyType
private nsfwFlagsDisplayed: number
private nsfwFlagsHidden: number
private nsfwFlagsWarned: number
private nsfwFlagsBlurred: number
private defaultValues = new Map<keyof VideoFilters, any>([ private defaultValues = new Map<keyof VideoFilters, any>([
[ 'sort', '-publishedAt' ], [ 'sort', '-publishedAt' ],
[ 'nsfw', 'false' ],
[ 'languageOneOf', undefined ], [ 'languageOneOf', undefined ],
[ 'categoryOneOf', undefined ], [ 'categoryOneOf', undefined ],
[ 'scope', 'federated' ], [ 'scope', 'federated' ],
@ -53,7 +60,6 @@ export class VideoFilters {
]) ])
private activeFilters: VideoFilterActive[] = [] private activeFilters: VideoFilterActive[] = []
private defaultNSFWPolicy: NSFWPolicyType
private onChangeCallbacks: (() => void)[] = [] private onChangeCallbacks: (() => void)[] = []
private oldFormObjectString: string private oldFormObjectString: string
@ -68,7 +74,7 @@ export class VideoFilters {
this.hiddenFields = hiddenFields this.hiddenFields = hiddenFields
this.reset(undefined, false) this.reset({ triggerChange: false })
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -106,21 +112,23 @@ export class VideoFilters {
this.defaultValues.set('sort', sort) this.defaultValues.set('sort', sort)
} }
setNSFWPolicy (nsfwPolicy: NSFWPolicyType) { setNSFWPolicy (user: Pick<User, 'nsfwPolicy' | 'nsfwFlagsDisplayed' | 'nsfwFlagsHidden' | 'nsfwFlagsWarned' | 'nsfwFlagsBlurred'>) {
const nsfw = nsfwPolicy === 'do_not_list' this.nsfwPolicy = user.nsfwPolicy
? 'false' this.nsfwFlagsDisplayed = user.nsfwFlagsDisplayed
: 'both' this.nsfwFlagsHidden = user.nsfwFlagsHidden
this.nsfwFlagsWarned = user.nsfwFlagsWarned
this.defaultValues.set('nsfw', nsfw) this.nsfwFlagsBlurred = user.nsfwFlagsBlurred
this.defaultNSFWPolicy = nsfwPolicy
return nsfw
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
reset (specificKey?: string, triggerChange = true) { private reset (options: {
debugLogger('Reset video filters', { specificKey, stack: new Error().stack }) specificKey?: string
triggerChange?: boolean // default true
}) {
const { specificKey, triggerChange = true } = options
debugLogger('Reset video filters', { specificKey })
for (const [ key, value ] of this.defaultValues) { for (const [ key, value ] of this.defaultValues) {
if (specificKey && specificKey !== key) continue if (specificKey && specificKey !== key) continue
@ -143,8 +151,6 @@ export class VideoFilters {
if (obj.sort !== undefined) this.sort = obj.sort 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.languageOneOf !== undefined) this.languageOneOf = splitIntoArray(obj.languageOneOf)
if (obj.categoryOneOf !== undefined) this.categoryOneOf = splitIntoArray(obj.categoryOneOf) if (obj.categoryOneOf !== undefined) this.categoryOneOf = splitIntoArray(obj.categoryOneOf)
@ -163,7 +169,14 @@ export class VideoFilters {
debugLogger('Cloning video filters', { videoFilters: this }) debugLogger('Cloning video filters', { videoFilters: this })
const cloned = new VideoFilters(this.defaultValues.get('sort'), this.defaultValues.get('scope'), this.hiddenFields) 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) cloned.load(this.toUrlObject(), this.customizedByUser)
@ -290,8 +303,9 @@ export class VideoFilters {
else if (this.live === 'false') isLive = false else if (this.live === 'false') isLive = false
return { return {
...this.buildNSFWVideosAPIObject(),
sort: this.sort, sort: this.sort,
nsfw: this.nsfw,
languageOneOf: this.languageOneOf, languageOneOf: this.languageOneOf,
categoryOneOf: this.categoryOneOf, categoryOneOf: this.categoryOneOf,
search: this.search, 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 () { getNSFWSettingsLabel () {
if (this.defaultNSFWPolicy === 'blur') return $localize`Blurred` 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 () { private getNSFWValue () {
if (this.nsfw === 'false') return $localize`hidden` if (this.hasCustomNSFWFlags()) {
if (this.defaultNSFWPolicy === 'blur') return $localize`blurred` 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` return $localize`displayed`
} }
private hasCustomNSFWFlags () {
return this.nsfwFlagsDisplayed || this.nsfwFlagsHidden || this.nsfwFlagsWarned || this.nsfwFlagsBlurred
}
} }

View file

@ -1,16 +1,25 @@
<div class="video-miniature" [ngClass]="getClasses()" (mouseenter)="loadActions()" (focusin)="loadActions()"> <div class="video-miniature" [ngClass]="getClasses()" (mouseenter)="loadActions()" (focusin)="loadActions()">
<my-video-thumbnail <my-video-thumbnail
[ariaLabel]="getAriaLabel()" [ariaLabel]="getAriaLabel()" [blur]="hasNSFWBlur()"
[video]="video()" [nsfw]="isVideoBlur" [videoRouterLink]="videoRouterLink" [videoHref]="videoHref" [videoTarget]="videoTarget" [video]="video()" [videoRouterLink]="videoRouterLink" [videoHref]="videoHref" [videoTarget]="videoTarget"
[displayWatchLaterPlaylist]="isWatchLaterPlaylistDisplayed()" [inWatchLaterPlaylist]="inWatchLaterPlaylist" (watchLaterClick)="onWatchLaterClick($event)" [displayWatchLaterPlaylist]="isWatchLaterPlaylistDisplayed()" [inWatchLaterPlaylist]="inWatchLaterPlaylist" (watchLaterClick)="onWatchLaterClick($event)"
> >
@if (displayOptions().privacyLabel) { <!-- Don't use @if that seems broken with content projection (Angular 19.1) -->
<ng-container ngProjectAs="label-warning" *ngIf="isUnlistedVideo()" i18n>Unlisted</ng-container> <ng-container ngProjectAs="label-warning" *ngIf="displayOptions().privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container>
<ng-container ngProjectAs="label-danger" *ngIf="isPrivateVideo()" i18n>Private</ng-container> <ng-container ngProjectAs="label-danger" *ngIf="displayOptions().privacyLabel && isPrivateVideo()" i18n>Private</ng-container>
<ng-container ngProjectAs="label-danger" *ngIf="isPasswordProtectedVideo()" i18n>Password protected</ng-container> <ng-container ngProjectAs="label-danger" *ngIf="displayOptions().privacyLabel && isPasswordProtectedVideo()" i18n>Password protected</ng-container>
}
</my-video-thumbnail> </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 class="video-info">
<div *ngIf="displayOptions().avatar || displayOptions().by" class="owner min-width-0"> <div *ngIf="displayOptions().avatar || displayOptions().by" class="owner min-width-0">
@if (displayOptions().avatar) { @if (displayOptions().avatar) {
@ -43,6 +52,10 @@
</my-link> </my-link>
<my-actor-host *ngIf="!video().isLocal" [host]="video().account.host"></my-actor-host> <my-actor-host *ngIf="!video().isLocal" [host]="video().account.host"></my-actor-host>
@if (!displayAsRow()) {
<ng-container *ngTemplateOutlet="nsfwWarningButton"></ng-container>
}
</div> </div>
</div> </div>
@ -50,7 +63,7 @@
<my-link <my-link
[internalLink]="videoRouterLink" [href]="videoHref" [target]="videoTarget" inheritParentStyle="true" [internalLink]="videoRouterLink" [href]="videoHref" [target]="videoTarget" inheritParentStyle="true"
[ariaLabel]="getAriaLabel()" [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 }} {{ video().name }}
</my-link> </my-link>
@ -61,6 +74,10 @@
(videoRemoved)="onVideoRemoved()" (videoBlocked)="onVideoBlocked()" (videoUnblocked)="onVideoUnblocked()" (videoAccountMuted)="onVideoAccountMuted()" (videoRemoved)="onVideoRemoved()" (videoBlocked)="onVideoBlocked()" (videoUnblocked)="onVideoUnblocked()" (videoAccountMuted)="onVideoAccountMuted()"
></my-video-actions-dropdown> ></my-video-actions-dropdown>
</div> </div>
@if (displayAsRow() || (!displayOptions().avatar && !displayOptions().by)) {
<ng-container *ngTemplateOutlet="nsfwWarningButton"></ng-container>
}
</div> </div>
<div class="date-and-views"> <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> <my-video-views-counter *ngIf="displayOptions().views" [isLive]="video().isLive" [viewers]="video().viewers" [views]="video().views"></my-video-views-counter>
</span> </span>
</div> </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>
</div> </div>

View file

@ -32,19 +32,21 @@ $more-button-width: 40px;
font-size: var(--co-fs-medium); font-size: var(--co-fs-medium);
} }
.date-and-views, .date-and-views {
.video-info-privacy,
.badges {
font-size: var(--co-fs-small); font-size: var(--co-fs-small);
color: pvar(--fg-200);
} }
.date-and-views { .nsfw-warning {
color: pvar(--fg-200); my-global-icon {
@include global-icon-size(18px);
}
} }
.owner-container { .owner-container {
display: flex; display: flex;
min-width: 1px; min-width: 1px;
width: 100%;
} }
my-actor-host { my-actor-host {
@ -144,14 +146,11 @@ my-actor-host {
margin-bottom: 25px; margin-bottom: 25px;
.video-info { .video-info {
margin: 0 10px;
width: 100%; width: 100%;
text-align: left; text-align: left;
} }
.video-actions { .video-actions {
margin: 0;
top: -3px; top: -3px;
width: auto; width: auto;
@ -208,18 +207,6 @@ my-actor-host {
my-actor-avatar { my-actor-avatar {
@include margin-right(0.5rem); @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 { @include on-small-main-col {

View file

@ -1,9 +1,8 @@
import { NgClass, NgFor, NgIf } from '@angular/common' import { CommonModule } from '@angular/common'
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
LOCALE_ID,
OnInit, OnInit,
booleanAttribute, booleanAttribute,
inject, inject,
@ -11,12 +10,13 @@ import {
numberAttribute, numberAttribute,
output output
} from '@angular/core' } from '@angular/core'
import { RouterLink } from '@angular/router'
import { AuthService, ScreenService, ServerService, User } from '@app/core' 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 { switchMap } from 'rxjs/operators'
import { LinkType } from '../../../types/link.type' import { LinkType } from '../../../types/link.type'
import { ActorAvatarComponent } from '../shared-actor-image/actor-avatar.component' 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 { LinkComponent } from '../shared-main/common/link.component'
import { DateToggleComponent } from '../shared-main/date/date-toggle.component' import { DateToggleComponent } from '../shared-main/date/date-toggle.component'
import { Video } from '../shared-main/video/video.model' import { Video } from '../shared-main/video/video.model'
@ -32,9 +32,6 @@ export type MiniatureDisplayOptions = {
views?: boolean views?: boolean
avatar?: boolean avatar?: boolean
privacyLabel?: boolean privacyLabel?: boolean
privacyText?: boolean
blacklistInfo?: boolean
nsfw?: boolean
by?: boolean by?: boolean
forceChannelInBy?: boolean forceChannelInBy?: boolean
@ -46,17 +43,16 @@ export type MiniatureDisplayOptions = {
templateUrl: './video-miniature.component.html', templateUrl: './video-miniature.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
NgClass, CommonModule,
VideoThumbnailComponent, VideoThumbnailComponent,
NgIf,
ActorAvatarComponent, ActorAvatarComponent,
LinkComponent, LinkComponent,
DateToggleComponent, DateToggleComponent,
VideoViewsCounterComponent, VideoViewsCounterComponent,
RouterLink,
NgFor,
VideoActionsDropdownComponent, VideoActionsDropdownComponent,
ActorHostComponent ActorHostComponent,
GlobalIconComponent,
NgbTooltipModule
] ]
}) })
export class VideoMiniatureComponent implements OnInit { export class VideoMiniatureComponent implements OnInit {
@ -66,11 +62,9 @@ export class VideoMiniatureComponent implements OnInit {
private videoPlaylistService = inject(VideoPlaylistService) private videoPlaylistService = inject(VideoPlaylistService)
private videoService = inject(VideoService) private videoService = inject(VideoService)
private cd = inject(ChangeDetectorRef) private cd = inject(ChangeDetectorRef)
private localeId = inject(LOCALE_ID)
readonly user = input<User>(undefined) readonly user = input.required<User>()
readonly video = input<Video>(undefined) readonly video = input.required<Video>()
readonly containedInPlaylists = input<VideoExistInPlaylist[]>(undefined)
readonly displayOptions = input<MiniatureDisplayOptions>({ readonly displayOptions = input<MiniatureDisplayOptions>({
date: true, date: true,
@ -78,8 +72,6 @@ export class VideoMiniatureComponent implements OnInit {
by: true, by: true,
avatar: true, avatar: true,
privacyLabel: false, privacyLabel: false,
privacyText: false,
blacklistInfo: false,
forceChannelInBy: false forceChannelInBy: false
}) })
@ -127,25 +119,27 @@ export class VideoMiniatureComponent implements OnInit {
ownerHref: string ownerHref: string
ownerTarget: string ownerTarget: string
nsfwTooltip: string
private ownerDisplayType: 'account' | 'videoChannel' private ownerDisplayType: 'account' | 'videoChannel'
private actionsLoaded = false private actionsLoaded = false
get authorAccount () { get preferAuthorDisplayName () {
return this.serverConfig.client.videos.miniature.preferAuthorDisplayName return this.serverConfig.client.videos.miniature.preferAuthorDisplayName
}
get authorAccount () {
return this.preferAuthorDisplayName
? this.video().account.displayName ? this.video().account.displayName
: this.video().account.name : this.video().account.name
} }
get authorChannel () { get authorChannel () {
return this.serverConfig.client.videos.miniature.preferAuthorDisplayName return this.preferAuthorDisplayName
? this.video().channel.displayName ? this.video().channel.displayName
: this.video().channel.name : this.video().channel.name
} }
get isVideoBlur () {
return this.video().isVideoNSFWForUser(this.user(), this.serverConfig)
}
ngOnInit () { ngOnInit () {
this.serverConfig = this.serverService.getHTMLConfig() this.serverConfig = this.serverService.getHTMLConfig()
@ -154,6 +148,7 @@ export class VideoMiniatureComponent implements OnInit {
this.setUpBy() this.setUpBy()
this.nsfwTooltip = this.videoService.buildNSFWTooltip(this.video())
this.channelLinkTitle = $localize`${this.video().channel.name} (channel page)` this.channelLinkTitle = $localize`${this.video().channel.name} (channel page)`
// We rely on mouseenter to lazy load actions // 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 videoLinkType = this.videoLinkType()
const video = this.video() const video = this.video()
if (videoLinkType === 'internal' || !video.url) { if (videoLinkType === 'internal' || !video.url) {
@ -181,7 +176,7 @@ export class VideoMiniatureComponent implements OnInit {
this.videoRouterLink = [ '/search/lazy-load-video', { url: video.url } ] this.videoRouterLink = [ '/search/lazy-load-video', { url: video.url } ]
} }
buildOwnerLink () { private buildOwnerLink () {
const video = this.video() const video = this.video()
const linkType = this.videoLinkType() 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 () { private setUpBy () {
if (this.displayOptions().forceChannelInBy) { if (this.displayOptions().forceChannelInBy) {
this.ownerDisplayType = 'videoChannel' this.ownerDisplayType = 'videoChannel'

View file

@ -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 { Component, OnDestroy, OnInit, booleanAttribute, inject, input, output } from '@angular/core'
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute } from '@angular/router'
import { import {
@ -50,9 +50,7 @@ enum GroupDate {
templateUrl: './videos-list.component.html', templateUrl: './videos-list.component.html',
styleUrls: [ './videos-list.component.scss' ], styleUrls: [ './videos-list.component.scss' ],
imports: [ imports: [
NgIf, CommonModule,
NgClass,
NgFor,
ButtonComponent, ButtonComponent,
ButtonComponent, ButtonComponent,
VideoFiltersHeaderComponent, VideoFiltersHeaderComponent,
@ -109,11 +107,9 @@ export class VideosListComponent implements OnInit, OnDestroy {
views: true, views: true,
by: true, by: true,
avatar: true, avatar: true,
privacyLabel: true, privacyLabel: true
privacyText: false,
blacklistInfo: false
} }
displayModerationBlock = false displayModerationBlock = true
private routeSub: Subscription private routeSub: Subscription
private userSub: Subscription private userSub: Subscription
@ -268,9 +264,9 @@ export class VideosListComponent implements OnInit, OnDestroy {
} }
private loadUserSettings (user: User) { 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 () { private reloadSyndicationItems () {
@ -298,6 +294,8 @@ export class VideosListComponent implements OnInit, OnDestroy {
.subscribe(user => { .subscribe(user => {
debugLogger('User changed', { user }) debugLogger('User changed', { user })
this.user = user
if (this.loadUserVideoPreferences()) { if (this.loadUserVideoPreferences()) {
this.loadUserSettings(user) this.loadUserSettings(user)
} }

View file

@ -11,7 +11,6 @@
</div> </div>
<my-video-miniature <my-video-miniature
[containedInPlaylists]="videosContainedInPlaylists() ? videosContainedInPlaylists()[video.id] : undefined"
[video]="video" [displayAsRow]="true" [displayOptions]="miniatureDisplayOptions()" [video]="video" [displayAsRow]="true" [displayOptions]="miniatureDisplayOptions()"
[displayVideoActions]="false" [user]="user()" [displayVideoActions]="false" [user]="user()"
></my-video-miniature> ></my-video-miniature>

View file

@ -3,7 +3,7 @@ import { AfterContentInit, Component, contentChildren, inject, input, model, Tem
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
import { ComponentPagination, Notifier, resetCurrentPage, User } from '@app/core' import { ComponentPagination, Notifier, resetCurrentPage, User } from '@app/core'
import { objectKeysTyped } from '@peertube/peertube-core-utils' 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 { logger } from '@root-helpers/logger'
import { Observable, Subject } from 'rxjs' import { Observable, Subject } from 'rxjs'
import { PeertubeCheckboxComponent } from '../shared-forms/peertube-checkbox.component' import { PeertubeCheckboxComponent } from '../shared-forms/peertube-checkbox.component'
@ -23,7 +23,6 @@ export type SelectionType = { [id: number]: boolean }
export class VideosSelectionComponent implements AfterContentInit { export class VideosSelectionComponent implements AfterContentInit {
private notifier = inject(Notifier) private notifier = inject(Notifier)
readonly videosContainedInPlaylists = input<VideosExistInPlaylists>(undefined)
readonly user = input<User>(undefined) readonly user = input<User>(undefined)
readonly pagination = input<ComponentPagination>(undefined) readonly pagination = input<ComponentPagination>(undefined)

View file

@ -7,7 +7,7 @@
<my-video-thumbnail <my-video-thumbnail
*ngIf="playlistElement().video" *ngIf="playlistElement().video"
[video]="playlistElement().video" [nsfw]="isVideoBlur(playlistElement().video)" [video]="playlistElement().video" [blur]="hasNSFWBlur(playlistElement().video)"
[videoRouterLink]="buildRouterLink()" [queryParams]="buildRouterQuery()" [ariaLabel]="getVideoAriaLabel()" [videoRouterLink]="buildRouterLink()" [queryParams]="buildRouterQuery()" [ariaLabel]="getVideoAriaLabel()"
></my-video-thumbnail> ></my-video-thumbnail>
@ -21,8 +21,18 @@
[routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()" [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()"
>{{ playlistElement().video.name }}</a> >{{ playlistElement().video.name }}</a>
<span i18n *ngIf="isVideoPrivate()" class="pt-badge badge-yellow">Private</span> @if (isVideoPrivate()) {
<span i18n *ngIf="isVideoPasswordProtected()" class="pt-badge badge-yellow">Password protected</span> <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> </div>
<span class="date-and-views"> <span class="date-and-views">

View file

@ -26,6 +26,12 @@ my-video-thumbnail,
@include margin-right(10px); @include margin-right(10px);
} }
.nsfw-warning {
my-global-icon {
@include global-icon-size(18px);
}
}
.video { .video {
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;

View file

@ -1,23 +1,22 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, inject, input, output, viewChild } from '@angular/core' import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, inject, input, output, viewChild } from '@angular/core'
import { AuthService, Notifier, ServerService } from '@app/core' import { FormsModule } from '@angular/forms'
import { NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownButtonItem, NgbDropdownItem } from '@ng-bootstrap/ng-bootstrap' 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 { secondsToTime } from '@peertube/peertube-core-utils'
import { HTMLServerConfig, VideoPlaylistElementType, VideoPlaylistElementUpdate, VideoPrivacy } from '@peertube/peertube-models' 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 { VideoPlaylistElement } from './video-playlist-element.model'
import { VideoPlaylist } from './video-playlist.model' import { VideoPlaylist } from './video-playlist.model'
import { VideoPlaylistService } from './video-playlist.service' 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({ @Component({
selector: 'my-video-playlist-element-miniature', 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', templateUrl: './video-playlist-element-miniature.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
NgClass, CommonModule,
RouterLink, RouterLink,
NgIf,
GlobalIconComponent, GlobalIconComponent,
VideoThumbnailComponent, VideoThumbnailComponent,
DateToggleComponent, DateToggleComponent,
VideoViewsCounterComponent, VideoViewsCounterComponent,
NgbDropdown, NgbDropdownModule,
NgbDropdownToggle,
NgbDropdownMenu,
NgbDropdownButtonItem,
NgbDropdownItem,
PeertubeCheckboxComponent, PeertubeCheckboxComponent,
FormsModule, FormsModule,
TimestampInputComponent TimestampInputComponent,
NgbTooltipModule
] ]
}) })
export class VideoPlaylistElementMiniatureComponent implements OnInit { export class VideoPlaylistElementMiniatureComponent implements OnInit {
private authService = inject(AuthService) private userService = inject(UserService)
private serverService = inject(ServerService) private serverService = inject(ServerService)
private notifier = inject(Notifier) private notifier = inject(Notifier)
private videoPlaylistService = inject(VideoPlaylistService) private videoPlaylistService = inject(VideoPlaylistService)
@ -73,9 +68,14 @@ export class VideoPlaylistElementMiniatureComponent implements OnInit {
} = {} as any } = {} as any
private serverConfig: HTMLServerConfig private serverConfig: HTMLServerConfig
private user: User
ngOnInit (): void { ngOnInit (): void {
this.serverConfig = this.serverService.getHTMLConfig() this.serverConfig = this.serverService.getHTMLConfig()
this.userService.getAnonymousOrLoggedUser().subscribe(user => {
this.user = user
})
} }
getVideoAriaLabel () { getVideoAriaLabel () {
@ -125,8 +125,16 @@ export class VideoPlaylistElementMiniatureComponent implements OnInit {
} }
} }
isVideoBlur (video: Video) { hasNSFWBlur (video: Video) {
return video.isVideoNSFWForUser(this.authService.getUser(), this.serverConfig) 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) { removeFromPlaylist (playlistElement: VideoPlaylistElement) {

View file

@ -0,0 +1,5 @@
<span
*ngIf="video().nsfw"
class="button-unstyle pt-badge" [ngClass]="badgeClass"
[attr.aria-label]="tooltip" [ngbTooltip]="tooltip" i18n
>Sensitive</span>

View file

@ -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'
}
}

View 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

View file

@ -4,6 +4,7 @@ export * from './images'
export * from './local-storage-utils' export * from './local-storage-utils'
export * from './logger' export * from './logger'
export * from './peertube-web-storage' export * from './peertube-web-storage'
export * from './theme-manager'
export * from './plugins-manager' export * from './plugins-manager'
export * from './string' export * from './string'
export * from './url' export * from './url'

View file

@ -1,10 +1,15 @@
function getBoolOrDefault (value: string, defaultValue: boolean) { export function getBoolOrDefault (value: string, defaultValue: boolean) {
if (value === 'true') return true if (value === 'true') return true
if (value === 'false') return false if (value === 'false') return false
return defaultValue return defaultValue
} }
export { export function getNumberOrDefault (value: string, defaultValue: number) {
getBoolOrDefault if (!value) return defaultValue
const result = parseInt(value, 10)
if (isNaN(result)) return defaultValue
return result
} }

View 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]
}
}

View file

@ -5,6 +5,12 @@ export const UserLocalStorageKeys = {
EMAIL: 'email', EMAIL: 'email',
NSFW_POLICY: 'nsfw_policy', 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', P2P_ENABLED: 'peertube-videojs-webtorrent_enabled',
AUTO_PLAY_VIDEO: 'auto_play_video', AUTO_PLAY_VIDEO: 'auto_play_video',

View file

@ -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 embedUrl: string
embedTitle: string embedTitle: string
aspectRatio?: number aspectRatio?: number
@ -37,30 +37,71 @@ function buildVideoOrPlaylistEmbed (options: {
return iframe.outerHTML 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 (video.isLocal && config.tracker.enabled === false) return false
if (isWebRTCDisabled()) return false if (isWebRTCDisabled()) return false
return userP2PEnabled 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) || return new Set<VideoPrivacyType>([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]).has(video.privacy.id) ||
(video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED && !videoPassword) (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) return new Set<VideoPrivacyType>([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL, VideoPrivacy.PASSWORD_PROTECTED ]).has(video.privacy.id)
} }
export { export function isVideoNSFWWarnedForUser (video: Video, config: HTMLServerConfig, user: User) {
buildVideoOrPlaylistEmbed, if (video.nsfw === false) return false
isP2PEnabled, // Don't display NSFW warning for the owner of the video
videoRequiresUserAuth, if (user?.account?.id === video.account.id) return false
videoRequiresFileToken
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 () { function isWebRTCDisabled () {

View file

@ -68,16 +68,24 @@ body {
font-family: $main-fonts; font-family: $main-fonts;
} }
.btn-outline-secondary {
--bs-btn-color: #{pvar(--fg-300)};
}
.btn { .btn {
--bs-btn-active-color: inherit; --bs-btn-active-color: inherit;
--bs-btn-active-bg: inherit; --bs-btn-active-bg: inherit;
--bs-btn-active-border-color: 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 {
flex: auto; flex: auto;
} }
@ -86,6 +94,12 @@ body {
cursor: pointer !important; cursor: pointer !important;
} }
.btn-group-vertical {
label {
margin-bottom: 0;
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Dropdown // Dropdown
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -285,17 +299,6 @@ body {
font-size: $button-font-size; 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 { .form-control {
color: pvar(--fg); color: pvar(--fg);
background-color: pvar(--input-bg); background-color: pvar(--input-bg);

View file

@ -40,6 +40,11 @@ $badge-grey-dark: #2D3448;
font-size: 100%; font-size: 100%;
} }
&.badge-small {
font-size: 10px;
padding: 1px 3px;
}
&.badge-primary { &.badge-primary {
color: pvar(--on-primary); color: pvar(--on-primary);
background-color: pvar(--primary); background-color: pvar(--primary);

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