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'
export class AdminConfigPage {
@ -18,16 +19,16 @@ export class AdminConfigPage {
await $('h2=' + waitTitles[tab]).waitForDisplayed()
}
async updateNSFWSetting (newValue: 'do_not_list' | 'blur' | 'display') {
async updateNSFWSetting (newValue: NSFWPolicyType) {
await this.navigateTo('instance-information')
const elem = $('#instanceDefaultNSFWPolicy')
const elem = $(`#instanceDefaultNSFWPolicy-${newValue} + label`)
await elem.waitForDisplayed()
await elem.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header
await elem.waitForClickable()
return elem.selectByAttribute('value', newValue)
return elem.click()
}
async updateHomepage (newValue: string) {

View file

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

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

View file

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

View file

@ -1,7 +1,6 @@
import { browserSleep, isIOS, isMobileDevice, isSafari } from '../utils'
export class PlayerPage {
getWatchVideoPlayerCurrentTime () {
const elem = $('video')
@ -10,7 +9,7 @@ export class PlayerPage {
: elem.getProperty('currentTime')
return p.then(t => parseInt(t + '', 10))
.then(t => Math.ceil(t))
.then(t => Math.ceil(t))
}
waitUntilPlaylistInfo (text: string, maxTime: number) {
@ -28,6 +27,10 @@ export class PlayerPage {
})
}
waitUntilPlaying () {
return $('.video-js.vjs-playing').waitForDisplayed()
}
async playAndPauseVideo (isAutoplay: boolean, waitUntilSec: number) {
// Autoplay is disabled on mobile and Safari
if (isIOS() || isSafari() || isMobileDevice() || isAutoplay === false) {
@ -66,11 +69,31 @@ export class PlayerPage {
return this.clickOnPlayButton()
}
private async clickOnPlayButton () {
const playButton = () => $('.vjs-big-play-button')
getPlayButton () {
return $('.vjs-big-play-button')
}
await playButton().waitForClickable()
await playButton().click()
getNSFWContentText () {
return $('.video-js .nsfw-content').getText()
}
getNSFWMoreContent () {
return $('.video-js .nsfw-more-content')
}
getMoreNSFWInfoButton () {
return $('.video-js .nsfw-container button')
}
async hasPoster () {
const property = await $('.video-js .vjs-poster').getCSSProperty('background-image')
return property.value.startsWith('url(')
}
private async clickOnPlayButton () {
await this.getPlayButton().waitForClickable()
await this.getPlayButton().click()
}
async fillEmbedVideoPassword (videoPassword: string) {

View file

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

View file

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

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

View file

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

View file

@ -40,7 +40,7 @@ export class VideoWatchPage {
}
isPrivacyWarningDisplayed () {
return $('my-privacy-concerns').isDisplayed()
return $('.privacy-concerns-text').isDisplayed()
}
async goOnAssociatedEmbed (passwordProtected = false) {
@ -74,6 +74,79 @@ export class VideoWatchPage {
return go(FIXTURE_URLS.HLS_PLAYLIST_EMBED)
}
getModalTitleEl () {
return $('.modal-content .modal-title')
}
confirmModal () {
return $('.modal-content .modal-footer .primary-button').click()
}
private async getVideoNameElement () {
// We have 2 video info name block, pick the first that is not empty
const elem = async () => {
const elems = await $$('.video-info-first-row .video-info-name').filter(e => e.isDisplayed())
return elems[0]
}
await browser.waitUntil(async () => {
const e = await elem()
return e?.isDisplayed()
})
return elem()
}
// ---------------------------------------------------------------------------
// Video password
// ---------------------------------------------------------------------------
isPasswordProtected () {
return $('#confirmInput').isExisting()
}
async fillVideoPassword (videoPassword: string) {
const videoPasswordInput = await $('input#confirmInput')
await videoPasswordInput.waitForClickable()
await videoPasswordInput.clearValue()
await videoPasswordInput.setValue(videoPassword)
const confirmButton = await $('input[value="Confirm"]')
await confirmButton.waitForClickable()
return confirmButton.click()
}
// ---------------------------------------------------------------------------
// Video actions
// ---------------------------------------------------------------------------
async like () {
const likeButton = await $('.action-button-like')
const isActivated = (await likeButton.getAttribute('class')).includes('activated')
let count: number
try {
count = parseInt(await $('.action-button-like > .count').getText())
} catch (error) {
count = 0
}
await likeButton.waitForClickable()
await likeButton.click()
if (isActivated) {
if (count === 1) {
return expect(!await $('.action-button-like > .count').isExisting())
} else {
return expect(parseInt(await $('.action-button-like > .count').getText())).toBe(count - 1)
}
} else {
return expect(parseInt(await $('.action-button-like > .count').getText())).toBe(count + 1)
}
}
async clickOnManage () {
await this.clickOnMoreDropdownIcon()
@ -90,6 +163,17 @@ export class VideoWatchPage {
}
}
async clickOnMoreDropdownIcon () {
const dropdown = $('my-video-actions-dropdown .action-button')
await dropdown.click()
await $('.dropdown-menu.show .dropdown-item').waitForDisplayed()
}
// ---------------------------------------------------------------------------
// Playlists
// ---------------------------------------------------------------------------
clickOnSave () {
return $('.action-button-save').click()
}
@ -116,69 +200,9 @@ export class VideoWatchPage {
return playlist().click()
}
async clickOnMoreDropdownIcon () {
const dropdown = $('my-video-actions-dropdown .action-button')
await dropdown.click()
await $('.dropdown-menu.show .dropdown-item').waitForDisplayed()
}
private async getVideoNameElement () {
// We have 2 video info name block, pick the first that is not empty
const elem = async () => {
const elems = await $$('.video-info-first-row .video-info-name').filter(e => e.isDisplayed())
return elems[0]
}
await browser.waitUntil(async () => {
const e = await elem()
return e?.isDisplayed()
})
return elem()
}
isPasswordProtected () {
return $('#confirmInput').isExisting()
}
async fillVideoPassword (videoPassword: string) {
const videoPasswordInput = await $('input#confirmInput')
await videoPasswordInput.waitForClickable()
await videoPasswordInput.clearValue()
await videoPasswordInput.setValue(videoPassword)
const confirmButton = await $('input[value="Confirm"]')
await confirmButton.waitForClickable()
return confirmButton.click()
}
async like () {
const likeButton = await $('.action-button-like')
const isActivated = (await likeButton.getAttribute('class')).includes('activated')
let count: number
try {
count = parseInt(await $('.action-button-like > .count').getText())
} catch (error) {
count = 0
}
await likeButton.waitForClickable()
await likeButton.click()
if (isActivated) {
if (count === 1) {
return expect(!await $('.action-button-like > .count').isExisting())
} else {
return expect(parseInt(await $('.action-button-like > .count').getText())).toBe(count - 1)
}
} else {
return expect(parseInt(await $('.action-button-like > .count').getText())).toBe(count + 1)
}
}
// ---------------------------------------------------------------------------
// Comments
// ---------------------------------------------------------------------------
async createThread (comment: string) {
const textarea = await $('my-video-comment-add textarea')

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

View file

@ -1,22 +1,41 @@
import { LoginPage } from '../po/login.po'
import { VideoPublishPage } from '../po/video-publish.po'
import { getScreenshotPath, isMobileDevice, waitServerUp } from '../utils'
import { VideoWatchPage } from '../po/video-watch.po'
import { getScreenshotPath, isMobileDevice, isSafari, waitServerUp } from '../utils'
describe('Publish video', () => {
let videoPublishPage: VideoPublishPage
let loginPage: LoginPage
let videoWatchPage: VideoWatchPage
before(async () => {
await waitServerUp()
loginPage = new LoginPage(isMobileDevice())
videoPublishPage = new VideoPublishPage()
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
await browser.maximizeWindow()
await loginPage.loginAsRootUser()
})
describe('Default upload values', function () {
it('Should have default video values', async function () {
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video3.mp4')
await videoPublishPage.validSecondStep('video')
await videoPublishPage.clickOnWatch()
await videoWatchPage.waitWatchVideoName('video')
expect(await videoWatchPage.getPrivacy()).toBe('Public')
expect(await videoWatchPage.getLicence()).toBe('Unknown')
expect(await videoWatchPage.isDownloadEnabled()).toBeTruthy()
expect(await videoWatchPage.areCommentsEnabled()).toBeTruthy()
})
})
describe('Common', function () {
it('Should upload a video and on refresh being redirected to the manage page', async function () {
await videoPublishPage.navigateTo()

View file

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

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.click()
const option = await $$(`[formcontrolname=${id}] li[role=option]`).filter(async o => {
const text = await o.getText()
const getOption = async () => {
const options = await $$(`[formcontrolname=${id}] li[role=option]`).filter(async o => {
const text = await o.getText()
return text.trimStart().startsWith(valueLabel)
}).then(options => options[0])
return text.trimStart().startsWith(valueLabel)
})
await option.waitForDisplayed()
if (options.length === 0) return undefined
return option.click()
return options[0]
}
await browser.waitUntil(async () => {
const option = await getOption()
if (!option) return false
return option.isDisplayed()
})
return (await getOption()).click()
}
export async function findParentElement (
el: WebdriverIO.Element,
el: ChainablePromiseElement,
finder: (el: WebdriverIO.Element) => Promise<boolean>
) {
if (await finder(el) === true) return el

View file

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

View file

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

View file

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

View file

@ -11,7 +11,7 @@
<span *ngFor="let category of categories" class="pt-badge badge-secondary me-1">{{ category }}</span>
</div>
<div i18n *ngIf="config.instance.isNSFW" class="fw-bold text-content mt-3">{{ config.instance.name }} is dedicated to sensitive/NSFW content.</div>
<div i18n *ngIf="config.instance.isNSFW" class="fw-bold text-content mt-3">{{ config.instance.name }} is dedicated to sensitive content.</div>
</div>
<div class="block description">

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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 { TableExpanderIconComponent } from '../../../shared/shared-tables/table-expander-icon.component'
import { VideoCellComponent } from '../../../shared/shared-tables/video-cell.component'
import { VideoNSFWBadgeComponent } from '../../../shared/shared-video/video-nsfw-badge.component'
@Component({
selector: 'my-video-block-list',
@ -36,7 +37,8 @@ import { VideoCellComponent } from '../../../shared/shared-tables/video-cell.com
VideoCellComponent,
AutoColspanDirective,
EmbedComponent,
PTDatePipe
PTDatePipe,
VideoNSFWBadgeComponent
]
})
export class VideoBlockListComponent extends RestTable implements OnInit {

View file

@ -74,7 +74,7 @@
<my-video-privacy-badge [video]="video"></my-video-privacy-badge>
<span *ngIf="video.nsfw" class="pt-badge badge-red" i18n>NSFW</span>
<my-video-nsfw-badge [video]="video" theme="red"></my-video-nsfw-badge>
<span *ngIf="isUnpublished(video)" class="pt-badge badge-yellow">{{ video.state.label }}</span>

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 { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
import { getAllFiles } from '@peertube/peertube-core-utils'
import { FileStorage, UserRight, VideoFile, VideoState, VideoStreamingPlaylistType } from '@peertube/peertube-models'
import { FileStorage, NSFWFlag, UserRight, VideoFile, VideoState, VideoStreamingPlaylistType } from '@peertube/peertube-models'
import { videoRequiresFileToken } from '@root-helpers/video'
import { SharedModule, SortMeta } from 'primeng/api'
import { TableModule, TableRowExpandEvent } from 'primeng/table'
@ -31,6 +31,7 @@ import {
VideoActionsDisplayType,
VideoActionsDropdownComponent
} from '../../../shared/shared-video-miniature/video-actions-dropdown.component'
import { VideoNSFWBadgeComponent } from '../../../shared/shared-video/video-nsfw-badge.component'
import { VideoPrivacyBadgeComponent } from '../../../shared/shared-video/video-privacy-badge.component'
import { VideoAdminService } from './video-admin.service'
@ -58,7 +59,8 @@ import { VideoAdminService } from './video-admin.service'
PTDatePipe,
RouterLink,
BytesPipe,
VideoPrivacyBadgeComponent
VideoPrivacyBadgeComponent,
VideoNSFWBadgeComponent
]
})
export class VideoListComponent extends RestTable<Video> implements OnInit {
@ -305,7 +307,11 @@ export class VideoListComponent extends RestTable<Video> implements OnInit {
this.videoAdminService.getAdminVideos({
pagination: this.pagination,
sort: this.sort,
nsfw: 'both', // Always list NSFW video, overriding instance/user setting
// Always list NSFW video, overriding instance/user setting
nsfw: 'both',
nsfwFlagsExcluded: NSFWFlag.NONE,
search: this.search
}).pipe(finalize(() => this.loading = false))
.subscribe({

View file

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

View file

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

View file

@ -145,7 +145,7 @@
</td>
<td *ngIf="isSelected('sensitive')">
<span *ngIf="video.nsfw" class="pt-badge badge-yellow" i18n>NSFW</span>
<my-video-nsfw-badge [video]="video"></my-video-nsfw-badge>
</td>
<td *ngIf="isSelected('playlists')">

View file

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

View file

@ -35,25 +35,6 @@
</div>
</div>
<div class="form-group" role="radiogroup">
<div class="radio-label label-container">
<label for="sensitiveContent" i18n>Display sensitive content</label>
<button type="button" i18n class="reset-button reset-button-small" (click)="resetField('nsfw')" *ngIf="advancedSearch().nsfw !== undefined">
Reset
</button>
</div>
<div class="peertube-radio-container">
<input type="radio" name="sensitiveContent" id="sensitiveContentYes" value="both" [(ngModel)]="advancedSearch().nsfw">
<label i18n for="sensitiveContentYes" class="radio">Yes</label>
</div>
<div class="peertube-radio-container">
<input type="radio" name="sensitiveContent" id="sensitiveContentNo" value="false" [(ngModel)]="advancedSearch().nsfw">
<label i18n for="sensitiveContentNo" class="radio">No</label>
</div>
</div>
<div class="form-group">
<div class="radio-label label-container">
<label for="publishedDateRange" i18n>Published date</label>

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -9,27 +9,54 @@
<div class="form-columns">
<div>
<div class="form-group">
<label i18n for="commentsPolicy">Comments policy</label>
<div class="form-group-description" i18n>
You can require comments to be approved depending on <a routerLink="/my-account/auto-tag-policies" target="_blank">your auto-tags policies</a>
</div>
<my-select-options inputId="commentsPolicy" [items]="commentPolicies" formControlName="commentsPolicy"></my-select-options>
<div *ngIf="formErrors.commentsPolicy" class="form-error" role="alert">
{{ formErrors.commentsPolicy }}
</div>
<my-select-radio
i18n-label label="Comments policy"
[items]="commentPolicies"
inputId="commentsPolicy"
formControlName="commentsPolicy"
>
<div class="form-group-description" i18n>
You can require comments to be approved depending on <a routerLink="/my-account/auto-tag-policies" target="_blank">your auto-tags policies</a>
</div>
</my-select-radio>
</div>
<my-peertube-checkbox inputName="nsfw" formControlName="nsfw" helpPlacement="bottom-right">
<my-peertube-checkbox inputName="nsfw" formControlName="nsfw">
<ng-template ptTemplate="label">
<ng-container i18n>Contains sensitive content</ng-container>
<ng-container i18n>Your video contains sensitive content</ng-container>
</ng-template>
<ng-template ptTemplate="help">
<ng-container ngProjectAs="description">
<ng-container i18n>Some instances hide videos containing mature or explicit content by default.</ng-container>
</ng-template>
</ng-container>
<ng-container ngProjectAs="extra">
<my-peertube-checkbox inputName="nsfwFlagViolent" formControlName="nsfwFlagViolent">
<ng-template ptTemplate="label">
<ng-container i18n>Some images may be violent</ng-container>
</ng-template>
</my-peertube-checkbox>
<my-peertube-checkbox inputName="nsfwFlagShocking" formControlName="nsfwFlagShocking">
<ng-template ptTemplate="label">
<ng-container i18n>Parts of the video may shock or disturb</ng-container>
</ng-template>
</my-peertube-checkbox>
<my-peertube-checkbox inputName="nsfwFlagSex" formControlName="nsfwFlagSex">
<ng-template ptTemplate="label">
<ng-container i18n>Content may be perceived as sexually explicit material</ng-container>
</ng-template>
</my-peertube-checkbox>
<div class="form-group">
<label i18n for="nsfwSummary">Describe what makes your content sensitive</label>
<input type="text" id="nsfwSummary" class="form-control" formControlName="nsfwSummary" [ngClass]="{ 'input-error': formErrors.nsfwSummary }" />
<div *ngIf="formErrors.nsfwSummary" class="form-error" role="alert">{{ formErrors.nsfwSummary }}</div>
</div>
</ng-container>
</my-peertube-checkbox>
</div>
</div>

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

View file

@ -1,11 +1,9 @@
import { Injectable, inject } from '@angular/core'
import { sortBy } from '@peertube/peertube-core-utils'
import { HTMLServerConfig, ServerConfig, ServerConfigTheme } from '@peertube/peertube-models'
import { logger } from '@root-helpers/logger'
import { capitalizeFirstLetter } from '@root-helpers/string'
import { ThemeManager } from '@root-helpers/theme-manager'
import { UserLocalStorageKeys } from '@root-helpers/users'
import { getLuminance, parse, toHSLA } from 'color-bits'
import debug from 'debug'
import { environment } from '../../../environments/environment'
import { AuthService } from '../auth'
import { PluginService } from '../plugins/plugin.service'
@ -13,8 +11,6 @@ import { ServerService } from '../server'
import { UserService } from '../users/user.service'
import { LocalStorageService } from '../wrappers/storage.service'
const debugLogger = debug('peertube:theme')
@Injectable()
export class ThemeService {
private auth = inject(AuthService)
@ -23,7 +19,6 @@ export class ThemeService {
private server = inject(ServerService)
private localStorageService = inject(LocalStorageService)
private oldInjectedProperties: string[] = []
private oldThemeName: string
private internalThemes: string[] = []
@ -34,6 +29,8 @@ export class ThemeService {
private serverConfig: HTMLServerConfig
private themeManager = new ThemeManager()
initialize () {
this.serverConfig = this.server.getHTMLConfig()
this.internalThemes = this.serverConfig.theme.builtIn.map(t => t.name)
@ -80,30 +77,19 @@ export class ThemeService {
logger.info(`Injecting ${this.themes.length} themes.`)
const head = this.getHeadElement()
for (const theme of this.themes) {
// Already added this theme?
if (fromLocalStorage === false && this.themeFromLocalStorage && this.themeFromLocalStorage.name === theme.name) continue
for (const css of theme.css) {
const link = document.createElement('link')
const links = this.themeManager.injectTheme(theme, environment.apiUrl)
const href = environment.apiUrl + `/themes/${theme.name}/${theme.version}/css/${css}`
link.setAttribute('href', href)
link.setAttribute('rel', 'alternate stylesheet')
link.setAttribute('type', 'text/css')
link.setAttribute('title', theme.name)
link.setAttribute('disabled', '')
if (fromLocalStorage === true) this.themeDOMLinksFromLocalStorage.push(link)
head.appendChild(link)
if (fromLocalStorage === true) {
this.themeDOMLinksFromLocalStorage = [ ...this.themeDOMLinksFromLocalStorage, ...links ]
}
}
}
private getCurrentTheme () {
private getCurrentThemeName () {
if (this.themeFromLocalStorage) return this.themeFromLocalStorage.name
const theme = this.auth.isLoggedIn()
@ -123,43 +109,24 @@ export class ThemeService {
return instanceTheme
}
private loadThemeStyle (name: string) {
const links = document.getElementsByTagName('link')
for (let i = 0; i < links.length; i++) {
const link = links[i]
if (link.getAttribute('rel').includes('style') && link.getAttribute('title')) {
link.disabled = link.getAttribute('title') !== name
if (!link.disabled) {
link.onload = () => this.injectColorPalette()
} else {
link.onload = undefined
}
}
}
document.body.dataset.ptTheme = name
}
private updateCurrentTheme () {
const currentTheme = this.getCurrentTheme()
if (this.oldThemeName === currentTheme) return
const currentThemeName = this.getCurrentThemeName()
if (this.oldThemeName === currentThemeName) return
if (this.oldThemeName) this.removeThemePlugins(this.oldThemeName)
logger.info(`Enabling ${currentTheme} theme.`)
logger.info(`Enabling ${currentThemeName} theme.`)
this.loadThemeStyle(currentTheme)
this.themeManager.loadThemeStyle(currentThemeName)
const theme = this.getTheme(currentTheme)
const theme = this.getTheme(currentThemeName)
if (this.internalThemes.includes(currentTheme)) {
logger.info(`Enabling internal theme ${currentTheme}`)
if (this.internalThemes.includes(currentThemeName)) {
logger.info(`Enabling internal theme ${currentThemeName}`)
this.localStorageService.setItem(UserLocalStorageKeys.LAST_ACTIVE_THEME, JSON.stringify({ name: currentTheme }), false)
this.localStorageService.setItem(UserLocalStorageKeys.LAST_ACTIVE_THEME, JSON.stringify({ name: currentThemeName }), false)
} else if (theme) {
logger.info(`Adding scripts of theme ${currentTheme}`)
logger.info(`Adding scripts of theme ${currentThemeName}`)
this.pluginService.addPlugin(theme, true)
@ -170,161 +137,9 @@ export class ThemeService {
this.localStorageService.removeItem(UserLocalStorageKeys.LAST_ACTIVE_THEME, false)
}
this.injectCoreColorPalette()
this.themeManager.injectCoreColorPalette()
this.oldThemeName = currentTheme
}
private injectCoreColorPalette (iteration = 0) {
if (iteration > 10) {
logger.error('Cannot inject core color palette: too many iterations')
return
}
if (!this.canInjectCoreColorPalette()) {
return setTimeout(() => this.injectCoreColorPalette(iteration + 1))
}
return this.injectColorPalette()
}
private canInjectCoreColorPalette () {
const computedStyle = getComputedStyle(document.body)
const isDark = computedStyle.getPropertyValue('--is-dark')
return isDark === '0' || isDark === '1'
}
private injectColorPalette () {
console.log(`Injecting color palette`)
const rootStyle = document.body.style
const computedStyle = getComputedStyle(document.body)
// FIXME: Remove previously injected properties
for (const property of this.oldInjectedProperties) {
rootStyle.removeProperty(property)
}
this.oldInjectedProperties = []
const isGlobalDarkTheme = () => {
return this.isDarkTheme({
fg: computedStyle.getPropertyValue('--fg') || computedStyle.getPropertyValue('--mainForegroundColor'),
bg: computedStyle.getPropertyValue('--bg') || computedStyle.getPropertyValue('--mainBackgroundColor'),
isDarkVar: computedStyle.getPropertyValue('--is-dark')
})
}
const isMenuDarkTheme = () => {
return this.isDarkTheme({
fg: computedStyle.getPropertyValue('--menu-fg'),
bg: computedStyle.getPropertyValue('--menu-bg'),
isDarkVar: computedStyle.getPropertyValue('--is-menu-dark')
})
}
const toProcess = [
{ prefix: 'primary', invertIfDark: true, step: 5, darkTheme: isGlobalDarkTheme },
{ prefix: 'on-primary', invertIfDark: true, step: 5, darkTheme: isGlobalDarkTheme },
{ prefix: 'bg-secondary', invertIfDark: true, step: 5, darkTheme: isGlobalDarkTheme },
{ prefix: 'fg', invertIfDark: true, fallbacks: { '--fg-300': '--greyForegroundColor' }, step: 5, darkTheme: isGlobalDarkTheme },
{ prefix: 'input-bg', invertIfDark: true, step: 5, darkTheme: isGlobalDarkTheme },
{ prefix: 'menu-fg', invertIfDark: true, step: 5, darkTheme: isMenuDarkTheme },
{ prefix: 'menu-bg', invertIfDark: true, step: 5, darkTheme: isMenuDarkTheme }
] as { prefix: string, invertIfDark: boolean, step: number, darkTheme: () => boolean, fallbacks?: Record<string, string> }[]
for (const { prefix, invertIfDark, step, darkTheme, fallbacks = {} } of toProcess) {
const mainColor = computedStyle.getPropertyValue('--' + prefix)
const darkInverter = invertIfDark && darkTheme()
? -1
: 1
if (!mainColor) {
console.error(`Cannot create palette of nonexistent "--${prefix}" CSS body variable`)
continue
}
// Use trim for some web browsers: https://github.com/Chocobozzz/PeerTube/issues/6952
const mainColorHSL = toHSLA(parse(mainColor.trim()))
debugLogger(`Theme main variable ${mainColor} -> ${this.toHSLStr(mainColorHSL)}`)
// Inject in alphabetical order for easy debug
const toInject: { id: number, key: string, value: string }[] = [
{ id: 500, key: `--${prefix}-500`, value: this.toHSLStr(mainColorHSL) }
]
for (const j of [ -1, 1 ]) {
let lastColorHSL = { ...mainColorHSL }
for (let i = 1; i <= 9; i++) {
const suffix = 500 + (50 * i * j)
const key = `--${prefix}-${suffix}`
const existingValue = computedStyle.getPropertyValue(key)
if (!existingValue || existingValue === '0') {
const newLuminance = this.buildNewLuminance(lastColorHSL, j * step, darkInverter)
const newColorHSL = { ...lastColorHSL, l: newLuminance }
const newColorStr = this.toHSLStr(newColorHSL)
const value = fallbacks[key]
? `var(${fallbacks[key]}, ${newColorStr})`
: newColorStr
toInject.push({ id: suffix, key, value })
lastColorHSL = newColorHSL
debugLogger(`Injected theme palette ${key} -> ${value}`)
} else {
lastColorHSL = toHSLA(parse(existingValue))
}
}
}
for (const { key, value } of sortBy(toInject, 'id')) {
rootStyle.setProperty(key, value)
this.oldInjectedProperties.push(key)
}
}
document.body.dataset.bsTheme = isGlobalDarkTheme()
? 'dark'
: ''
}
private buildNewLuminance (base: { l: number }, factor: number, darkInverter: number) {
return Math.max(Math.min(100, Math.round(base.l + (factor * -1 * darkInverter))), 0)
}
private toHSLStr (c: { h: number, s: number, l: number, a: number }) {
return `hsl(${Math.round(c.h)} ${Math.round(c.s)}% ${Math.round(c.l)}% / ${Math.round(c.a)})`
}
private isDarkTheme (options: {
fg: string
bg: string
isDarkVar: string
}) {
const { fg, bg, isDarkVar } = options
if (isDarkVar === '1') {
return true
} else if (fg && bg) {
try {
if (getLuminance(parse(bg)) < getLuminance(parse(fg))) {
return true
}
} catch (err) {
console.error('Cannot parse deprecated CSS variables', err)
}
}
return false
this.oldThemeName = currentThemeName
}
private listenUserTheme () {
@ -381,9 +196,8 @@ export class ThemeService {
this.removeThemePlugins(this.themeFromLocalStorage.name)
this.oldThemeName = undefined
const head = this.getHeadElement()
for (const htmlLinkElement of this.themeDOMLinksFromLocalStorage) {
head.removeChild(htmlLinkElement)
this.themeManager.removeThemeLink(htmlLinkElement)
}
this.themeFromLocalStorage = undefined
@ -391,10 +205,6 @@ export class ThemeService {
}
}
private getHeadElement () {
return document.getElementsByTagName('head')[0]
}
private getTheme (name: string) {
return this.themes.find(t => t.name === name)
}

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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'),
'p2p': require('../../../assets/images/feather/airplay.svg'),
'play': require('../../../assets/images/feather/play.svg'),
'circle-alert': require('../../../assets/images/feather/circle-alert.svg'),
'playlists': require('../../../assets/images/feather/playlists.svg'),
'refresh': require('../../../assets/images/feather/refresh-cw.svg'),
'repeat': require('../../../assets/images/feather/repeat.svg'),

View file

@ -10,7 +10,7 @@
<tr>
<th class="t-label" scope="row">
<div i18n>Default NSFW/sensitive videos policy</div>
<div i18n>Default sensitive content policy</div>
<span i18n class="fs-7 fw-normal fst-italic">can be redefined by the users</span>
</th>

View file

@ -66,7 +66,8 @@ export class InstanceFeaturesTableComponent implements OnInit {
const policy = this.serverConfig().instance.defaultNSFWPolicy
if (policy === 'do_not_list') return $localize`Hidden`
if (policy === 'blur') return $localize`Blurred with confirmation request`
if (policy === 'warn') return $localize`Warn users`
if (policy === 'blur') return $localize`Warn users and blur thumbnail`
if (policy === 'display') return $localize`Displayed`
}

View file

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

View file

@ -18,6 +18,7 @@ import {
FeedFormatType,
FeedType,
FeedType_Type,
NSFWFlag,
NSFWPolicyType,
ResultList,
ServerErrorCode,
@ -30,7 +31,6 @@ import {
VideoDetails as VideoDetailsServerModel,
VideoFile,
VideoFileMetadata,
VideoIncludeType,
VideoPrivacy,
VideoPrivacyType,
VideosCommonQuery,
@ -45,26 +45,14 @@ import { from, Observable, of, throwError } from 'rxjs'
import { catchError, concatMap, map, switchMap, toArray } from 'rxjs/operators'
import { environment } from '../../../../environments/environment'
import { Account } from '../account/account.model'
import { AccountService } from '../account/account.service'
import { VideoChannel } from '../channel/video-channel.model'
import { VideoChannelService } from '../channel/video-channel.service'
import { VideoDetails } from './video-details.model'
import { VideoPasswordService } from './video-password.service'
import { Video } from './video.model'
export type CommonVideoParams = {
export type CommonVideoParams = Omit<VideosCommonQuery, 'start' | 'count' | 'sort'> & {
videoPagination?: ComponentPaginationLight
sort: VideoSortField | SortMeta
include?: VideoIncludeType
isLocal?: boolean
categoryOneOf?: number[]
languageOneOf?: string[]
privacyOneOf?: VideoPrivacyType[]
isLive?: boolean
skipCount?: boolean
nsfw?: BooleanBothQuery
host?: string
search?: string
}
@Injectable()
@ -75,6 +63,7 @@ export class VideoService {
private restService = inject(RestService)
private serverService = inject(ServerService)
private confirmService = inject(ConfirmService)
private userService = inject(UserService)
static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos'
static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
@ -113,6 +102,17 @@ export class VideoService {
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
removeVideo (idArg: number | number[]) {
const ids = arrayify(idArg)
return from(ids)
.pipe(
concatMap(id => this.authHttp.delete(`${VideoService.BASE_VIDEO_URL}/${id}`)),
toArray(),
catchError(err => this.restExtractor.handleError(err))
)
}
listMyVideos (options: {
videoPagination?: ComponentPaginationLight
restPagination?: RestPagination
@ -154,45 +154,30 @@ export class VideoService {
)
}
getAccountVideos (
listAccountVideos (
options: CommonVideoParams & {
account: Pick<Account, 'nameWithHost'>
}
): Observable<ResultList<Video>> {
const { account, ...parameters } = options
let params = new HttpParams()
params = this.buildCommonVideosParams({ params, ...parameters })
return this.authHttp
.get<ResultList<Video>>(AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/videos', { params })
.pipe(
switchMap(res => this.extractVideos(res)),
catchError(err => this.restExtractor.handleError(err))
)
return this.listVideos({ ...options, videoChannel: options.account })
}
getVideoChannelVideos (
parameters: CommonVideoParams & {
listChannelVideos (
options: CommonVideoParams & {
videoChannel: Pick<VideoChannel, 'nameWithHost'>
}
): Observable<ResultList<Video>> {
const { videoChannel } = parameters
let params = new HttpParams()
params = this.buildCommonVideosParams({ params, ...parameters })
return this.authHttp
.get<ResultList<Video>>(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/videos', { params })
.pipe(
switchMap(res => this.extractVideos(res)),
catchError(err => this.restExtractor.handleError(err))
)
return this.listVideos({ ...options, videoChannel: options.videoChannel })
}
getVideos (parameters: CommonVideoParams): Observable<ResultList<Video>> {
listVideos (
options: CommonVideoParams & {
videoChannel?: Pick<VideoChannel, 'nameWithHost'>
account?: Pick<Account, 'nameWithHost'>
}
): Observable<ResultList<Video>> {
let params = new HttpParams()
params = this.buildCommonVideosParams({ params, ...parameters })
params = this.buildCommonVideosParams({ params, ...options })
return this.authHttp
.get<ResultList<Video>>(VideoService.BASE_VIDEO_URL, { params })
@ -202,6 +187,87 @@ export class VideoService {
)
}
buildCommonVideosParams (options: CommonVideoParams & { params: HttpParams }) {
const {
params,
videoPagination,
sort,
categoryOneOf,
languageOneOf,
privacyOneOf,
skipCount,
search,
nsfw,
nsfwFlagsExcluded,
nsfwFlagsIncluded,
...otherOptions
} = options
const pagination = videoPagination
? this.restService.componentToRestPagination(videoPagination)
: undefined
let newParams = this.restService.addRestGetParams(params, pagination, this.buildListSort(sort))
if (skipCount) newParams = newParams.set('skipCount', skipCount + '')
if (languageOneOf !== undefined) newParams = this.restService.addArrayParams(newParams, 'languageOneOf', languageOneOf)
if (categoryOneOf !== undefined) newParams = this.restService.addArrayParams(newParams, 'categoryOneOf', categoryOneOf)
if (privacyOneOf !== undefined) newParams = this.restService.addArrayParams(newParams, 'privacyOneOf', privacyOneOf)
if (search) newParams = newParams.set('search', search)
newParams = this.buildNSFWParams(newParams, { nsfw, nsfwFlagsExcluded, nsfwFlagsIncluded })
return this.restService.addObjectParams(newParams, otherOptions)
}
buildNSFWParams (params: HttpParams, options: Pick<CommonVideoParams, 'nsfw' | 'nsfwFlagsExcluded' | 'nsfwFlagsIncluded'> = {}) {
const { nsfw, nsfwFlagsExcluded, nsfwFlagsIncluded } = options
const anonymous = this.auth.isLoggedIn()
? undefined
: this.userService.getAnonymousUser()
const anonymousFlagsExcluded = anonymous
? anonymous.nsfwFlagsHidden
: undefined
const anonymousFlagsIncluded = anonymous
? anonymous.nsfwFlagsDisplayed | anonymous.nsfwFlagsBlurred | anonymous.nsfwFlagsWarned
: undefined
if (nsfw !== undefined) params = params.set('nsfw', nsfw)
else if (anonymous?.nsfwPolicy) params = params.set('nsfw', this.nsfwPolicyToParam(anonymous.nsfwPolicy))
if (nsfwFlagsExcluded !== undefined) params = params.set('nsfwFlagsExcluded', nsfwFlagsExcluded)
else if (anonymousFlagsExcluded !== undefined) params = params.set('nsfwFlagsExcluded', anonymousFlagsExcluded)
if (nsfwFlagsIncluded !== undefined) params = params.set('nsfwFlagsIncluded', nsfwFlagsIncluded)
else if (anonymousFlagsIncluded !== undefined) params = params.set('nsfwFlagsIncluded', anonymousFlagsIncluded)
return params
}
private buildListSort (sortArg: VideoSortField | SortMeta) {
const sort = this.restService.buildSortString(sortArg)
if (typeof sort === 'string') {
// Silently use the best algorithm for logged in users if they chose the hot algorithm
if (
this.auth.isLoggedIn() &&
(sort === 'hot' || sort === '-hot')
) {
return sort.replace('hot', 'best')
}
return sort
}
}
// ---------------------------------------------------------------------------
// Video feeds
// ---------------------------------------------------------------------------
buildBaseFeedUrls (params: HttpParams, base = VideoService.BASE_FEEDS_URL) {
const feeds: { type: FeedType_Type, format: FeedFormatType, label: string, url: string }[] = [
{
@ -278,6 +344,10 @@ export class VideoService {
return this.buildBaseFeedUrls(params, VideoService.BASE_SUBSCRIPTION_FEEDS_URL)
}
// ---------------------------------------------------------------------------
// Video files
// ---------------------------------------------------------------------------
getVideoFileMetadata (metadataUrl: string) {
return this.authHttp
.get<VideoFileMetadata>(metadataUrl)
@ -286,17 +356,6 @@ export class VideoService {
)
}
removeVideo (idArg: number | number[]) {
const ids = arrayify(idArg)
return from(ids)
.pipe(
concatMap(id => this.authHttp.delete(`${VideoService.BASE_VIDEO_URL}/${id}`)),
toArray(),
catchError(err => this.restExtractor.handleError(err))
)
}
removeVideoFiles (videoIds: (number | string)[], type: 'hls' | 'web-videos') {
return from(videoIds)
.pipe(
@ -316,6 +375,8 @@ export class VideoService {
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
// ---------------------------------------------------------------------------
runTranscoding (options: {
videos: Video[]
type: 'hls' | 'web-video'
@ -475,6 +536,28 @@ export class VideoService {
}
}
buildNSFWTooltip (video: Pick<VideoServerModel, 'nsfw' | 'nsfwFlags'>) {
const flags: string[] = []
if ((video.nsfwFlags & NSFWFlag.VIOLENT) === NSFWFlag.VIOLENT) {
flags.push($localize`violence`)
}
if ((video.nsfwFlags & NSFWFlag.SHOCKING_DISTURBING) === NSFWFlag.SHOCKING_DISTURBING) {
flags.push($localize`shocking content`)
}
if ((video.nsfwFlags & NSFWFlag.EXPLICIT_SEX) === NSFWFlag.EXPLICIT_SEX) {
flags.push($localize`explicit sex`)
}
if (flags.length === 0) {
return $localize`This video contains sensitive content`
}
return $localize`This video contains sensitive content: ${flags.join(' - ')}`
}
getHighestAvailablePrivacy (serverPrivacies: VideoConstant<VideoPrivacyType>[]) {
// We do not add a password as this requires additional configuration.
const order = [
@ -517,64 +600,6 @@ export class VideoService {
return 'videoChannel' as 'videoChannel'
}
buildCommonVideosParams (options: CommonVideoParams & { params: HttpParams }) {
const {
params,
videoPagination,
sort,
isLocal,
include,
categoryOneOf,
languageOneOf,
privacyOneOf,
skipCount,
isLive,
nsfw,
search,
host,
...otherOptions
} = options
const pagination = videoPagination
? this.restService.componentToRestPagination(videoPagination)
: undefined
let newParams = this.restService.addRestGetParams(params, pagination, this.buildListSort(sort))
if (skipCount) newParams = newParams.set('skipCount', skipCount + '')
if (isLocal !== undefined) newParams = newParams.set('isLocal', isLocal)
if (include !== undefined) newParams = newParams.set('include', include)
if (isLive !== undefined) newParams = newParams.set('isLive', isLive)
if (nsfw !== undefined) newParams = newParams.set('nsfw', nsfw)
if (languageOneOf !== undefined) newParams = this.restService.addArrayParams(newParams, 'languageOneOf', languageOneOf)
if (categoryOneOf !== undefined) newParams = this.restService.addArrayParams(newParams, 'categoryOneOf', categoryOneOf)
if (privacyOneOf !== undefined) newParams = this.restService.addArrayParams(newParams, 'privacyOneOf', privacyOneOf)
if (search) newParams = newParams.set('search', search)
if (host) newParams = newParams.set('host', host)
newParams = this.restService.addObjectParams(newParams, otherOptions)
return newParams
}
private buildListSort (sortArg: VideoSortField | SortMeta) {
const sort = this.restService.buildSortString(sortArg)
if (typeof sort === 'string') {
// Silently use the best algorithm for logged in users if they chose the hot algorithm
if (
this.auth.isLoggedIn() &&
(sort === 'hot' || sort === '-hot')
) {
return sort.replace('hot', 'best')
}
return sort
}
}
private setVideoRate (id: string, rateType: UserVideoRateType, videoPassword?: string) {
const url = `${VideoService.BASE_VIDEO_URL}/${id}/rate`
const body: UserVideoRateUpdate = {

View file

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

View file

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

View file

@ -1,12 +1,12 @@
<div class="root" [ngClass]="{ 'small': size() === 'small' }">
<my-video-thumbnail
*ngIf="thumbnail()"
*ngIf="thumbnail()" blur="false"
[video]="video()" [videoRouterLink]="getVideoUrl()" [ariaLabel]="video().name" playOverlay="false"
></my-video-thumbnail>
<div *ngIf="title()" class="min-width-0" [ngClass]="{ ellipsis: ellipsis }">
<div class="d-flex flex-wrap">
<div class="d-flex flex-wrap gap-2 align-items-center">
<a class="name" [ngClass]="{ ellipsis: ellipsis }" [routerLink]="getVideoUrl()" [title]="video().name">
{{ video().name }}
</a>

View file

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

View file

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

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

View file

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

View file

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

View file

@ -1,17 +1,33 @@
import { NgIf } from '@angular/common'
import { Component, OnDestroy, OnInit, inject, input } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { AuthService, Notifier, ServerService, User, UserService } from '@app/core'
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { NSFWPolicyType, UserUpdateMe } from '@peertube/peertube-models'
import { FormReactiveErrors, FormReactiveService, FormReactiveValidationMessages } from '@app/shared/shared-forms/form-reactive.service'
import { NSFWFlag, NSFWFlagType, NSFWPolicyType, UserUpdateMe } from '@peertube/peertube-models'
import { pick } from 'lodash-es'
import { Subject, Subscription } from 'rxjs'
import { first } from 'rxjs/operators'
import { SelectOptionsItem } from 'src/types'
import { BuildFormArgument } from '../form-validators/form-validator.model'
import { PeertubeCheckboxComponent } from '../shared-forms/peertube-checkbox.component'
import { SelectLanguagesComponent } from '../shared-forms/select/select-languages.component'
import { SelectRadioComponent } from '../shared-forms/select/select-radio.component'
import { HelpComponent } from '../shared-main/buttons/help.component'
type NSFWFlagPolicyType = NSFWPolicyType | 'default'
type Form = {
nsfwPolicy: FormControl<NSFWPolicyType>
nsfwFlagViolent: FormControl<NSFWFlagPolicyType>
nsfwFlagShocking: FormControl<NSFWFlagPolicyType>
nsfwFlagSex: FormControl<NSFWFlagPolicyType>
p2pEnabled: FormControl<boolean>
autoPlayVideo: FormControl<boolean>
autoPlayNextVideo: FormControl<boolean>
videoLanguages: FormControl<string[]>
}
@Component({
selector: 'my-user-video-settings',
templateUrl: './user-video-settings.component.html',
@ -22,11 +38,12 @@ import { HelpComponent } from '../shared-main/buttons/help.component'
HelpComponent,
SelectLanguagesComponent,
PeertubeCheckboxComponent,
NgIf
NgIf,
SelectRadioComponent
]
})
export class UserVideoSettingsComponent extends FormReactive implements OnInit, OnDestroy {
protected formReactiveService = inject(FormReactiveService)
export class UserVideoSettingsComponent implements OnInit, OnDestroy {
private formReactiveService = inject(FormReactiveService)
private authService = inject(AuthService)
private notifier = inject(Notifier)
private userService = inject(UserService)
@ -37,26 +54,72 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
readonly notifyOnUpdate = input(true)
readonly userInformationLoaded = input<Subject<any>>(undefined)
defaultNSFWPolicy: NSFWPolicyType
form: FormGroup<Form>
formErrors: FormReactiveErrors = {}
validationMessages: FormReactiveValidationMessages = {}
nsfwItems: SelectOptionsItem[] = [
{
id: 'do_not_list',
label: $localize`Hide`
},
{
id: 'warn',
label: $localize`Warn`
},
{
id: 'blur',
label: $localize`Blur`
},
{
id: 'display',
label: $localize`Display`
}
]
nsfwFlagItems: SelectOptionsItem[] = [
{
id: 'default',
label: $localize`Default`
},
{
id: 'do_not_list',
label: $localize`Hide`
},
{
id: 'warn',
label: $localize`Warn`
},
{
id: 'blur',
label: $localize`Blur`
},
{
id: 'display',
label: $localize`Display`
}
]
formValuesWatcher: Subscription
ngOnInit () {
this.buildForm({
nsfwPolicy: null,
p2pEnabled: null,
autoPlayVideo: null,
autoPlayNextVideo: null,
videoLanguages: null
})
this.buildForm()
this.updateNSFWDefaultLabel(this.user().nsfwPolicy)
this.form.controls.nsfwPolicy.valueChanges.subscribe(nsfwPolicy => this.updateNSFWDefaultLabel(nsfwPolicy))
this.userInformationLoaded().pipe(first())
.subscribe(
() => {
const serverConfig = this.serverService.getHTMLConfig()
this.defaultNSFWPolicy = serverConfig.instance.defaultNSFWPolicy
const defaultNSFWPolicy = serverConfig.instance.defaultNSFWPolicy
this.form.patchValue({
nsfwPolicy: this.user().nsfwPolicy || this.defaultNSFWPolicy,
nsfwPolicy: this.user().nsfwPolicy || defaultNSFWPolicy,
nsfwFlagViolent: this.buildNSFWFormFlag(NSFWFlag.VIOLENT),
nsfwFlagShocking: this.buildNSFWFormFlag(NSFWFlag.SHOCKING_DISTURBING),
nsfwFlagSex: this.buildNSFWFormFlag(NSFWFlag.EXPLICIT_SEX),
p2pEnabled: this.user().p2pEnabled,
autoPlayVideo: this.user().autoPlayVideo === true,
autoPlayNextVideo: this.user().autoPlayNextVideo,
@ -72,13 +135,32 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
this.formValuesWatcher?.unsubscribe()
}
updateDetails (onlyKeys?: string[]) {
const nsfwPolicy = this.form.value['nsfwPolicy']
const p2pEnabled = this.form.value['p2pEnabled']
const autoPlayVideo = this.form.value['autoPlayVideo']
const autoPlayNextVideo = this.form.value['autoPlayNextVideo']
private buildForm () {
const obj: BuildFormArgument = {
nsfwPolicy: null,
nsfwFlagViolent: null,
nsfwFlagShocking: null,
nsfwFlagSex: null,
const videoLanguages = this.form.value['videoLanguages']
p2pEnabled: null,
autoPlayVideo: null,
autoPlayNextVideo: null,
videoLanguages: null
}
const {
form,
formErrors,
validationMessages
} = this.formReactiveService.buildForm<Form>(obj)
this.form = form
this.formErrors = formErrors
this.validationMessages = validationMessages
}
updateDetails (onlyKeys?: string[]) {
const videoLanguages = this.form.value.videoLanguages
if (Array.isArray(videoLanguages)) {
if (videoLanguages.length > 20) {
@ -87,19 +169,33 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
}
}
const value = this.form.value
let details: UserUpdateMe = {
nsfwPolicy,
p2pEnabled,
autoPlayVideo,
autoPlayNextVideo,
nsfwPolicy: value.nsfwPolicy,
p2pEnabled: value.p2pEnabled,
autoPlayVideo: value.autoPlayVideo,
autoPlayNextVideo: value.autoPlayNextVideo,
nsfwFlagsDisplayed: this.buildNSFWUpdateFlag('display'),
nsfwFlagsHidden: this.buildNSFWUpdateFlag('do_not_list'),
nsfwFlagsWarned: this.buildNSFWUpdateFlag('warn'),
nsfwFlagsBlurred: this.buildNSFWUpdateFlag('blur'),
videoLanguages
}
if (videoLanguages) {
details = Object.assign(details, videoLanguages)
}
if (onlyKeys) {
const hasNSFWFlags = onlyKeys.includes('nsfwFlagViolent') ||
onlyKeys.includes('nsfwFlagShocking') ||
onlyKeys.includes('nsfwFlagSex')
if (onlyKeys) details = pick(details, onlyKeys)
const onlyKeysWithNSFW = hasNSFWFlags
? [ ...onlyKeys, 'nsfwFlagsDisplayed', 'nsfwFlagsHidden', 'nsfwFlagsWarned', 'nsfwFlagsBlurred' ]
: onlyKeys
details = pick(details, onlyKeysWithNSFW)
}
if (this.authService.isLoggedIn()) {
return this.updateLoggedProfile(details)
@ -113,7 +209,7 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
this.formValuesWatcher = this.form.valueChanges.subscribe((formValue: any) => {
const updatedKey = Object.keys(formValue)
.find(k => formValue[k] !== oldForm[k])
.find(k => formValue[k] !== ((oldForm as any)[k]))
oldForm = { ...this.form.value }
@ -141,4 +237,32 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
this.notifier.success($localize`Display/Video settings updated.`)
}
}
private buildNSFWFormFlag (flag: NSFWFlagType): NSFWPolicyType | 'default' {
const user = this.user()
if ((user.nsfwFlagsDisplayed & flag) === flag) return 'display'
if ((user.nsfwFlagsWarned & flag) === flag) return 'warn'
if ((user.nsfwFlagsBlurred & flag) === flag) return 'blur'
if ((user.nsfwFlagsHidden & flag) === flag) return 'do_not_list'
return 'default'
}
private buildNSFWUpdateFlag (type: NSFWPolicyType): number {
let result = NSFWFlag.NONE
if (this.form.value.nsfwFlagViolent === type) result |= NSFWFlag.VIOLENT
if (this.form.value.nsfwFlagShocking === type) result |= NSFWFlag.SHOCKING_DISTURBING
if (this.form.value.nsfwFlagSex === type) result |= NSFWFlag.EXPLICIT_SEX
return result
}
private updateNSFWDefaultLabel (nsfwPolicy: NSFWPolicyType) {
const defaultItem = this.nsfwFlagItems.find(item => item.id === 'default')
const nsfwPolicyLabel = this.nsfwItems.find(i => i.id === nsfwPolicy).label
defaultItem.label = $localize`Default (${nsfwPolicyLabel})`
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,16 +1,25 @@
<div class="video-miniature" [ngClass]="getClasses()" (mouseenter)="loadActions()" (focusin)="loadActions()">
<my-video-thumbnail
[ariaLabel]="getAriaLabel()"
[video]="video()" [nsfw]="isVideoBlur" [videoRouterLink]="videoRouterLink" [videoHref]="videoHref" [videoTarget]="videoTarget"
[ariaLabel]="getAriaLabel()" [blur]="hasNSFWBlur()"
[video]="video()" [videoRouterLink]="videoRouterLink" [videoHref]="videoHref" [videoTarget]="videoTarget"
[displayWatchLaterPlaylist]="isWatchLaterPlaylistDisplayed()" [inWatchLaterPlaylist]="inWatchLaterPlaylist" (watchLaterClick)="onWatchLaterClick($event)"
>
@if (displayOptions().privacyLabel) {
<ng-container ngProjectAs="label-warning" *ngIf="isUnlistedVideo()" i18n>Unlisted</ng-container>
<ng-container ngProjectAs="label-danger" *ngIf="isPrivateVideo()" i18n>Private</ng-container>
<ng-container ngProjectAs="label-danger" *ngIf="isPasswordProtectedVideo()" i18n>Password protected</ng-container>
}
<!-- Don't use @if that seems broken with content projection (Angular 19.1) -->
<ng-container ngProjectAs="label-warning" *ngIf="displayOptions().privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container>
<ng-container ngProjectAs="label-danger" *ngIf="displayOptions().privacyLabel && isPrivateVideo()" i18n>Private</ng-container>
<ng-container ngProjectAs="label-danger" *ngIf="displayOptions().privacyLabel && isPasswordProtectedVideo()" i18n>Password protected</ng-container>
</my-video-thumbnail>
<ng-template #nsfwWarningButton>
<button
type="button" class="nsfw-warning button-unstyle ms-auto ps-1"
*ngIf="hasNSFWWarning()" [attr.aria-label]="nsfwTooltip" [ngbTooltip]="nsfwTooltip"
>
<my-global-icon iconName="eye-close"></my-global-icon>
</button>
</ng-template>
<div class="video-info">
<div *ngIf="displayOptions().avatar || displayOptions().by" class="owner min-width-0">
@if (displayOptions().avatar) {
@ -43,6 +52,10 @@
</my-link>
<my-actor-host *ngIf="!video().isLocal" [host]="video().account.host"></my-actor-host>
@if (!displayAsRow()) {
<ng-container *ngTemplateOutlet="nsfwWarningButton"></ng-container>
}
</div>
</div>
@ -50,7 +63,7 @@
<my-link
[internalLink]="videoRouterLink" [href]="videoHref" [target]="videoTarget" inheritParentStyle="true"
[ariaLabel]="getAriaLabel()"
[title]="video().name" class="video-name" className="ellipsis-multiline-2" [ngClass]="{ 'blur-filter': isVideoBlur }"
[title]="video().name" class="video-name" className="ellipsis-multiline-2"
>
{{ video().name }}
</my-link>
@ -61,6 +74,10 @@
(videoRemoved)="onVideoRemoved()" (videoBlocked)="onVideoBlocked()" (videoUnblocked)="onVideoUnblocked()" (videoAccountMuted)="onVideoAccountMuted()"
></my-video-actions-dropdown>
</div>
@if (displayAsRow() || (!displayOptions().avatar && !displayOptions().by)) {
<ng-container *ngTemplateOutlet="nsfwWarningButton"></ng-container>
}
</div>
<div class="date-and-views">
@ -72,22 +89,5 @@
<my-video-views-counter *ngIf="displayOptions().views" [isLive]="video().isLive" [viewers]="video().viewers" [views]="video().views"></my-video-views-counter>
</span>
</div>
<div class="video-info-privacy fw-semibold">
<ng-container *ngIf="displayOptions().privacyText">{{ video().privacy.label }}</ng-container>
</div>
<div *ngIf="containedInPlaylists()" class="badges">
<a *ngFor="let playlist of containedInPlaylists()" class="pt-badge badge-secondary" [routerLink]="['/w/p/', playlist.playlistShortUUID]">
{{ playlist.playlistDisplayName }}
</a>
<span *ngIf="displayOptions().blacklistInfo && video().blacklisted" class="pt-badge badge-danger video-info-blocked">
<span class="fw-semibold" i18n>Blocked</span>
<span *ngIf="video().blacklistedReason"> - {{ video().blacklistedReason }}</span>
</span>
<span i18n *ngIf="displayOptions().nsfw && video().nsfw" class="pt-badge badge-danger video-info-nsfw">Sensitive</span>
</div>
</div>
</div>

View file

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

View file

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

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

View file

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

View file

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

View file

@ -7,7 +7,7 @@
<my-video-thumbnail
*ngIf="playlistElement().video"
[video]="playlistElement().video" [nsfw]="isVideoBlur(playlistElement().video)"
[video]="playlistElement().video" [blur]="hasNSFWBlur(playlistElement().video)"
[videoRouterLink]="buildRouterLink()" [queryParams]="buildRouterQuery()" [ariaLabel]="getVideoAriaLabel()"
></my-video-thumbnail>
@ -21,8 +21,18 @@
[routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()"
>{{ playlistElement().video.name }}</a>
<span i18n *ngIf="isVideoPrivate()" class="pt-badge badge-yellow">Private</span>
<span i18n *ngIf="isVideoPasswordProtected()" class="pt-badge badge-yellow">Password protected</span>
@if (isVideoPrivate()) {
<span i18n class="pt-badge badge-yellow">Private</span>
} @else if(isVideoPasswordProtected()) {
<span i18n class="pt-badge badge-yellow">Password protected</span>
}
<button
type="button" class="nsfw-warning button-unstyle ms-2"
*ngIf="hasNSFWWarning(playlistElement().video)" [attr.aria-label]="getNSFWTooltip(playlistElement().video)" [ngbTooltip]="getNSFWTooltip(playlistElement().video)"
>
<my-global-icon iconName="eye-close"></my-global-icon>
</button>
</div>
<span class="date-and-views">

View file

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

View file

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

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 './logger'
export * from './peertube-web-storage'
export * from './theme-manager'
export * from './plugins-manager'
export * from './string'
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 === 'false') return false
return defaultValue
}
export {
getBoolOrDefault
export function getNumberOrDefault (value: string, defaultValue: number) {
if (!value) return defaultValue
const result = parseInt(value, 10)
if (isNaN(result)) return defaultValue
return result
}

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',
NSFW_POLICY: 'nsfw_policy',
NSFW_FLAGS_DISPLAYED: 'nsfw_flags_displayed',
NSFW_FLAGS_HIDDEN: 'nsfw_flags_hidden',
NSFW_FLAGS_WARNED: 'nsfw_flags_warned',
NSFW_FLAGS_BLURRED: 'nsfw_flags_blurred',
P2P_ENABLED: 'peertube-videojs-webtorrent_enabled',
AUTO_PLAY_VIDEO: 'auto_play_video',

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
embedTitle: string
aspectRatio?: number
@ -37,30 +37,71 @@ function buildVideoOrPlaylistEmbed (options: {
return iframe.outerHTML
}
function isP2PEnabled (video: Video, config: HTMLServerConfig, userP2PEnabled: boolean) {
export function isP2PEnabled (video: Video, config: HTMLServerConfig, userP2PEnabled: boolean) {
if (video.isLocal && config.tracker.enabled === false) return false
if (isWebRTCDisabled()) return false
return userP2PEnabled
}
function videoRequiresUserAuth (video: Video, videoPassword?: string) {
export function videoRequiresUserAuth (video: Video, videoPassword?: string) {
return new Set<VideoPrivacyType>([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]).has(video.privacy.id) ||
(video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED && !videoPassword)
}
function videoRequiresFileToken (video: Video) {
export function videoRequiresFileToken (video: Video) {
return new Set<VideoPrivacyType>([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL, VideoPrivacy.PASSWORD_PROTECTED ]).has(video.privacy.id)
}
export {
buildVideoOrPlaylistEmbed,
isP2PEnabled,
videoRequiresUserAuth,
videoRequiresFileToken
export function isVideoNSFWWarnedForUser (video: Video, config: HTMLServerConfig, user: User) {
if (video.nsfw === false) return false
// Don't display NSFW warning for the owner of the video
if (user?.account?.id === video.account.id) return false
if (!user) {
return config.instance.defaultNSFWPolicy === 'warn' || config.instance.defaultNSFWPolicy === 'blur'
}
if ((user.nsfwFlagsWarned & video.nsfwFlags) !== 0) return true
if ((user.nsfwFlagsBlurred & video.nsfwFlags) !== 0) return true
if ((user.nsfwFlagsDisplayed & video.nsfwFlags) !== 0) return false
if ((user.nsfwFlagsHidden & video.nsfwFlags) !== 0) return false
return user.nsfwPolicy === 'warn' || user.nsfwPolicy === 'blur'
}
export function isVideoNSFWBlurForUser (video: Video, config: HTMLServerConfig, user: User) {
if (video.nsfw === false) return false
// Don't display NSFW warning for the owner of the video
if (user?.account?.id === video.account.id) return false
if (!user) return config.instance.defaultNSFWPolicy === 'blur'
if ((user.nsfwFlagsBlurred & video.nsfwFlags) !== 0) return true
if ((user.nsfwFlagsWarned & video.nsfwFlags) !== 0) return false
if ((user.nsfwFlagsDisplayed & video.nsfwFlags) !== 0) return false
if ((user.nsfwFlagsHidden & video.nsfwFlags) !== 0) return false
return user.nsfwPolicy === 'blur'
}
export function isVideoNSFWHiddenForUser (video: Video, config: HTMLServerConfig, user: User) {
if (video.nsfw === false) return false
// Video is not hidden for the owner of the video
if (user?.account?.id === video.account.id) return false
if (!user) return config.instance.defaultNSFWPolicy === 'do_not_list'
if ((user.nsfwFlagsHidden & video.nsfwFlags) !== 0) return true
if ((user.nsfwFlagsBlurred & video.nsfwFlags) !== 0) return false
if ((user.nsfwFlagsWarned & video.nsfwFlags) !== 0) return false
if ((user.nsfwFlagsDisplayed & video.nsfwFlags) !== 0) return false
return user.nsfwPolicy === 'do_not_list'
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
function isWebRTCDisabled () {

View file

@ -68,16 +68,24 @@ body {
font-family: $main-fonts;
}
.btn-outline-secondary {
--bs-btn-color: #{pvar(--fg-300)};
}
.btn {
--bs-btn-active-color: inherit;
--bs-btn-active-bg: inherit;
--bs-btn-active-border-color: inherit;
}
.btn-outline-primary {
--bs-btn-color: #{pvar(--fg)};
--bs-btn-border-color: #{pvar(--bg-secondary-450)};
--bs-btn-hover-color: #fff;
--bs-btn-hover-bg: #{pvar(--bg-secondary-450)};
--bs-btn-hover-border-color: #{pvar(--bg-secondary-450)};
--bs-btn-active-color: #{pvar(--on-primary)};
--bs-btn-active-bg: #{pvar(--primary)};
--bs-btn-active-border-color: #{pvar(--primary)};
}
.flex-auto {
flex: auto;
}
@ -86,6 +94,12 @@ body {
cursor: pointer !important;
}
.btn-group-vertical {
label {
margin-bottom: 0;
}
}
// ---------------------------------------------------------------------------
// Dropdown
// ---------------------------------------------------------------------------
@ -285,17 +299,6 @@ body {
font-size: $button-font-size;
}
.btn-outline-secondary {
border-color: pvar(--input-border-color);
&:focus-within,
&:focus,
&:hover {
color: #fff;
background-color: #6c757d;
}
}
.form-control {
color: pvar(--fg);
background-color: pvar(--input-bg);

View file

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

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