diff --git a/client/e2e/src/po/admin-config.po.ts b/client/e2e/src/po/admin-config.po.ts index 92b586d8e..d04e38ec8 100644 --- a/client/e2e/src/po/admin-config.po.ts +++ b/client/e2e/src/po/admin-config.po.ts @@ -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) { diff --git a/client/e2e/src/po/admin-registration.po.ts b/client/e2e/src/po/admin-registration.po.ts index 918587c5e..500d59929 100644 --- a/client/e2e/src/po/admin-registration.po.ts +++ b/client/e2e/src/po/admin-registration.po.ts @@ -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) } - } diff --git a/client/e2e/src/po/admin-user.po.ts b/client/e2e/src/po/admin-user.po.ts new file mode 100644 index 000000000..ac6c30d84 --- /dev/null +++ b/client/e2e/src/po/admin-user.po.ts @@ -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() + } +} diff --git a/client/e2e/src/po/anonymous-settings.po.ts b/client/e2e/src/po/anonymous-settings.po.ts index 17ee7a65d..b3438b3c0 100644 --- a/client/e2e/src/po/anonymous-settings.po.ts +++ b/client/e2e/src/po/anonymous-settings.po.ts @@ -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() + } } diff --git a/client/e2e/src/po/my-account.po.ts b/client/e2e/src/po/my-account.po.ts index 31f7ebcb3..3afa1de56 100644 --- a/client/e2e/src/po/my-account.po.ts +++ b/client/e2e/src/po/my-account.po.ts @@ -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') diff --git a/client/e2e/src/po/player.po.ts b/client/e2e/src/po/player.po.ts index d3744a43c..b051895de 100644 --- a/client/e2e/src/po/player.po.ts +++ b/client/e2e/src/po/player.po.ts @@ -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) { diff --git a/client/e2e/src/po/signup.po.ts b/client/e2e/src/po/signup.po.ts index 02cdbd2cd..3ee21db52 100644 --- a/client/e2e/src/po/signup.po.ts +++ b/client/e2e/src/po/signup.po.ts @@ -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' }) diff --git a/client/e2e/src/po/video-list.po.ts b/client/e2e/src/po/video-list.po.ts index e7cd542a2..b1969e60d 100644 --- a/client/e2e/src/po/video-list.po.ts +++ b/client/e2e/src/po/video-list.po.ts @@ -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() - } } diff --git a/client/e2e/src/po/video-manage.ts b/client/e2e/src/po/video-manage.ts index c1d30c9e1..ceeeddb93 100644 --- a/client/e2e/src/po/video-manage.ts +++ b/client/e2e/src/po/video-manage.ts @@ -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' } diff --git a/client/e2e/src/po/video-publish.po.ts b/client/e2e/src/po/video-publish.po.ts index 11864d459..8ea1f1c16 100644 --- a/client/e2e/src/po/video-publish.po.ts +++ b/client/e2e/src/po/video-publish.po.ts @@ -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) diff --git a/client/e2e/src/po/video-watch.po.ts b/client/e2e/src/po/video-watch.po.ts index f664bd40a..b9c38594f 100644 --- a/client/e2e/src/po/video-watch.po.ts +++ b/client/e2e/src/po/video-watch.po.ts @@ -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') diff --git a/client/e2e/src/suites-local/nsfw.e2e-spec.ts b/client/e2e/src/suites-local/nsfw.e2e-spec.ts new file mode 100644 index 000000000..64c8a6dc4 --- /dev/null +++ b/client/e2e/src/suites-local/nsfw.e2e-spec.ts @@ -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('') + 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() + }) + }) + }) +}) diff --git a/client/e2e/src/suites-local/player-settings.e2e-spec.ts b/client/e2e/src/suites-local/player-settings.e2e-spec.ts index 20457568a..7bf289324 100644 --- a/client/e2e/src/suites-local/player-settings.e2e-spec.ts +++ b/client/e2e/src/suites-local/player-settings.e2e-spec.ts @@ -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')) + }) }) diff --git a/client/e2e/src/suites-local/publish.e2e-spec.ts b/client/e2e/src/suites-local/publish.e2e-spec.ts index bf2aa18ca..386c03019 100644 --- a/client/e2e/src/suites-local/publish.e2e-spec.ts +++ b/client/e2e/src/suites-local/publish.e2e-spec.ts @@ -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() diff --git a/client/e2e/src/suites-local/signup.e2e-spec.ts b/client/e2e/src/suites-local/signup.e2e-spec.ts index 556ae2d0a..def224111 100644 --- a/client/e2e/src/suites-local/signup.e2e-spec.ts +++ b/client/e2e/src/suites-local/signup.e2e-spec.ts @@ -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')) + }) }) diff --git a/client/e2e/src/suites-local/videos-list.e2e-spec.ts b/client/e2e/src/suites-local/videos-list.e2e-spec.ts deleted file mode 100644 index b5e0cce94..000000000 --- a/client/e2e/src/suites-local/videos-list.e2e-spec.ts +++ /dev/null @@ -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('') - 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() - }) - }) -}) diff --git a/client/e2e/src/types/common.ts b/client/e2e/src/types/common.ts deleted file mode 100644 index c0b59d297..000000000 --- a/client/e2e/src/types/common.ts +++ /dev/null @@ -1 +0,0 @@ -export type NSFWPolicy = 'do_not_list' | 'blur' | 'display' diff --git a/client/e2e/src/utils/elements.ts b/client/e2e/src/utils/elements.ts index 842f40ded..98759273c 100644 --- a/client/e2e/src/utils/elements.ts +++ b/client/e2e/src/utils/elements.ts @@ -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 ) { if (await finder(el) === true) return el diff --git a/client/e2e/src/utils/files.ts b/client/e2e/src/utils/files.ts index 3f26258c8..8046aa1d7 100644 --- a/client/e2e/src/utils/files.ts +++ b/client/e2e/src/utils/files.ts @@ -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) { diff --git a/client/e2e/wdio.main.conf.ts b/client/e2e/wdio.main.conf.ts index 79dce21c4..a77291e8b 100644 --- a/client/e2e/wdio.main.conf.ts +++ b/client/e2e/wdio.main.conf.ts @@ -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') diff --git a/client/package.json b/client/package.json index 6cd3ed81b..d785dcaf0 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/app/+about/about-instance/children/about-instance-home.component.html b/client/src/app/+about/about-instance/children/about-instance-home.component.html index c71d358b8..b5f6e7489 100644 --- a/client/src/app/+about/about-instance/children/about-instance-home.component.html +++ b/client/src/app/+about/about-instance/children/about-instance-home.component.html @@ -11,7 +11,7 @@ {{ category }} -
{{ config.instance.name }} is dedicated to sensitive/NSFW content.
+
{{ config.instance.name }} is dedicated to sensitive content.
diff --git a/client/src/app/+accounts/account-video-channels/account-video-channels.component.ts b/client/src/app/+accounts/account-video-channels/account-video-channels.component.ts index 553c8139a..46e7b1d7f 100644 --- a/client/src/app/+accounts/account-video-channels/account-video-channels.component.ts +++ b/client/src/app/+accounts/account-video-channels/account-video-channels.component.ts @@ -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 }))) }) ) diff --git a/client/src/app/+accounts/account-videos/account-videos.component.ts b/client/src/app/+accounts/account-videos/account-videos.component.ts index f13646451..1503cdf8d 100644 --- a/client/src/app/+accounts/account-videos/account-videos.component.ts +++ b/client/src/app/+accounts/account-videos/account-videos.component.ts @@ -59,7 +59,7 @@ export class AccountVideosComponent implements OnInit, OnDestroy, DisableForReus skipCount: true } - return this.videoService.getAccountVideos(options) + return this.videoService.listAccountVideos(options) } getSyndicationItems () { diff --git a/client/src/app/+accounts/accounts.component.ts b/client/src/app/+accounts/accounts.component.ts index 21e6de348..f9f3d97a5 100644 --- a/client/src/app/+accounts/accounts.component.ts +++ b/client/src/app/+accounts/accounts.component.ts @@ -238,7 +238,7 @@ export class AccountsComponent implements OnInit, OnDestroy { } private loadAccountVideosCount () { - this.videoService.getAccountVideos({ + this.videoService.listAccountVideos({ account: this.account, videoPagination: { currentPage: 1, diff --git a/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html b/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html index daaeef7d0..efc36c750 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html +++ b/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html @@ -174,9 +174,9 @@
-
+
-

MODERATION & NSFW

+

MODERATION & SENSITIVE CONTENT

Manage users to build a moderation team.
@@ -186,34 +186,24 @@
- This instance is dedicated to sensitive or NSFW content + This instance is dedicated to sensitive content Enabling it will allow other administrators to know that you are mainly federating sensitive content.
- 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.
- - - - - With Hide or Blur thumbnails, a confirmation will be requested to watch the video. - - - -
- -
+
@@ -238,7 +228,7 @@
-
Who moderates the instance? What is the policy regarding NSFW videos? Political videos? etc
+
Who moderates the instance? What is the policy regarding sensitive content? Political videos? etc
- Auto block + @if (videoBlock.type === 2) { + Auto block + }
- NSFW + diff --git a/client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts b/client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts index 708775a7a..6a46e02a3 100644 --- a/client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts +++ b/client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts @@ -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 { diff --git a/client/src/app/+admin/overview/videos/video-list.component.html b/client/src/app/+admin/overview/videos/video-list.component.html index cfecc2d4a..6f4fd04b5 100644 --- a/client/src/app/+admin/overview/videos/video-list.component.html +++ b/client/src/app/+admin/overview/videos/video-list.component.html @@ -74,7 +74,7 @@ - NSFW + {{ video.state.label }} diff --git a/client/src/app/+admin/overview/videos/video-list.component.ts b/client/src/app/+admin/overview/videos/video-list.component.ts index 82bbc0486..bf64887f5 100644 --- a/client/src/app/+admin/overview/videos/video-list.component.ts +++ b/client/src/app/+admin/overview/videos/video-list.component.ts @@ -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
-
-
- - -
- -
- - -
- -
- - -
-
-
diff --git a/client/src/app/+search/search.component.ts b/client/src/app/+search/search.component.ts index 911651ffe..019ed49ff 100644 --- a/client/src/app/+search/search.component.ts +++ b/client/src/app/+search/search.component.ts @@ -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 diff --git a/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts b/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts index 464d078e7..a4134448d 100644 --- a/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts +++ b/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts @@ -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 () { diff --git a/client/src/app/+video-channels/video-channels.component.ts b/client/src/app/+video-channels/video-channels.component.ts index bbe58c579..50c68f1ac 100644 --- a/client/src/app/+video-channels/video-channels.component.ts +++ b/client/src/app/+video-channels/video-channels.component.ts @@ -176,7 +176,7 @@ export class VideoChannelsComponent implements OnInit, OnDestroy { } private loadChannelVideosCount () { - this.videoService.getVideoChannelVideos({ + this.videoService.listChannelVideos({ videoChannel: this.videoChannel, videoPagination: { currentPage: 1, diff --git a/client/src/app/+video-list/videos-list-all.component.ts b/client/src/app/+video-list/videos-list-all.component.ts index f58e29c02..948055c59 100644 --- a/client/src/app/+video-list/videos-list-all.component.ts +++ b/client/src/app/+video-list/videos-list-all.component.ts @@ -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', diff --git a/client/src/app/+video-watch/shared/action-buttons/video-rate.component.scss b/client/src/app/+video-watch/shared/action-buttons/video-rate.component.scss index 30add70c2..8e694616d 100644 --- a/client/src/app/+video-watch/shared/action-buttons/video-rate.component.scss +++ b/client/src/app/+video-watch/shared/action-buttons/video-rate.component.scss @@ -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)); } } diff --git a/client/src/app/+video-watch/shared/recommendations/video-recommendation.service.ts b/client/src/app/+video-watch/shared/recommendations/video-recommendation.service.ts index 75e529e79..c6fee2cc0 100644 --- a/client/src/app/+video-watch/shared/recommendations/video-recommendation.service.ts +++ b/client/src/app/+video-watch/shared/recommendations/video-recommendation.service.ts @@ -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 diff --git a/client/src/app/+video-watch/video-watch.component.html b/client/src/app/+video-watch/video-watch.component.html index 65b0f59b2..0fb189f3a 100644 --- a/client/src/app/+video-watch/video-watch.component.html +++ b/client/src/app/+video-watch/video-watch.component.html @@ -32,7 +32,7 @@
- +
@@ -110,7 +110,7 @@ class="border-top pt-3" [video]="video" [videoPassword]="videoPassword" - [user]="user" + [user]="authUser" (timestampClicked)="handleTimestampClicked($event)" >
diff --git a/client/src/app/+video-watch/video-watch.component.ts b/client/src/app/+video-watch/video-watch.component.ts index 60e23490b..35f28ec9d 100644 --- a/client/src/app/+video-watch/video-watch.component.ts +++ b/client/src/app/+video-watch/video-watch.component.ts @@ -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 }) } } diff --git a/client/src/app/+videos-publish-manage/shared-manage/common/video-edit.model.ts b/client/src/app/+videos-publish-manage/shared-manage/common/video-edit.model.ts index 032758825..b518184fa 100644 --- a/client/src/app/+videos-publish-manage/shared-manage/common/video-edit.model.ts +++ b/client/src/app/+videos-publish-manage/shared-manage/common/video-edit.model.ts @@ -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 + & 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 & { @@ -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, diff --git a/client/src/app/+videos-publish-manage/shared-manage/moderation/video-moderation.component.html b/client/src/app/+videos-publish-manage/shared-manage/moderation/video-moderation.component.html index 4b22afb64..864f69adc 100644 --- a/client/src/app/+videos-publish-manage/shared-manage/moderation/video-moderation.component.html +++ b/client/src/app/+videos-publish-manage/shared-manage/moderation/video-moderation.component.html @@ -9,27 +9,54 @@
- - -
- You can require comments to be approved depending on your auto-tags policies -
- - - - + +
+ You can require comments to be approved depending on your auto-tags policies +
+
- + - Contains sensitive content + Your video contains sensitive content - + Some instances hide videos containing mature or explicit content by default. - + + + + + + Some images may be violent + + + + + + Parts of the video may shock or disturb + + + + + + Content may be perceived as sexually explicit material + + + +
+ + + + +
+
+
diff --git a/client/src/app/+videos-publish-manage/shared-manage/moderation/video-moderation.component.ts b/client/src/app/+videos-publish-manage/shared-manage/moderation/video-moderation.component.ts index 6a5d5c447..db32d3df1 100644 --- a/client/src/app/+videos-publish-manage/shared-manage/moderation/video-moderation.component.ts +++ b/client/src/app/+videos-publish-manage/shared-manage/moderation/video-moderation.component.ts @@ -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 + + nsfwFlagViolent: FormControl + nsfwFlagShocking: FormControl + nsfwFlagSex: FormControl + nsfwSummary: FormControl + commentPolicies: FormControl } @@ -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() + } } } diff --git a/client/src/app/core/theme/theme.service.ts b/client/src/app/core/theme/theme.service.ts index 9f05627ed..964eac917 100644 --- a/client/src/app/core/theme/theme.service.ts +++ b/client/src/app/core/theme/theme.service.ts @@ -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 }[] - - 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) } diff --git a/client/src/app/core/users/user-local-storage.service.ts b/client/src/app/core/users/user-local-storage.service.ts index bfa4b4be0..51e5fd16f 100644 --- a/client/src/app/core/users/user-local-storage.service.ts +++ b/client/src/app/core/users/user-local-storage.service.ts @@ -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(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, diff --git a/client/src/app/core/users/user.model.ts b/client/src/app/core/users/user.model.ts index f98e05106..79b45349c 100644 --- a/client/src/app/core/users/user.model.ts +++ b/client/src/app/core/users/user.model.ts @@ -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) { diff --git a/client/src/app/menu/quick-settings-modal.component.ts b/client/src/app/menu/quick-settings-modal.component.ts index 3c7ecab4e..a97d05f72 100644 --- a/client/src/app/menu/quick-settings-modal.component.ts +++ b/client/src/app/menu/quick-settings-modal.component.ts @@ -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) } } diff --git a/client/src/app/shared/form-validators/video-validators.ts b/client/src/app/shared/form-validators/video-validators.ts index bf18ba630..86a46dbcc 100644 --- a/client/src/app/shared/form-validators/video-validators.ts +++ b/client/src/app/shared/form-validators/video-validators.ts @@ -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: { diff --git a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/channel-miniature-markup.component.ts b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/channel-miniature-markup.component.ts index d63cd96f0..4c870e9f0 100644 --- a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/channel-miniature-markup.component.ts +++ b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/channel-miniature-markup.component.ts @@ -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) }) }) ) } diff --git a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/playlist-miniature-markup.component.ts b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/playlist-miniature-markup.component.ts index c4c56e836..86e2bcd76 100644 --- a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/playlist-miniature-markup.component.ts +++ b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/playlist-miniature-markup.component.ts @@ -35,9 +35,7 @@ export class PlaylistMiniatureMarkupComponent implements CustomMarkupComponent, views: true, by: true, avatar: true, - privacyLabel: false, - privacyText: false, - blacklistInfo: false + privacyLabel: false } ngOnInit () { diff --git a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/video-miniature-markup.component.html b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/video-miniature-markup.component.html index c9569b727..ce068d305 100644 --- a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/video-miniature-markup.component.html +++ b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/video-miniature-markup.component.html @@ -1,6 +1,6 @@ diff --git a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/video-miniature-markup.component.ts b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/video-miniature-markup.component.ts index 67ef8a213..2f3f20ec2 100644 --- a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/video-miniature-markup.component.ts +++ b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/video-miniature-markup.component.ts @@ -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(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()) diff --git a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/videos-list-markup.component.html b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/videos-list-markup.component.html index 868bda387..f4d3d3f23 100644 --- a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/videos-list-markup.component.html +++ b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/videos-list-markup.component.html @@ -3,7 +3,7 @@
diff --git a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/videos-list-markup.component.ts b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/videos-list-markup.component.ts index f94e2af4c..6a7e60f04 100644 --- a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/videos-list-markup.component.ts +++ b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/videos-list-markup.component.ts @@ -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(undefined) @@ -42,19 +43,14 @@ export class VideosListMarkupComponent implements CustomMarkupComponent, OnInit readonly loaded = output() 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)) diff --git a/client/src/app/shared/shared-forms/peertube-checkbox.component.html b/client/src/app/shared/shared-forms/peertube-checkbox.component.html index 5284d066e..cf1f851c4 100644 --- a/client/src/app/shared/shared-forms/peertube-checkbox.component.html +++ b/client/src/app/shared/shared-forms/peertube-checkbox.component.html @@ -6,7 +6,7 @@ [(ngModel)]="checked" (ngModelChange)="onModelChange()" [id]="inputName()" - [disabled]="disabled()" + [disabled]="disabled" [attr.aria-describedby]="inputName() + '-description'" /> diff --git a/client/src/app/shared/shared-forms/peertube-checkbox.component.ts b/client/src/app/shared/shared-forms/peertube-checkbox.component.ts index 1e3b01ea6..131576356 100644 --- a/client/src/app/shared/shared-forms/peertube-checkbox.component.ts +++ b/client/src/app/shared/shared-forms/peertube-checkbox.component.ts @@ -23,9 +23,9 @@ export class PeertubeCheckboxComponent implements ControlValueAccessor, AfterCon readonly labelText = input(undefined) readonly labelInnerHTML = input(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 } } diff --git a/client/src/app/shared/shared-forms/select/select-radio.component.html b/client/src/app/shared/shared-forms/select/select-radio.component.html new file mode 100644 index 000000000..823790639 --- /dev/null +++ b/client/src/app/shared/shared-forms/select/select-radio.component.html @@ -0,0 +1,34 @@ +
+ + + + + @if (isGroup()) { +
+ @for (item of items(); track item.id) { + + + + } +
+ } @else { + @for (item of items(); track item.id) { +
+ + + + +
+ {{ item.description}} +
+
+ } + } +
diff --git a/client/src/app/shared/shared-forms/select/select-radio.component.scss b/client/src/app/shared/shared-forms/select/select-radio.component.scss new file mode 100644 index 000000000..97df6242c --- /dev/null +++ b/client/src/app/shared/shared-forms/select/select-radio.component.scss @@ -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; +} diff --git a/client/src/app/shared/shared-forms/select/select-radio.component.ts b/client/src/app/shared/shared-forms/select/select-radio.component.ts new file mode 100644 index 000000000..a17962974 --- /dev/null +++ b/client/src/app/shared/shared-forms/select/select-radio.component.ts @@ -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() + readonly inputId = input.required() + + readonly label = input() + 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() + } +} diff --git a/client/src/app/shared/shared-icons/global-icon.component.ts b/client/src/app/shared/shared-icons/global-icon.component.ts index 5b2aa330a..f30c3de20 100644 --- a/client/src/app/shared/shared-icons/global-icon.component.ts +++ b/client/src/app/shared/shared-icons/global-icon.component.ts @@ -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'), diff --git a/client/src/app/shared/shared-instance/instance-features-table.component.html b/client/src/app/shared/shared-instance/instance-features-table.component.html index d5a4aefcb..90cd662c8 100644 --- a/client/src/app/shared/shared-instance/instance-features-table.component.html +++ b/client/src/app/shared/shared-instance/instance-features-table.component.html @@ -10,7 +10,7 @@ -
Default NSFW/sensitive videos policy
+
Default sensitive content policy
can be redefined by the users diff --git a/client/src/app/shared/shared-instance/instance-features-table.component.ts b/client/src/app/shared/shared-instance/instance-features-table.component.ts index 98e9de00e..19062cf3b 100644 --- a/client/src/app/shared/shared-instance/instance-features-table.component.ts +++ b/client/src/app/shared/shared-instance/instance-features-table.component.ts @@ -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` } diff --git a/client/src/app/shared/shared-main/video/video.model.ts b/client/src/app/shared/shared-main/video/video.model.ts index c0e759807..519c9493b 100644 --- a/client/src/app/shared/shared-main/video/video.model.ts +++ b/client/src/app/shared/shared-main/video/video.model.ts @@ -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) { diff --git a/client/src/app/shared/shared-main/video/video.service.ts b/client/src/app/shared/shared-main/video/video.service.ts index 20214ae49..9edded864 100644 --- a/client/src/app/shared/shared-main/video/video.service.ts +++ b/client/src/app/shared/shared-main/video/video.service.ts @@ -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 & { 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 } ): Observable> { - const { account, ...parameters } = options - - let params = new HttpParams() - params = this.buildCommonVideosParams({ params, ...parameters }) - - return this.authHttp - .get>(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 } ): Observable> { - const { videoChannel } = parameters - - let params = new HttpParams() - params = this.buildCommonVideosParams({ params, ...parameters }) - - return this.authHttp - .get>(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> { + listVideos ( + options: CommonVideoParams & { + videoChannel?: Pick + account?: Pick + } + ): Observable> { let params = new HttpParams() - params = this.buildCommonVideosParams({ params, ...parameters }) + params = this.buildCommonVideosParams({ params, ...options }) return this.authHttp .get>(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 = {}) { + 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(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) { + 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[]) { // 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 = { diff --git a/client/src/app/shared/shared-search/advanced-search.model.ts b/client/src/app/shared/shared-search/advanced-search.model.ts index b977a4801..43fbb6341 100644 --- a/client/src/app/shared/shared-search/advanced-search.model.ts +++ b/client/src/app/shared/shared-search/advanced-search.model.ts @@ -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 || diff --git a/client/src/app/shared/shared-search/search.service.ts b/client/src/app/shared/shared-search/search.service.ts index 795899437..153b4bae8 100644 --- a/client/src/app/shared/shared-search/search.service.ts +++ b/client/src/app/shared/shared-search/search.service.ts @@ -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>(url, { params }) .pipe( diff --git a/client/src/app/shared/shared-tables/video-cell.component.html b/client/src/app/shared/shared-tables/video-cell.component.html index 16fc28810..4dbc97f41 100644 --- a/client/src/app/shared/shared-tables/video-cell.component.html +++ b/client/src/app/shared/shared-tables/video-cell.component.html @@ -1,12 +1,12 @@
-
+
{{ video().name }} diff --git a/client/src/app/shared/shared-thumbnail/video-thumbnail.component.html b/client/src/app/shared/shared-thumbnail/video-thumbnail.component.html index aad0a6243..afe79c8ed 100644 --- a/client/src/app/shared/shared-thumbnail/video-thumbnail.component.html +++ b/client/src/app/shared/shared-thumbnail/video-thumbnail.component.html @@ -9,7 +9,7 @@ } - +
-
-
+
+
@if (video().isLive) {
diff --git a/client/src/app/shared/shared-thumbnail/video-thumbnail.component.scss b/client/src/app/shared/shared-thumbnail/video-thumbnail.component.scss index 866b04771..a094913df 100644 --- a/client/src/app/shared/shared-thumbnail/video-thumbnail.component.scss +++ b/client/src/app/shared/shared-thumbnail/video-thumbnail.component.scss @@ -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, diff --git a/client/src/app/shared/shared-thumbnail/video-thumbnail.component.ts b/client/src/app/shared/shared-thumbnail/video-thumbnail.component.ts index 6f7ed994b..3db155d78 100644 --- a/client/src/app/shared/shared-thumbnail/video-thumbnail.component.ts +++ b/client/src/app/shared/shared-thumbnail/video-thumbnail.component.ts @@ -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() - readonly nsfw = input(false) readonly videoRouterLink = input(undefined) readonly queryParams = input<{ @@ -47,6 +46,7 @@ export class VideoThumbnailComponent implements OnChanges { readonly playOverlay = input(true, { transform: booleanAttribute }) readonly ariaLabel = input.required() + readonly blur = input.required({ transform: booleanAttribute }) readonly watchLaterTooltip = viewChild('watchLaterTooltip') readonly watchLaterClick = output() diff --git a/client/src/app/shared/shared-user-settings/user-video-settings.component.html b/client/src/app/shared/shared-user-settings/user-video-settings.component.html index c499015fc..e069463e4 100644 --- a/client/src/app/shared/shared-user-settings/user-video-settings.component.html +++ b/client/src/app/shared/shared-user-settings/user-video-settings.component.html @@ -2,23 +2,36 @@
-
- - - - - With Hide or Blur thumbnails, a confirmation will be requested to watch the video. - - +
+
-
- +
+ +
+ +
+ +
+ +
+
diff --git a/client/src/app/shared/shared-user-settings/user-video-settings.component.scss b/client/src/app/shared/shared-user-settings/user-video-settings.component.scss index edcb6b597..dc1b30423 100644 --- a/client/src/app/shared/shared-user-settings/user-video-settings.component.scss +++ b/client/src/app/shared/shared-user-settings/user-video-settings.component.scss @@ -9,5 +9,6 @@ my-select-languages { display: block; - @include responsive-width(340px); + width: 340px; + max-width: 100%; } diff --git a/client/src/app/shared/shared-user-settings/user-video-settings.component.ts b/client/src/app/shared/shared-user-settings/user-video-settings.component.ts index efbe34703..ba92acc78 100644 --- a/client/src/app/shared/shared-user-settings/user-video-settings.component.ts +++ b/client/src/app/shared/shared-user-settings/user-video-settings.component.ts @@ -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 + nsfwFlagViolent: FormControl + nsfwFlagShocking: FormControl + nsfwFlagSex: FormControl + + p2pEnabled: FormControl + autoPlayVideo: FormControl + autoPlayNextVideo: FormControl + videoLanguages: FormControl +} + @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>(undefined) - defaultNSFWPolicy: NSFWPolicyType + form: FormGroup
+ 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(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})` + } } diff --git a/client/src/app/shared/shared-video-miniature/video-filters-header.component.html b/client/src/app/shared/shared-video-miniature/video-filters-header.component.html index 2bbe8f6f6..547436f8a 100644 --- a/client/src/app/shared/shared-video-miniature/video-filters-header.component.html +++ b/client/src/app/shared/shared-video-miniature/video-filters-header.component.html @@ -78,14 +78,12 @@
-
- - -
+
+
{{ filters().getNSFWSettingsLabel() }}
-
- - +
+ Update your policy in your settings. +
@@ -144,7 +142,7 @@
diff --git a/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss b/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss index 7363c4039..7a99c7c2d 100644 --- a/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss +++ b/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss @@ -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; diff --git a/client/src/app/shared/shared-video-miniature/video-filters-header.component.ts b/client/src/app/shared/shared-video-miniature/video-filters-header.component.ts index d15d7fa99..667f1bab7 100644 --- a/client/src/app/shared/shared-video-miniature/video-filters-header.component.ts +++ b/client/src/app/shared/shared-video-miniature/video-filters-header.component.ts @@ -81,7 +81,6 @@ export class VideoFiltersHeaderComponent implements OnInit { this.form = this.fb.group({ sort: [ '' ], - nsfw: [ '' ], languageOneOf: [ '' ], categoryOneOf: [ '' ], scope: [ '' ], diff --git a/client/src/app/shared/shared-video-miniature/video-filters.model.ts b/client/src/app/shared/shared-video-miniature/video-filters.model.ts index 872d4ede0..c14503fcc 100644 --- a/client/src/app/shared/shared-video-miniature/video-filters.model.ts +++ b/client/src/app/shared/shared-video-miniature/video-filters.model.ts @@ -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([ [ '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) { + 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> { + 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 + } } diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.html b/client/src/app/shared/shared-video-miniature/video-miniature.component.html index b0bb0d7d5..65d95d439 100644 --- a/client/src/app/shared/shared-video-miniature/video-miniature.component.html +++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.html @@ -1,16 +1,25 @@
+ - @if (displayOptions().privacyLabel) { - Unlisted - Private - Password protected - } + + Unlisted + Private + Password protected + + + +
@if (displayOptions().avatar) { @@ -43,6 +52,10 @@ + + @if (!displayAsRow()) { + + }
@@ -50,7 +63,7 @@ {{ video().name }} @@ -61,6 +74,10 @@ (videoRemoved)="onVideoRemoved()" (videoBlocked)="onVideoBlocked()" (videoUnblocked)="onVideoUnblocked()" (videoAccountMuted)="onVideoAccountMuted()" >
+ + @if (displayAsRow() || (!displayOptions().avatar && !displayOptions().by)) { + + }
@@ -72,22 +89,5 @@
- -
- {{ video().privacy.label }} -
- -
- - {{ playlist.playlistDisplayName }} - - - - Blocked - - {{ video().blacklistedReason }} - - - Sensitive -
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.scss b/client/src/app/shared/shared-video-miniature/video-miniature.component.scss index cdb374763..7e90ff1e8 100644 --- a/client/src/app/shared/shared-video-miniature/video-miniature.component.scss +++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.scss @@ -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 { diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts index 5d37fd5ae..69aca6a7f 100644 --- a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts +++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts @@ -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(undefined) - readonly video = input
diff --git a/client/src/app/shared/shared-video-miniature/videos-selection.component.ts b/client/src/app/shared/shared-video-miniature/videos-selection.component.ts index 3e07b37b2..2593d161b 100644 --- a/client/src/app/shared/shared-video-miniature/videos-selection.component.ts +++ b/client/src/app/shared/shared-video-miniature/videos-selection.component.ts @@ -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(undefined) readonly user = input(undefined) readonly pagination = input(undefined) diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html index 956729a09..45781ff04 100644 --- a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html +++ b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html @@ -7,7 +7,7 @@ @@ -21,8 +21,18 @@ [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()" >{{ playlistElement().video.name }} - Private - Password protected + @if (isVideoPrivate()) { + Private + } @else if(isVideoPasswordProtected()) { + Password protected + } + +
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.scss b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.scss index cf0de2285..188ec7667 100644 --- a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.scss +++ b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.scss @@ -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; diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts index 6b293319b..8dee8a49e 100644 --- a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts +++ b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts @@ -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) { diff --git a/client/src/app/shared/shared-video/video-nsfw-badge.component.html b/client/src/app/shared/shared-video/video-nsfw-badge.component.html new file mode 100644 index 000000000..1e83db836 --- /dev/null +++ b/client/src/app/shared/shared-video/video-nsfw-badge.component.html @@ -0,0 +1,5 @@ +Sensitive diff --git a/client/src/app/shared/shared-video/video-nsfw-badge.component.ts b/client/src/app/shared/shared-video/video-nsfw-badge.component.ts new file mode 100644 index 000000000..b780cf9f6 --- /dev/null +++ b/client/src/app/shared/shared-video/video-nsfw-badge.component.ts @@ -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>() + 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' + } +} diff --git a/client/src/assets/images/feather/circle-alert.svg b/client/src/assets/images/feather/circle-alert.svg new file mode 100644 index 000000000..bce0713a5 --- /dev/null +++ b/client/src/assets/images/feather/circle-alert.svg @@ -0,0 +1 @@ + diff --git a/client/src/root-helpers/index.ts b/client/src/root-helpers/index.ts index 86301eafa..b4479f0ec 100644 --- a/client/src/root-helpers/index.ts +++ b/client/src/root-helpers/index.ts @@ -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' diff --git a/client/src/root-helpers/local-storage-utils.ts b/client/src/root-helpers/local-storage-utils.ts index c2b3f9035..4a4e1cf5c 100644 --- a/client/src/root-helpers/local-storage-utils.ts +++ b/client/src/root-helpers/local-storage-utils.ts @@ -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 } diff --git a/client/src/root-helpers/theme-manager.ts b/client/src/root-helpers/theme-manager.ts new file mode 100644 index 000000000..d40af8b80 --- /dev/null +++ b/client/src/root-helpers/theme-manager.ts @@ -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 }[] + + 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] + } +} diff --git a/client/src/root-helpers/users/user-local-storage-keys.ts b/client/src/root-helpers/users/user-local-storage-keys.ts index 90ccf0e78..6d82b6d09 100644 --- a/client/src/root-helpers/users/user-local-storage-keys.ts +++ b/client/src/root-helpers/users/user-local-storage-keys.ts @@ -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', diff --git a/client/src/root-helpers/video.ts b/client/src/root-helpers/video.ts index 4b1a5b51b..2999f0e40 100644 --- a/client/src/root-helpers/video.ts +++ b/client/src/root-helpers/video.ts @@ -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([ 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([ 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 () { diff --git a/client/src/sass/bootstrap.scss b/client/src/sass/bootstrap.scss index d9a2f33db..acd2126dc 100644 --- a/client/src/sass/bootstrap.scss +++ b/client/src/sass/bootstrap.scss @@ -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); diff --git a/client/src/sass/class-helpers/_badges.scss b/client/src/sass/class-helpers/_badges.scss index 24286188a..881a30e61 100644 --- a/client/src/sass/class-helpers/_badges.scss +++ b/client/src/sass/class-helpers/_badges.scss @@ -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); diff --git a/client/src/sass/include/_form-mixins.scss b/client/src/sass/include/_form-mixins.scss index 33e5cd073..82e35d0e0 100644 --- a/client/src/sass/include/_form-mixins.scss +++ b/client/src/sass/include/_form-mixins.scss @@ -56,13 +56,10 @@ @mixin peertube-select-container ($width) { padding: 0; margin: 0; - width: $width; position: relative; height: min-content; - @media screen and (max-width: $width) { - width: 100%; - } + @include responsive-width($width); &::after { top: 50%; @@ -116,6 +113,7 @@ label { font-size: $form-input-font-size; + color: pvar(--fg-350); } [type=radio]:focus-visible,[type=radio]:focus { @@ -141,7 +139,6 @@ line-height: 20px; display: inline-block; font-weight: $font-regular; - color: pvar(--fg); } [type=radio]:checked + label::before, @@ -200,6 +197,7 @@ box-shadow: $focus-box-shadow-form; } + // Checkbox + span { position: relative; width: 20px; @@ -224,6 +222,7 @@ } } + // Checkbox checked &:checked + span { background: pvar(--input-check-active-bg); animation: jelly 0.6s ease; @@ -246,10 +245,12 @@ } } + // Label + span + span { font-weight: $font-regular; cursor: pointer; display: inline; + color: pvar(--fg-350); @include margin-left(8px); } diff --git a/client/src/sass/include/_miniature.scss b/client/src/sass/include/_miniature.scss index f237ddcf4..8027422f6 100644 --- a/client/src/sass/include/_miniature.scss +++ b/client/src/sass/include/_miniature.scss @@ -13,12 +13,6 @@ &:hover { text-decoration: none; } - - &.blur-filter { - filter: blur(3px); - - @include padding-left(4px); - } } @mixin miniature-thumbnail { @@ -94,7 +88,6 @@ &.blur-filter { filter: blur(20px); - transform: scale(1.03); } } } diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss index 9c3301394..c9f0551b9 100644 --- a/client/src/sass/include/_mixins.scss +++ b/client/src/sass/include/_mixins.scss @@ -96,7 +96,7 @@ } } -@mixin fill-svg-color ($color) { +@mixin fill-path-svg-color ($color) { ::ng-deep svg { path { fill: $color; @@ -104,6 +104,12 @@ } } +@mixin fill-svg-color ($color) { + ::ng-deep svg { + fill: $color; + } +} + @mixin rounded-line-height-1-5 ($font-size) { line-height: calc(#{$font-size} + #{math.round(math.div($font-size, 2))}); } @@ -128,7 +134,7 @@ @mixin responsive-width ($width) { width: $width; - @media screen and (max-width: $width) { + @media screen and (max-width: #{$width - 30px}) { width: 100%; } } diff --git a/client/src/standalone/player/src/peertube-player.ts b/client/src/standalone/player/src/peertube-player.ts index 182eb47cc..98eb51e23 100644 --- a/client/src/standalone/player/src/peertube-player.ts +++ b/client/src/standalone/player/src/peertube-player.ts @@ -1,20 +1,37 @@ -import './shared/context-menu' -import './shared/upnext/end-card' -import './shared/upnext/upnext-plugin' -import './shared/stats/stats-card' -import './shared/stats/stats-plugin' +import { buildVideoLink, decorateVideoLink, isDefaultLocale, pick } from '@peertube/peertube-core-utils' +import { logger } from '@root-helpers/logger' +import { PluginsManager } from '@root-helpers/plugins-manager' +import { TranslationsManager } from '@root-helpers/translations-manager' +import { copyToClipboard } from '@root-helpers/utils' +import { buildVideoOrPlaylistEmbed } from '@root-helpers/video' +import { isMobile } from '@root-helpers/web-browser' +import videojs, { VideoJsPlayer } from 'video.js' +import { saveAverageBandwidth } from './peertube-player-local-storage' import './shared/bezels/bezels-plugin' -import './shared/peertube/peertube-plugin' -import './shared/resolutions/peertube-resolutions-plugin' +import './shared/context-menu' import './shared/control-bar/caption-toggle-button' -import './shared/control-bar/storyboard-plugin' import './shared/control-bar/chapters-plugin' -import './shared/control-bar/time-tooltip' import './shared/control-bar/next-previous-video-button' import './shared/control-bar/p2p-info-button' import './shared/control-bar/peertube-link-button' -import './shared/control-bar/theater-button' import './shared/control-bar/peertube-live-display' +import './shared/control-bar/storyboard-plugin' +import './shared/control-bar/theater-button' +import './shared/control-bar/time-tooltip' +import './shared/dock/peertube-dock-component' +import './shared/dock/peertube-dock-plugin' +import './shared/nsfw/peertube-nsfw-component' +import './shared/nsfw/peertube-nsfw-plugin' +import './shared/hotkeys/peertube-hotkeys-plugin' +import './shared/metrics/metrics-plugin' +import './shared/mobile/peertube-mobile-buttons' +import './shared/mobile/peertube-mobile-plugin' +import './shared/p2p-media-loader/hls-plugin' +import './shared/p2p-media-loader/p2p-media-loader-plugin' +import './shared/peertube/peertube-plugin' +import { ControlBarOptionsBuilder, HLSOptionsBuilder, WebVideoOptionsBuilder } from './shared/player-options-builder' +import './shared/playlist/playlist-plugin' +import './shared/resolutions/peertube-resolutions-plugin' import './shared/settings/menu-focus-fixed' import './shared/settings/resolution-menu-button' import './shared/settings/resolution-menu-item' @@ -23,37 +40,23 @@ import './shared/settings/settings-menu-button' import './shared/settings/settings-menu-item' import './shared/settings/settings-panel' import './shared/settings/settings-panel-child' -import './shared/playlist/playlist-plugin' -import './shared/mobile/peertube-mobile-plugin' -import './shared/mobile/peertube-mobile-buttons' -import './shared/hotkeys/peertube-hotkeys-plugin' -import './shared/metrics/metrics-plugin' -import './shared/p2p-media-loader/hls-plugin' -import './shared/p2p-media-loader/p2p-media-loader-plugin' +import './shared/stats/stats-card' +import './shared/stats/stats-plugin' +import './shared/upnext/end-card' +import './shared/upnext/upnext-plugin' import './shared/web-video/web-video-plugin' -import './shared/dock/peertube-dock-component' -import './shared/dock/peertube-dock-plugin' -import videojs, { VideoJsPlayer } from 'video.js' -import { logger } from '@root-helpers/logger' -import { PluginsManager } from '@root-helpers/plugins-manager' -import { copyToClipboard } from '@root-helpers/utils' -import { buildVideoOrPlaylistEmbed } from '@root-helpers/video' -import { isMobile } from '@root-helpers/web-browser' -import { buildVideoLink, decorateVideoLink, isDefaultLocale, pick } from '@peertube/peertube-core-utils' -import { saveAverageBandwidth } from './peertube-player-local-storage' -import { ControlBarOptionsBuilder, HLSOptionsBuilder, WebVideoOptionsBuilder } from './shared/player-options-builder' -import { TranslationsManager } from '@root-helpers/translations-manager' import { PeerTubePlayerConstructorOptions, PeerTubePlayerLoadOptions, PlayerNetworkInfo, VideoJSPluginOptions } from './types' -// Change 'Playback Rate' to 'Speed' (smaller for our settings menu) -(videojs.getComponent('PlaybackRateMenuButton') as any).prototype.controlText_ = 'Speed' - const CaptionsButton = videojs.getComponent('CaptionsButton') as any // Change Captions to Subtitles/CC CaptionsButton.prototype.controlText_ = 'Subtitles/CC' // We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know) CaptionsButton.prototype.label_ = ' ' +// Change 'Playback Rate' to 'Speed' (smaller for our settings menu) +const PlaybackRateMenuButton = videojs.getComponent('PlaybackRateMenuButton') as any +PlaybackRateMenuButton.prototype.controlText_ = 'Speed' + // TODO: remove when https://github.com/videojs/video.js/pull/7598 is merged const PlayProgressBar = videojs.getComponent('PlayProgressBar') as any if (PlayProgressBar.prototype.options_.children.includes('timeTooltip') !== true) { @@ -100,8 +103,11 @@ export class PeerTubePlayer { this.loadDynamicPlugins() - if (this.options.controlBar === false) this.player.controlBar.hide() - else this.player.controlBar.show() + if (this.options.controlBar === false) { + this.player.controlBar.hide() + } else { + this.player.controlBar.show() + } this.player.autoplay(this.getAutoPlayValue(this.currentLoadOptions.autoplay)) @@ -134,8 +140,7 @@ export class PeerTubePlayer { enable () { if (!this.player) return - - (this.player.el() as HTMLElement).style.pointerEvents = 'auto' + ;(this.player.el() as HTMLElement).style.pointerEvents = 'auto' } disable () { @@ -148,9 +153,8 @@ export class PeerTubePlayer { // Disable player this.player.hasStarted(false) this.player.removeClass('vjs-has-autoplay') - this.player.bigPlayButton.hide(); - - (this.player.el() as HTMLElement).style.pointerEvents = 'none' + this.player.bigPlayButton.hide() + ;(this.player.el() as HTMLElement).style.pointerEvents = 'none' } setCurrentTime (currentTime: number) { @@ -252,6 +256,7 @@ export class PeerTubePlayer { if (this.player.usingPlugin('stats')) this.player.stats().dispose() if (this.player.usingPlugin('storyboard')) this.player.storyboard().dispose() if (this.player.usingPlugin('chapters')) this.player.chapters().dispose() + if (this.player.usingPlugin('peertubeNSFW')) this.player.peertubeNSFW().dispose() if (this.player.usingPlugin('peertubeDock')) this.player.peertubeDock().dispose() @@ -305,11 +310,14 @@ export class PeerTubePlayer { if (this.currentLoadOptions.dock) { this.player.peertubeDock(this.currentLoadOptions.dock) } + + if (this.currentLoadOptions.nsfwWarning) { + this.player.peertubeNSFW(this.currentLoadOptions.nsfwWarning) + } } private async tryToRecoverHLSError (err: any) { if (err.code === MediaError.MEDIA_ERR_DECODE) { - // Display a notification to user if (this.videojsDecodeErrors === 0) { this.options.errorNotifier(this.player.localize('The video failed to play, will try to fast forward.')) @@ -425,6 +433,7 @@ export class PeerTubePlayer { autoplay: this.getAutoPlayValue(this.currentLoadOptions.autoplay), poster: this.currentLoadOptions.poster, + inactivityTimeout: this.options.inactivityTimeout, playbackRates: [ 0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2 ], @@ -485,7 +494,6 @@ export class PeerTubePlayer { } private getContextMenuOptions () { - const content = () => { const self = this const player = this.player diff --git a/client/src/standalone/player/src/sass/shared/index.scss b/client/src/standalone/player/src/sass/shared/index.scss index 4bfd67a26..27a3ee6d4 100644 --- a/client/src/standalone/player/src/sass/shared/index.scss +++ b/client/src/standalone/player/src/sass/shared/index.scss @@ -2,6 +2,7 @@ @use './dock'; @use './control-bar'; @use './mobile'; +@use './nsfw'; @use './context-menu'; @use './settings-menu'; @use './spinner'; diff --git a/client/src/standalone/player/src/sass/shared/nsfw.scss b/client/src/standalone/player/src/sass/shared/nsfw.scss new file mode 100644 index 000000000..7d30aa861 --- /dev/null +++ b/client/src/standalone/player/src/sass/shared/nsfw.scss @@ -0,0 +1,74 @@ +@use 'sass:math'; +@use '_variables' as *; +@use '_mixins' as *; +@use '_icons' as *; +@use './_player-variables' as *; + +.video-js.vjs-peertube-skin { + --container-margin-x: 20px; + --container-margin-y: 20px; + + &.vjs-size-570 { + --container-margin-x: 10px; + --container-margin-y: 10px; + } + + .nsfw-container { + font-size: 14px; + position: absolute; + top: var(--container-margin-y); + right: var(--container-margin-x); + width: 100%; + width: fit-content; + background-color: pvar(--bg-secondary-500); + color: pvar(--fg-400); + max-width: calc(40% - 2 * var(--container-margin-x)); + max-height: calc(100% - 2 * var(--container-margin-y)); + padding: 1rem;; + border-radius: 4px; + overflow: auto; + + .nsfw-title { + font-size: 1.25rem; + font-weight: $font-bold; + margin-bottom: 0.5rem; + } + + button, + .nsfw-more-flags, + .nsfw-more-summary { + margin-top: 0.75rem; + font-size: 13px; + } + + button { + padding: 0; + color: pvar(--fg-450); + text-decoration: underline; + + &:hover { + opacity: 0.8; + } + + &::after { + @include chevron-down(8px, 2px); + @include margin-left(5px); + } + } + + .nsfw-more-content { + strong { + display: block; + margin-bottom: 5px; + } + } + } + + &.peertube-dock { + .nsfw-container { + top: unset; + bottom: var(--container-margin-y); + max-width: 90%; + } + } +} diff --git a/client/src/standalone/player/src/shared/nsfw/index.ts b/client/src/standalone/player/src/shared/nsfw/index.ts new file mode 100644 index 000000000..16e70a9c1 --- /dev/null +++ b/client/src/standalone/player/src/shared/nsfw/index.ts @@ -0,0 +1,2 @@ +export * from './peertube-dock-component' +export * from './peertube-dock-plugin' diff --git a/client/src/standalone/player/src/shared/nsfw/peertube-nsfw-component.ts b/client/src/standalone/player/src/shared/nsfw/peertube-nsfw-component.ts new file mode 100644 index 000000000..d821f1834 --- /dev/null +++ b/client/src/standalone/player/src/shared/nsfw/peertube-nsfw-component.ts @@ -0,0 +1,92 @@ +import { NSFWFlag } from '@peertube/peertube-models' +import videojs from 'video.js' +import { type PeerTubeNSFWPluginOptions } from './peertube-nsfw-plugin' + +const Component = videojs.getComponent('Component') + +class PeerTubeNSFWComponent extends Component { + declare options_: videojs.ComponentOptions & PeerTubeNSFWPluginOptions + + // eslint-disable-next-line @typescript-eslint/no-useless-constructor + constructor (player: videojs.Player, options: videojs.ComponentOptions & PeerTubeNSFWPluginOptions) { + super(player, options) + } + + createEl () { + const el = super.createEl('div', { className: 'nsfw-container' }) + + const title = super.createEl('div', { className: 'nsfw-title' }) + title.textContent = this.player().localize('Sensitive content') + + const content = super.createEl('div', { className: 'nsfw-content' }) + content.textContent = this.player().localize('This video contains sensitive content.') + + el.appendChild(title) + el.appendChild(content) + + if (this.options_.flags || this.options_.summary) { + const moreButton = super.createEl( + 'button', + { textContent: this.player().localize('Learn more') }, + { type: 'button' } + ) as HTMLButtonElement + + el.appendChild(moreButton) + + moreButton.addEventListener('click', () => { + this.appendMoreContent() + + moreButton.style.display = 'none' + }) + } + + return el + } + + private appendMoreContent () { + const moreContentEl = super.createEl('div', { className: 'nsfw-more-content' }) + + if (this.options_.flags) { + const moreContentFlags = super.createEl('div', { className: 'nsfw-more-flags' }) + moreContentFlags.appendChild(super.createEl('strong', { textContent: this.player().localize('Content warning') })) + moreContentFlags.appendChild(super.createEl('div', { textContent: this.buildFlagStrings().join(' - ') })) + + moreContentEl.appendChild(moreContentFlags) + } + + if (this.options_.summary) { + const moreContentSummary = super.createEl('div', { className: 'nsfw-more-summary' }) + moreContentSummary.appendChild(super.createEl('strong', { textContent: `Author note` })) + moreContentSummary.appendChild(super.createEl('div', { textContent: this.options_.summary })) + + moreContentEl.appendChild(moreContentSummary) + } + + this.el().appendChild(moreContentEl) + } + + private buildFlagStrings () { + const flags = this.options_.flags + const flagStrings: string[] = [] + + if ((flags & NSFWFlag.VIOLENT) === NSFWFlag.VIOLENT) { + flagStrings.push(this.player().localize(`Violence`)) + } + + if ((flags & NSFWFlag.SHOCKING_DISTURBING) === NSFWFlag.SHOCKING_DISTURBING) { + flagStrings.push(this.player().localize(`Shocking Content`)) + } + + if ((flags & NSFWFlag.EXPLICIT_SEX) === NSFWFlag.EXPLICIT_SEX) { + flagStrings.push(this.player().localize(`Explicit Sex`)) + } + + return flagStrings + } +} + +videojs.registerComponent('PeerTubeNSFWComponent', PeerTubeNSFWComponent) + +export { + PeerTubeNSFWComponent +} diff --git a/client/src/standalone/player/src/shared/nsfw/peertube-nsfw-plugin.ts b/client/src/standalone/player/src/shared/nsfw/peertube-nsfw-plugin.ts new file mode 100644 index 000000000..c5d87f145 --- /dev/null +++ b/client/src/standalone/player/src/shared/nsfw/peertube-nsfw-plugin.ts @@ -0,0 +1,40 @@ +import videojs from 'video.js' +import { PeerTubeNSFWComponent } from './peertube-nsfw-component' + +const Plugin = videojs.getPlugin('plugin') + +export type PeerTubeNSFWPluginOptions = { + summary: string + flags: number +} + +class PeerTubeNSFWPlugin extends Plugin { + declare private nsfwComponent: PeerTubeNSFWComponent + + constructor (player: videojs.Player, options: videojs.PlayerOptions & PeerTubeNSFWPluginOptions) { + super(player, options) + + player.ready(() => { + player.addClass('peertube-nsfw') + }) + + this.nsfwComponent = new PeerTubeNSFWComponent(player, options) + player.addChild(this.nsfwComponent) + + player.one('play', () => { + this.nsfwComponent.hide() + }) + } + + dispose () { + this.nsfwComponent?.dispose() + this.player.removeChild(this.nsfwComponent) + this.player.removeClass('peertube-nsfw') + + super.dispose() + } +} + +videojs.registerPlugin('peertubeNSFW', PeerTubeNSFWPlugin) + +export { PeerTubeNSFWPlugin } diff --git a/client/src/standalone/player/src/shared/playlist/playlist-plugin.ts b/client/src/standalone/player/src/shared/playlist/playlist-plugin.ts index 54e3702a5..4cb208032 100644 --- a/client/src/standalone/player/src/shared/playlist/playlist-plugin.ts +++ b/client/src/standalone/player/src/shared/playlist/playlist-plugin.ts @@ -12,6 +12,8 @@ class PlaylistPlugin extends Plugin { constructor (player: videojs.Player, options?: PlaylistPluginOptions) { super(player, options) + this.player.addClass('vjs-playlist') + this.playlistMenu = new PlaylistMenu(player, options) this.playlistButton = new PlaylistButton(player, { ...options, playlistMenu: this.playlistMenu }) diff --git a/client/src/standalone/player/src/types/peertube-player-options.ts b/client/src/standalone/player/src/types/peertube-player-options.ts index 83d42334b..f26dc05ab 100644 --- a/client/src/standalone/player/src/types/peertube-player-options.ts +++ b/client/src/standalone/player/src/types/peertube-player-options.ts @@ -86,6 +86,11 @@ export type PeerTubePlayerLoadOptions = { requiresPassword: boolean videoPassword: () => string + nsfwWarning?: { + flags: number + summary: string + } + nextVideo: { enabled: boolean getVideoTitle: () => string diff --git a/client/src/standalone/player/src/types/peertube-videojs-typings.ts b/client/src/standalone/player/src/types/peertube-videojs-typings.ts index b78038e27..b0607a14c 100644 --- a/client/src/standalone/player/src/types/peertube-videojs-typings.ts +++ b/client/src/standalone/player/src/types/peertube-videojs-typings.ts @@ -10,6 +10,7 @@ import { StoryboardPlugin } from '../shared/control-bar/storyboard-plugin' import { PeerTubeDockPlugin, PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' import { HotkeysOptions, PeerTubeHotkeysPlugin } from '../shared/hotkeys/peertube-hotkeys-plugin' import { PeerTubeMobilePlugin } from '../shared/mobile/peertube-mobile-plugin' +import { PeerTubeNSFWPlugin, PeerTubeNSFWPluginOptions } from '../shared/nsfw/peertube-nsfw-plugin' import { Html5Hlsjs } from '../shared/p2p-media-loader/hls-plugin' import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin' import { RedundancyUrlManager } from '../shared/p2p-media-loader/redundancy-url-manager' @@ -24,7 +25,6 @@ import { WebVideoPlugin } from '../shared/web-video/web-video-plugin' import { PlayerMode } from './peertube-player-options' declare module 'video.js' { - export interface VideoJsPlayer { srOptions_: HlsjsConfigHandlerOptions @@ -32,44 +32,45 @@ declare module 'video.js' { // FIXME: add it to upstream typings posterImage: { - show (): void - hide (): void + show(): void + hide(): void } - handleTechSeeked_ (): void + handleTechSeeked_(): void - textTracks (): TextTrackList & { + textTracks(): TextTrackList & { tracks_: (TextTrack & { id: string, label: string, src: string })[] } // Plugins - peertube (): PeerTubePlugin + peertube(): PeerTubePlugin - webVideo (options?: any): WebVideoPlugin + webVideo(options?: any): WebVideoPlugin - p2pMediaLoader (options?: any): P2pMediaLoaderPlugin - hlsjs (options?: any): any + p2pMediaLoader(options?: any): P2pMediaLoaderPlugin + hlsjs(options?: any): any - peertubeResolutions (): PeerTubeResolutionsPlugin + peertubeResolutions(): PeerTubeResolutionsPlugin - contextMenu (options?: ContextMenuPluginOptions): ContextMenuPlugin + contextMenu(options?: ContextMenuPluginOptions): ContextMenuPlugin - bezels (): BezelsPlugin - peertubeMobile (): PeerTubeMobilePlugin - peerTubeHotkeysPlugin (options?: HotkeysOptions): PeerTubeHotkeysPlugin + bezels(): BezelsPlugin + peertubeMobile(): PeerTubeMobilePlugin + peerTubeHotkeysPlugin(options?: HotkeysOptions): PeerTubeHotkeysPlugin - stats (options?: StatsCardOptions): StatsForNerdsPlugin + stats(options?: StatsCardOptions): StatsForNerdsPlugin - storyboard (options?: StoryboardOptions): StoryboardPlugin + storyboard(options?: StoryboardOptions): StoryboardPlugin - peertubeDock (options?: PeerTubeDockPluginOptions): PeerTubeDockPlugin + peertubeDock(options?: PeerTubeDockPluginOptions): PeerTubeDockPlugin + peertubeNSFW(options?: PeerTubeNSFWPluginOptions): PeerTubeNSFWPlugin - chapters (options?: ChaptersOptions): ChaptersPlugin + chapters(options?: ChaptersOptions): ChaptersPlugin - upnext (options?: UpNextPluginOptions): UpNextPlugin + upnext(options?: UpNextPluginOptions): UpNextPlugin - playlist (options?: PlaylistPluginOptions): PlaylistPlugin + playlist(options?: PlaylistPluginOptions): PlaylistPlugin } } @@ -214,7 +215,7 @@ export type WebVideoPluginOptions = { } export type HLSLoaderClass = { - new (confg: HlsConfig): Loader + new(confg: HlsConfig): Loader getEngine(): HlsJsP2PEngine } diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts index e3f450758..714b88a32 100644 --- a/client/src/standalone/videos/embed.ts +++ b/client/src/standalone/videos/embed.ts @@ -18,6 +18,7 @@ import { AuthHTTP, LiveManager, PeerTubePlugin, + PeerTubeTheme, PlayerOptionsBuilder, PlaylistFetcher, PlaylistTracker, @@ -41,6 +42,7 @@ export class PeerTubeEmbed { private readonly videoFetcher: VideoFetcher private readonly playlistFetcher: PlaylistFetcher private readonly peertubePlugin: PeerTubePlugin + private readonly peertubeTheme: PeerTubeTheme private readonly playerHTML: PlayerHTML private readonly playerOptionsBuilder: PlayerOptionsBuilder private readonly liveManager: LiveManager @@ -65,6 +67,7 @@ export class PeerTubeEmbed { this.videoFetcher = new VideoFetcher(this.http) this.playlistFetcher = new PlaylistFetcher(this.http) this.peertubePlugin = new PeerTubePlugin(this.http) + this.peertubeTheme = new PeerTubeTheme(this.peertubePlugin) this.playerHTML = new PlayerHTML(videoWrapperId) this.playerOptionsBuilder = new PlayerOptionsBuilder(this.playerHTML, this.videoFetcher, this.peertubePlugin) this.liveManager = new LiveManager(this.playerHTML) @@ -101,6 +104,8 @@ export class PeerTubeEmbed { .then(res => res.json()) } + this.peertubeTheme.loadTheme(this.config) + const videoId = this.isPlaylistEmbed() ? await this.initPlaylist() : this.getResourceId() @@ -278,6 +283,8 @@ export class PeerTubeEmbed { video, captionsResponse, chaptersResponse, + + config: this.config, translations, storyboardsResponse, @@ -379,6 +386,7 @@ export class PeerTubeEmbed { this.peertubePlayer.unload() this.peertubePlayer.disable() + this.peertubePlayer.setPoster(video.previewPath) } diff --git a/client/src/standalone/videos/shared/index.ts b/client/src/standalone/videos/shared/index.ts index a09b8d450..eb36b400f 100644 --- a/client/src/standalone/videos/shared/index.ts +++ b/client/src/standalone/videos/shared/index.ts @@ -1,5 +1,6 @@ export * from './auth-http' export * from './peertube-plugin' +export * from './peertube-theme' export * from './live-manager' export * from './player-html' export * from './player-options-builder' diff --git a/client/src/standalone/videos/shared/peertube-theme.ts b/client/src/standalone/videos/shared/peertube-theme.ts new file mode 100644 index 000000000..61b9db9d1 --- /dev/null +++ b/client/src/standalone/videos/shared/peertube-theme.ts @@ -0,0 +1,49 @@ +import { HTMLServerConfig, ServerConfig } from '@peertube/peertube-models' +import { logger, ThemeManager } from '../../../root-helpers' +import { PeerTubePlugin } from './peertube-plugin' +import { getBackendUrl } from './url' + +export class PeerTubeTheme { + private themeManager = new ThemeManager() + + constructor (private readonly pluginPlugin: PeerTubePlugin) { + } + + loadTheme (config: HTMLServerConfig) { + for (const theme of config.theme.registered) { + this.themeManager.injectTheme(theme, getBackendUrl()) + } + + const themeName = this.getCurrentThemeName(config) + logger.info(`Enabling ${themeName} theme.`) + + this.themeManager.loadThemeStyle(themeName) + + const theme = config.theme.registered.find(t => t.name === themeName) + const isInternalTheme = config.theme.builtIn.map(t => t.name as string).includes(themeName) + + if (isInternalTheme) { + logger.info(`Enabling internal theme ${themeName}`) + } else if (theme) { + logger.info(`Adding scripts of theme ${themeName}`) + + const pluginManager = this.pluginPlugin.getPluginsManager() + pluginManager.addPlugin(theme, true) + pluginManager.reloadLoadedScopes() + } + + this.themeManager.injectCoreColorPalette() + } + + private getCurrentThemeName (config: HTMLServerConfig) { + const instanceTheme = config.theme.default + if (instanceTheme !== 'default') return instanceTheme + + // Default to dark theme if available and wanted by the user + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + return 'peertube-core-dark-brown' satisfies ServerConfig['theme']['builtIn'][0]['name'] + } + + return instanceTheme + } +} diff --git a/client/src/standalone/videos/shared/player-options-builder.ts b/client/src/standalone/videos/shared/player-options-builder.ts index 3bd0c1995..a6677466c 100644 --- a/client/src/standalone/videos/shared/player-options-builder.ts +++ b/client/src/standalone/videos/shared/player-options-builder.ts @@ -16,6 +16,9 @@ import { getParamString, getParamToggle, isP2PEnabled, + isVideoNSFWBlurForUser, + isVideoNSFWHiddenForUser, + isVideoNSFWWarnedForUser, logger, peertubeLocalStorage, UserLocalStorageKeys, @@ -235,6 +238,7 @@ export class PlayerOptionsBuilder { videoPassword: () => string requiresPassword: boolean + config: HTMLServerConfig translations: Translations playlist?: { @@ -256,7 +260,8 @@ export class PlayerOptionsBuilder { playlist, live, storyboardsResponse, - chaptersResponse + chaptersResponse, + config } = options const [ videoCaptions, storyboard, chapters ] = await Promise.all([ @@ -265,10 +270,13 @@ export class PlayerOptionsBuilder { this.buildChapters(chaptersResponse) ]) + const nsfwWarn = isVideoNSFWWarnedForUser(video, config, null) || isVideoNSFWHiddenForUser(video, config, null) + const nsfwBlur = isVideoNSFWBlurForUser(video, config, null) || isVideoNSFWHiddenForUser(video, config, null) + return { mode: this.mode, - autoplay: forceAutoplay || alreadyPlayed || this.autoplay, + autoplay: !nsfwWarn && (forceAutoplay || alreadyPlayed || this.autoplay), forceAutoplay, p2pEnabled: this.p2pEnabled, @@ -291,11 +299,20 @@ export class PlayerOptionsBuilder { videoShortUUID: video.shortUUID, videoUUID: video.uuid, + nsfwWarning: nsfwWarn + ? { + flags: video.nsfwFlags, + summary: video.nsfwSummary + } + : undefined, + + poster: nsfwBlur + ? null + : getBackendUrl() + video.previewPath, + duration: video.duration, videoRatio: video.aspectRatio, - poster: getBackendUrl() + video.previewPath, - embedUrl: getBackendUrl() + video.embedPath, embedTitle: video.name, diff --git a/client/src/types/index.ts b/client/src/types/index.ts index 60564496c..c170dcf94 100644 --- a/client/src/types/index.ts +++ b/client/src/types/index.ts @@ -5,3 +5,4 @@ export * from './job-type-client.type' export * from './link.type' export * from './register-client-option.model' export * from './select-options-item.model' +export * from './select-radio-item.model' diff --git a/client/src/types/select-radio-item.model.ts b/client/src/types/select-radio-item.model.ts new file mode 100644 index 000000000..2c73acc82 --- /dev/null +++ b/client/src/types/select-radio-item.model.ts @@ -0,0 +1,5 @@ +export interface SelectRadioItem { + id: T + label: string + description?: string +} diff --git a/client/yarn.lock b/client/yarn.lock index 28fcaf9ca..71b1d3881 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -446,10 +446,10 @@ "@babel/helper-string-parser" "^7.25.9" "@babel/helper-validator-identifier" "^7.25.9" -"@browserstack/ai-sdk-node@1.5.9": - version "1.5.9" - resolved "https://registry.yarnpkg.com/@browserstack/ai-sdk-node/-/ai-sdk-node-1.5.9.tgz#ec604b5e1dd28aa040dca6a3da49c50e60147a3d" - integrity sha512-RGLWcxadVEkafFaoeAPnIGxS/C+DtHDYWZ3Sy+P5VvWlDWhN9hZU5lZGMsUZg+XmrnuhodEEaZnJQV9P0IEeMQ== +"@browserstack/ai-sdk-node@1.5.17": + version "1.5.17" + resolved "https://registry.yarnpkg.com/@browserstack/ai-sdk-node/-/ai-sdk-node-1.5.17.tgz#3666b01f7f16fe7b7ca0e12c251c77c40a6bb8e5" + integrity sha512-odjnFulpBeF64UGHA+bIxkIcALYvEPznTl4U0hRT1AFfn4FqT+4wQdPBYnSnlc2XWTedv4zCDvbp4AFrtKXHEw== dependencies: axios "^1.7.4" uuid "9.0.1" @@ -510,126 +510,251 @@ resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz#38848d3e25afe842a7943643cbcd387cc6e13461" integrity sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA== +"@esbuild/aix-ppc64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.3.tgz#014180d9a149cffd95aaeead37179433f5ea5437" + integrity sha512-W8bFfPA8DowP8l//sxjJLSLkD8iEjMc7cBVyP+u4cEv9sM7mdUCkgsj+t0n/BWPFtv7WWCN5Yzj0N6FJNUUqBQ== + "@esbuild/android-arm64@0.24.2": version "0.24.2" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz#f592957ae8b5643129fa889c79e69cd8669bb894" integrity sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg== +"@esbuild/android-arm64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.3.tgz#649e47e04ddb24a27dc05c395724bc5f4c55cbfe" + integrity sha512-XelR6MzjlZuBM4f5z2IQHK6LkK34Cvv6Rj2EntER3lwCBFdg6h2lKbtRjpTTsdEjD/WSe1q8UyPBXP1x3i/wYQ== + "@esbuild/android-arm@0.24.2": version "0.24.2" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.24.2.tgz#72d8a2063aa630308af486a7e5cbcd1e134335b3" integrity sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q== +"@esbuild/android-arm@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.3.tgz#8a0f719c8dc28a4a6567ef7328c36ea85f568ff4" + integrity sha512-PuwVXbnP87Tcff5I9ngV0lmiSu40xw1At6i3GsU77U7cjDDB4s0X2cyFuBiDa1SBk9DnvWwnGvVaGBqoFWPb7A== + "@esbuild/android-x64@0.24.2": version "0.24.2" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.24.2.tgz#9a7713504d5f04792f33be9c197a882b2d88febb" integrity sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw== +"@esbuild/android-x64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.3.tgz#e2ab182d1fd06da9bef0784a13c28a7602d78009" + integrity sha512-ogtTpYHT/g1GWS/zKM0cc/tIebFjm1F9Aw1boQ2Y0eUQ+J89d0jFY//s9ei9jVIlkYi8AfOjiixcLJSGNSOAdQ== + "@esbuild/darwin-arm64@0.24.2": version "0.24.2" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz#02ae04ad8ebffd6e2ea096181b3366816b2b5936" integrity sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA== +"@esbuild/darwin-arm64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.3.tgz#c7f3166fcece4d158a73dcfe71b2672ca0b1668b" + integrity sha512-eESK5yfPNTqpAmDfFWNsOhmIOaQA59tAcF/EfYvo5/QWQCzXn5iUSOnqt3ra3UdzBv073ykTtmeLJZGt3HhA+w== + "@esbuild/darwin-x64@0.24.2": version "0.24.2" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz#9ec312bc29c60e1b6cecadc82bd504d8adaa19e9" integrity sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA== +"@esbuild/darwin-x64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.3.tgz#d8c5342ec1a4bf4b1915643dfe031ba4b173a87a" + integrity sha512-Kd8glo7sIZtwOLcPbW0yLpKmBNWMANZhrC1r6K++uDR2zyzb6AeOYtI6udbtabmQpFaxJ8uduXMAo1gs5ozz8A== + "@esbuild/freebsd-arm64@0.24.2": version "0.24.2" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz#5e82f44cb4906d6aebf24497d6a068cfc152fa00" integrity sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg== +"@esbuild/freebsd-arm64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.3.tgz#9f7d789e2eb7747d4868817417cc968ffa84f35b" + integrity sha512-EJiyS70BYybOBpJth3M0KLOus0n+RRMKTYzhYhFeMwp7e/RaajXvP+BWlmEXNk6uk+KAu46j/kaQzr6au+JcIw== + "@esbuild/freebsd-x64@0.24.2": version "0.24.2" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz#3fb1ce92f276168b75074b4e51aa0d8141ecce7f" integrity sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q== +"@esbuild/freebsd-x64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.3.tgz#8ad35c51d084184a8e9e76bb4356e95350a64709" + integrity sha512-Q+wSjaLpGxYf7zC0kL0nDlhsfuFkoN+EXrx2KSB33RhinWzejOd6AvgmP5JbkgXKmjhmpfgKZq24pneodYqE8Q== + "@esbuild/linux-arm64@0.24.2": version "0.24.2" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz#856b632d79eb80aec0864381efd29de8fd0b1f43" integrity sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg== +"@esbuild/linux-arm64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.3.tgz#3af0da3d9186092a9edd4e28fa342f57d9e3cd30" + integrity sha512-xCUgnNYhRD5bb1C1nqrDV1PfkwgbswTTBRbAd8aH5PhYzikdf/ddtsYyMXFfGSsb/6t6QaPSzxtbfAZr9uox4A== + "@esbuild/linux-arm@0.24.2": version "0.24.2" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz#c846b4694dc5a75d1444f52257ccc5659021b736" integrity sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA== +"@esbuild/linux-arm@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.3.tgz#e91cafa95e4474b3ae3d54da12e006b782e57225" + integrity sha512-dUOVmAUzuHy2ZOKIHIKHCm58HKzFqd+puLaS424h6I85GlSDRZIA5ycBixb3mFgM0Jdh+ZOSB6KptX30DD8YOQ== + "@esbuild/linux-ia32@0.24.2": version "0.24.2" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz#f8a16615a78826ccbb6566fab9a9606cfd4a37d5" integrity sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw== +"@esbuild/linux-ia32@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.3.tgz#81025732d85b68ee510161b94acdf7e3007ea177" + integrity sha512-yplPOpczHOO4jTYKmuYuANI3WhvIPSVANGcNUeMlxH4twz/TeXuzEP41tGKNGWJjuMhotpGabeFYGAOU2ummBw== + "@esbuild/linux-loong64@0.24.2": version "0.24.2" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz#1c451538c765bf14913512c76ed8a351e18b09fc" integrity sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ== +"@esbuild/linux-loong64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.3.tgz#3c744e4c8d5e1148cbe60a71a11b58ed8ee5deb8" + integrity sha512-P4BLP5/fjyihmXCELRGrLd793q/lBtKMQl8ARGpDxgzgIKJDRJ/u4r1A/HgpBpKpKZelGct2PGI4T+axcedf6g== + "@esbuild/linux-mips64el@0.24.2": version "0.24.2" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz#0846edeefbc3d8d50645c51869cc64401d9239cb" integrity sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw== +"@esbuild/linux-mips64el@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.3.tgz#1dfe2a5d63702db9034cc6b10b3087cc0424ec26" + integrity sha512-eRAOV2ODpu6P5divMEMa26RRqb2yUoYsuQQOuFUexUoQndm4MdpXXDBbUoKIc0iPa4aCO7gIhtnYomkn2x+bag== + "@esbuild/linux-ppc64@0.24.2": version "0.24.2" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz#8e3fc54505671d193337a36dfd4c1a23b8a41412" integrity sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw== +"@esbuild/linux-ppc64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.3.tgz#2e85d9764c04a1ebb346dc0813ea05952c9a5c56" + integrity sha512-ZC4jV2p7VbzTlnl8nZKLcBkfzIf4Yad1SJM4ZMKYnJqZFD4rTI+pBG65u8ev4jk3/MPwY9DvGn50wi3uhdaghg== + "@esbuild/linux-riscv64@0.24.2": version "0.24.2" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz#6a1e92096d5e68f7bb10a0d64bb5b6d1daf9a694" integrity sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q== +"@esbuild/linux-riscv64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.3.tgz#a9ea3334556b09f85ccbfead58c803d305092415" + integrity sha512-LDDODcFzNtECTrUUbVCs6j9/bDVqy7DDRsuIXJg6so+mFksgwG7ZVnTruYi5V+z3eE5y+BJZw7VvUadkbfg7QA== + "@esbuild/linux-s390x@0.24.2": version "0.24.2" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz#ab18e56e66f7a3c49cb97d337cd0a6fea28a8577" integrity sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw== +"@esbuild/linux-s390x@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.3.tgz#f6a7cb67969222b200974de58f105dfe8e99448d" + integrity sha512-s+w/NOY2k0yC2p9SLen+ymflgcpRkvwwa02fqmAwhBRI3SC12uiS10edHHXlVWwfAagYSY5UpmT/zISXPMW3tQ== + "@esbuild/linux-x64@0.24.2": version "0.24.2" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz#8140c9b40da634d380b0b29c837a0b4267aff38f" integrity sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q== +"@esbuild/linux-x64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.3.tgz#a237d3578ecdd184a3066b1f425e314ade0f8033" + integrity sha512-nQHDz4pXjSDC6UfOE1Fw9Q8d6GCAd9KdvMZpfVGWSJztYCarRgSDfOVBY5xwhQXseiyxapkiSJi/5/ja8mRFFA== + "@esbuild/netbsd-arm64@0.24.2": version "0.24.2" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz#65f19161432bafb3981f5f20a7ff45abb2e708e6" integrity sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw== +"@esbuild/netbsd-arm64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.3.tgz#4c15c68d8149614ddb6a56f9c85ae62ccca08259" + integrity sha512-1QaLtOWq0mzK6tzzp0jRN3eccmN3hezey7mhLnzC6oNlJoUJz4nym5ZD7mDnS/LZQgkrhEbEiTn515lPeLpgWA== + "@esbuild/netbsd-x64@0.24.2": version "0.24.2" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz#7a3a97d77abfd11765a72f1c6f9b18f5396bcc40" integrity sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw== +"@esbuild/netbsd-x64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.3.tgz#12f6856f8c54c2d7d0a8a64a9711c01a743878d5" + integrity sha512-i5Hm68HXHdgv8wkrt+10Bc50zM0/eonPb/a/OFVfB6Qvpiirco5gBA5bz7S2SHuU+Y4LWn/zehzNX14Sp4r27g== + "@esbuild/openbsd-arm64@0.24.2": version "0.24.2" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz#58b00238dd8f123bfff68d3acc53a6ee369af89f" integrity sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A== +"@esbuild/openbsd-arm64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.3.tgz#ca078dad4a34df192c60233b058db2ca3d94bc5c" + integrity sha512-zGAVApJEYTbOC6H/3QBr2mq3upG/LBEXr85/pTtKiv2IXcgKV0RT0QA/hSXZqSvLEpXeIxah7LczB4lkiYhTAQ== + "@esbuild/openbsd-x64@0.24.2": version "0.24.2" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz#0ac843fda0feb85a93e288842936c21a00a8a205" integrity sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA== +"@esbuild/openbsd-x64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.3.tgz#c9178adb60e140e03a881d0791248489c79f95b2" + integrity sha512-fpqctI45NnCIDKBH5AXQBsD0NDPbEFczK98hk/aa6HJxbl+UtLkJV2+Bvy5hLSLk3LHmqt0NTkKNso1A9y1a4w== + "@esbuild/sunos-x64@0.24.2": version "0.24.2" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz#8b7aa895e07828d36c422a4404cc2ecf27fb15c6" integrity sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig== +"@esbuild/sunos-x64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.3.tgz#03765eb6d4214ff27e5230af779e80790d1ee09f" + integrity sha512-ROJhm7d8bk9dMCUZjkS8fgzsPAZEjtRJqCAmVgB0gMrvG7hfmPmz9k1rwO4jSiblFjYmNvbECL9uhaPzONMfgA== + "@esbuild/win32-arm64@0.24.2": version "0.24.2" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz#c023afb647cabf0c3ed13f0eddfc4f1d61c66a85" integrity sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ== +"@esbuild/win32-arm64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.3.tgz#f1c867bd1730a9b8dfc461785ec6462e349411ea" + integrity sha512-YWcow8peiHpNBiIXHwaswPnAXLsLVygFwCB3A7Bh5jRkIBFWHGmNQ48AlX4xDvQNoMZlPYzjVOQDYEzWCqufMQ== + "@esbuild/win32-ia32@0.24.2": version "0.24.2" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz#96c356132d2dda990098c8b8b951209c3cd743c2" integrity sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA== +"@esbuild/win32-ia32@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.3.tgz#77491f59ef6c9ddf41df70670d5678beb3acc322" + integrity sha512-qspTZOIGoXVS4DpNqUYUs9UxVb04khS1Degaw/MnfMe7goQ3lTfQ13Vw4qY/Nj0979BGvMRpAYbs/BAxEvU8ew== + "@esbuild/win32-x64@0.24.2": version "0.24.2" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz#34aa0b52d0fbb1a654b596acfa595f0c7b77a77b" integrity sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg== +"@esbuild/win32-x64@0.25.3": + version "0.25.3" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.3.tgz#b17a2171f9074df9e91bfb07ef99a892ac06412a" + integrity sha512-ICgUR+kPimx0vvRzf+N/7L7tVSQeE3BYY+NhHRHXS1kBuPO7z2+7ea2HbhDyZdTephgvNvKrlDDKUexuCVBVvg== + "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": version "4.4.1" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz#d1145bf2c20132d6400495d6df4bf59362fd9d56" @@ -722,6 +847,17 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3" integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA== +"@inquirer/checkbox@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@inquirer/checkbox/-/checkbox-3.0.1.tgz#0a57f704265f78c36e17f07e421b98efb4b9867b" + integrity sha512-0hm2nrToWUdD6/UHnel/UKGdk1//ke5zGUpHIvk5ZWmaKezlGxZkOJXNSWsdxO/rEqTkbB3lNC2J6nBElV2aAQ== + dependencies: + "@inquirer/core" "^9.2.1" + "@inquirer/figures" "^1.0.6" + "@inquirer/type" "^2.0.0" + ansi-escapes "^4.3.2" + yoctocolors-cjs "^2.1.2" + "@inquirer/checkbox@^4.0.4": version "4.1.1" resolved "https://registry.yarnpkg.com/@inquirer/checkbox/-/checkbox-4.1.1.tgz#5f2c0ce74a75e3872f8e170fd209655972ce7802" @@ -741,6 +877,14 @@ "@inquirer/core" "^10.1.2" "@inquirer/type" "^3.0.2" +"@inquirer/confirm@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@inquirer/confirm/-/confirm-4.0.1.tgz#9106d6bffa0b2fdd0e4f60319b6f04f2e06e6e25" + integrity sha512-46yL28o2NJ9doViqOy0VDcoTzng7rAb6yPQKU7VDLqkmbCaH4JqK4yk4XqlzNWy9PVC5pG1ZUXPBQv+VqnYs2w== + dependencies: + "@inquirer/core" "^9.2.1" + "@inquirer/type" "^2.0.0" + "@inquirer/confirm@^5.1.1": version "5.1.5" resolved "https://registry.yarnpkg.com/@inquirer/confirm/-/confirm-5.1.5.tgz#0e6bf86794f69f849667ee38815608d6cd5917ba" @@ -763,6 +907,33 @@ wrap-ansi "^6.2.0" yoctocolors-cjs "^2.1.2" +"@inquirer/core@^9.2.1": + version "9.2.1" + resolved "https://registry.yarnpkg.com/@inquirer/core/-/core-9.2.1.tgz#677c49dee399c9063f31e0c93f0f37bddc67add1" + integrity sha512-F2VBt7W/mwqEU4bL0RnHNZmC/OxzNx9cOYxHqnXX3MP6ruYvZUZAW9imgN9+h/uBT/oP8Gh888J2OZSbjSeWcg== + dependencies: + "@inquirer/figures" "^1.0.6" + "@inquirer/type" "^2.0.0" + "@types/mute-stream" "^0.0.4" + "@types/node" "^22.5.5" + "@types/wrap-ansi" "^3.0.0" + ansi-escapes "^4.3.2" + cli-width "^4.1.0" + mute-stream "^1.0.0" + signal-exit "^4.1.0" + strip-ansi "^6.0.1" + wrap-ansi "^6.2.0" + yoctocolors-cjs "^2.1.2" + +"@inquirer/editor@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@inquirer/editor/-/editor-3.0.1.tgz#d109f21e050af6b960725388cb1c04214ed7c7bc" + integrity sha512-VA96GPFaSOVudjKFraokEEmUQg/Lub6OXvbIEZU1SDCmBzRkHGhxoFAVaF30nyiB4m5cEbDgiI2QRacXZ2hw9Q== + dependencies: + "@inquirer/core" "^9.2.1" + "@inquirer/type" "^2.0.0" + external-editor "^3.1.0" + "@inquirer/editor@^4.2.1": version "4.2.6" resolved "https://registry.yarnpkg.com/@inquirer/editor/-/editor-4.2.6.tgz#dec442b9f7ada0804bb9ba689370cc05fd385b20" @@ -772,6 +943,15 @@ "@inquirer/type" "^3.0.4" external-editor "^3.1.0" +"@inquirer/expand@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@inquirer/expand/-/expand-3.0.1.tgz#aed9183cac4d12811be47a4a895ea8e82a17e22c" + integrity sha512-ToG8d6RIbnVpbdPdiN7BCxZGiHOTomOX94C2FaT5KOHupV40tKEDozp12res6cMIfRKrXLJyexAZhWVHgbALSQ== + dependencies: + "@inquirer/core" "^9.2.1" + "@inquirer/type" "^2.0.0" + yoctocolors-cjs "^2.1.2" + "@inquirer/expand@^4.0.4": version "4.0.8" resolved "https://registry.yarnpkg.com/@inquirer/expand/-/expand-4.0.8.tgz#8438bd34af182d4a37d8d7101a328e10430efadc" @@ -786,6 +966,19 @@ resolved "https://registry.yarnpkg.com/@inquirer/figures/-/figures-1.0.10.tgz#e3676a51c9c51aaabcd6ba18a28e82b98417db37" integrity sha512-Ey6176gZmeqZuY/W/nZiUyvmb1/qInjcpiZjXWi6nON+nxJpD1bxtSoBxNliGISae32n6OwbY+TSXPZ1CfS4bw== +"@inquirer/figures@^1.0.6": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@inquirer/figures/-/figures-1.0.11.tgz#4744e6db95288fea1dead779554859710a959a21" + integrity sha512-eOg92lvrn/aRUqbxRyvpEWnrvRuTYRifixHkYVpJiygTgVSBIHDqLh0SrMQXkafvULg3ck11V7xvR+zcgvpHFw== + +"@inquirer/input@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@inquirer/input/-/input-3.0.1.tgz#de63d49e516487388508d42049deb70f2cb5f28e" + integrity sha512-BDuPBmpvi8eMCxqC5iacloWqv+5tQSJlUafYWUe31ow1BVXjW2a5qe3dh4X/Z25Wp22RwvcaLCc2siHobEOfzg== + dependencies: + "@inquirer/core" "^9.2.1" + "@inquirer/type" "^2.0.0" + "@inquirer/input@^4.1.1": version "4.1.5" resolved "https://registry.yarnpkg.com/@inquirer/input/-/input-4.1.5.tgz#ea3ffed7947c28d61ef3f261c4f261e99c4cac8a" @@ -794,6 +987,14 @@ "@inquirer/core" "^10.1.6" "@inquirer/type" "^3.0.4" +"@inquirer/number@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@inquirer/number/-/number-2.0.1.tgz#b9863080d02ab7dc2e56e16433d83abea0f2a980" + integrity sha512-QpR8jPhRjSmlr/mD2cw3IR8HRO7lSVOnqUvQa8scv1Lsr3xoAMMworcYW3J13z3ppjBFBD2ef1Ci6AE5Qn8goQ== + dependencies: + "@inquirer/core" "^9.2.1" + "@inquirer/type" "^2.0.0" + "@inquirer/number@^3.0.4": version "3.0.8" resolved "https://registry.yarnpkg.com/@inquirer/number/-/number-3.0.8.tgz#ca44c09a8ac74040e2327e04694799eae603e9de" @@ -802,6 +1003,15 @@ "@inquirer/core" "^10.1.6" "@inquirer/type" "^3.0.4" +"@inquirer/password@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@inquirer/password/-/password-3.0.1.tgz#2a9a9143591088336bbd573bcb05d5bf080dbf87" + integrity sha512-haoeEPUisD1NeE2IanLOiFr4wcTXGWrBOyAyPZi1FfLJuXOzNmxCJPgUrGYKVh+Y8hfGJenIfz5Wb/DkE9KkMQ== + dependencies: + "@inquirer/core" "^9.2.1" + "@inquirer/type" "^2.0.0" + ansi-escapes "^4.3.2" + "@inquirer/password@^4.0.4": version "4.0.8" resolved "https://registry.yarnpkg.com/@inquirer/password/-/password-4.0.8.tgz#ac2b14800a75f15e3404d98616d9dc7d8c2df38b" @@ -827,6 +1037,31 @@ "@inquirer/search" "^3.0.4" "@inquirer/select" "^4.0.4" +"@inquirer/prompts@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/@inquirer/prompts/-/prompts-6.0.1.tgz#43f5c0ed35c5ebfe52f1d43d46da2d363d950071" + integrity sha512-yl43JD/86CIj3Mz5mvvLJqAOfIup7ncxfJ0Btnl0/v5TouVUyeEdcpknfgc+yMevS/48oH9WAkkw93m7otLb/A== + dependencies: + "@inquirer/checkbox" "^3.0.1" + "@inquirer/confirm" "^4.0.1" + "@inquirer/editor" "^3.0.1" + "@inquirer/expand" "^3.0.1" + "@inquirer/input" "^3.0.1" + "@inquirer/number" "^2.0.1" + "@inquirer/password" "^3.0.1" + "@inquirer/rawlist" "^3.0.1" + "@inquirer/search" "^2.0.1" + "@inquirer/select" "^3.0.1" + +"@inquirer/rawlist@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@inquirer/rawlist/-/rawlist-3.0.1.tgz#729def358419cc929045f264131878ed379e0af3" + integrity sha512-VgRtFIwZInUzTiPLSfDXK5jLrnpkuSOh1ctfaoygKAdPqjcjKYmGh6sCY1pb0aGnCGsmhUxoqLDUAU0ud+lGXQ== + dependencies: + "@inquirer/core" "^9.2.1" + "@inquirer/type" "^2.0.0" + yoctocolors-cjs "^2.1.2" + "@inquirer/rawlist@^4.0.4": version "4.0.8" resolved "https://registry.yarnpkg.com/@inquirer/rawlist/-/rawlist-4.0.8.tgz#1d4389186d63861a2abe2dd107f72e813dc0ea4b" @@ -836,6 +1071,16 @@ "@inquirer/type" "^3.0.4" yoctocolors-cjs "^2.1.2" +"@inquirer/search@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@inquirer/search/-/search-2.0.1.tgz#69b774a0a826de2e27b48981d01bc5ad81e73721" + integrity sha512-r5hBKZk3g5MkIzLVoSgE4evypGqtOannnB3PKTG9NRZxyFRKcfzrdxXXPcoJQsxJPzvdSU2Rn7pB7lw0GCmGAg== + dependencies: + "@inquirer/core" "^9.2.1" + "@inquirer/figures" "^1.0.6" + "@inquirer/type" "^2.0.0" + yoctocolors-cjs "^2.1.2" + "@inquirer/search@^3.0.4": version "3.0.8" resolved "https://registry.yarnpkg.com/@inquirer/search/-/search-3.0.8.tgz#38c25f5b2db15a268be76b09bd12b4599ecc216b" @@ -846,6 +1091,17 @@ "@inquirer/type" "^3.0.4" yoctocolors-cjs "^2.1.2" +"@inquirer/select@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@inquirer/select/-/select-3.0.1.tgz#1df9ed27fb85a5f526d559ac5ce7cc4e9dc4e7ec" + integrity sha512-lUDGUxPhdWMkN/fHy1Lk7pF3nK1fh/gqeyWXmctefhxLYxlDsc7vsPBEpxrfVGDsVdyYJsiJoD4bJ1b623cV1Q== + dependencies: + "@inquirer/core" "^9.2.1" + "@inquirer/figures" "^1.0.6" + "@inquirer/type" "^2.0.0" + ansi-escapes "^4.3.2" + yoctocolors-cjs "^2.1.2" + "@inquirer/select@^4.0.4": version "4.0.8" resolved "https://registry.yarnpkg.com/@inquirer/select/-/select-4.0.8.tgz#dde85e10bc4e650c51542de533a91b6bc63498b7" @@ -864,6 +1120,13 @@ dependencies: mute-stream "^1.0.0" +"@inquirer/type@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@inquirer/type/-/type-2.0.0.tgz#08fa513dca2cb6264fe1b0a2fabade051444e3f6" + integrity sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag== + dependencies: + mute-stream "^1.0.0" + "@inquirer/type@^3.0.2", "@inquirer/type@^3.0.4": version "3.0.4" resolved "https://registry.yarnpkg.com/@inquirer/type/-/type-3.0.4.tgz#fa5f9e91a0abf3c9e93d3e1990ecb891d8195cf2" @@ -978,13 +1241,6 @@ dependencies: "@inquirer/type" "^1.5.5" -"@ljharb/through@^2.3.11": - version "2.3.13" - resolved "https://registry.yarnpkg.com/@ljharb/through/-/through-2.3.13.tgz#b7e4766e0b65aa82e529be945ab078de79874edc" - integrity sha512-/gKJun8NNiWGZJkGzI/Ragc53cOdcLNdzjLaIa+GEjguQs0ulsurx8WN0jijdK9yPqDvziX995sMRLyLt1uZMQ== - dependencies: - call-bind "^1.0.7" - "@lmdb/lmdb-darwin-arm64@3.2.2": version "3.2.2" resolved "https://registry.yarnpkg.com/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.2.2.tgz#39e25e2a95d35a7350862af96d05e5396ea8a074" @@ -1395,25 +1651,25 @@ he "^1.2.0" tokenizr "^1.6.4" -"@percy/appium-app@^2.0.1": - version "2.0.9" - resolved "https://registry.yarnpkg.com/@percy/appium-app/-/appium-app-2.0.9.tgz#b93f085b76514f8d180745978d837df093872c7f" - integrity sha512-50tBFevee8f6zxAPr8o8e73vy8JNuLKRi7BcbdWh+N7dK+B6Iu1tyGNElFddDjJaTlWcv1Tg9iAdRlXLfIpZhg== +"@percy/appium-app@^2.0.9": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@percy/appium-app/-/appium-app-2.1.0.tgz#f1c8b798362fbdeea98dbd5dfb9c0857835b1d70" + integrity sha512-XVigKgAcXEerIch3Ufngac07gOH4KnfTDp/xyPujDyjvAZSWfIyIRnojmfbLEs2HnZEnmFFoEMX6ZB4Tk0SO/Q== dependencies: - "@percy/sdk-utils" "^1.28.2" + "@percy/sdk-utils" "^1.30.9" tmp "^0.2.3" -"@percy/sdk-utils@^1.28.2", "@percy/sdk-utils@^1.30.3": - version "1.30.7" - resolved "https://registry.yarnpkg.com/@percy/sdk-utils/-/sdk-utils-1.30.7.tgz#39fcba80aa041dee8b585dbcf9b95c01c93f7993" - integrity sha512-HVQSg0MgY4Ziv0mtbeelz4aRBKoEQnKaKtWl7Nf6FzSELAdUXNz4BNRBAJWOt8O6M5MRXbk6/7jSFJStGsg5Zw== +"@percy/sdk-utils@^1.30.9": + version "1.30.10" + resolved "https://registry.yarnpkg.com/@percy/sdk-utils/-/sdk-utils-1.30.10.tgz#f90d1373868a79b3887fccae0d9c246417d8d557" + integrity sha512-EOFm6XDbXIpo1YjF+JWxNCW5TB0ZaqjQfHLtOCmffhHi2T0MCXSAHdNxeTUyADyySzWjD4bKba/PbZwwTVE8Zw== -"@percy/selenium-webdriver@^2.0.3": - version "2.2.2" - resolved "https://registry.yarnpkg.com/@percy/selenium-webdriver/-/selenium-webdriver-2.2.2.tgz#36e795f2a26fb8cd2d17b8df59af3e4c72cfe2c6" - integrity sha512-ksgPO9q/twhZTSVUrw8a96iiMMi2Y+SpGtwIEyOuZtNeEqEeJH3Mta1EvEUSauTH7HjqkP3Qemh/HaWrLUDK5w== +"@percy/selenium-webdriver@^2.2.2": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@percy/selenium-webdriver/-/selenium-webdriver-2.2.3.tgz#1751c55588e87d8d91bc11af00f6c92ab8194ece" + integrity sha512-dVUsgKkDUYvv7+jN4S4HuwSoYxb7Up0U7dM3DRj3/XzLp3boZiyTWAdFdOGS8R5eSsiY5UskTcGQKmGqHRle1Q== dependencies: - "@percy/sdk-utils" "^1.30.3" + "@percy/sdk-utils" "^1.30.9" node-request-interceptor "^0.6.3" "@pkgjs/parseargs@^0.11.0": @@ -1466,6 +1722,19 @@ unbzip2-stream "1.4.3" yargs "17.7.2" +"@puppeteer/browsers@^2.2.0": + version "2.10.2" + resolved "https://registry.yarnpkg.com/@puppeteer/browsers/-/browsers-2.10.2.tgz#c2a63cee699c6b5b971b9fcba9095098970f1648" + integrity sha512-i4Ez+s9oRWQbNjtI/3+jxr7OH508mjAKvza0ekPJem0ZtmsYHP3B5dq62+IaBHKaGCOuqJxXzvFLUhJvQ6jtsQ== + dependencies: + debug "^4.4.0" + extract-zip "^2.0.1" + progress "^2.0.3" + proxy-agent "^6.5.0" + semver "^7.7.1" + tar-fs "^3.0.8" + yargs "^17.7.2" + "@rollup/plugin-inject@^5.0.5": version "5.0.5" resolved "https://registry.yarnpkg.com/@rollup/plugin-inject/-/plugin-inject-5.0.5.tgz#616f3a73fe075765f91c5bec90176608bed277a3" @@ -1683,6 +1952,11 @@ "@angular-devkit/schematics" "19.1.5" jsonc-parser "3.3.1" +"@sec-ant/readable-stream@^0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz#60de891bb126abfdc5410fdc6166aca065f10a0c" + integrity sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg== + "@sigstore/bundle@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@sigstore/bundle/-/bundle-3.0.0.tgz#ffffc750436c6eb8330ead1ca65bc892f893a7c5" @@ -1739,6 +2013,11 @@ resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-5.6.0.tgz#41dd6093d34652cddb5d5bdeee04eafc33826668" integrity sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g== +"@sindresorhus/merge-streams@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz#abb11d99aeb6d27f1b563c38147a72d50058e339" + integrity sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ== + "@socket.io/component-emitter@~3.1.0": version "3.1.2" resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2" @@ -1944,7 +2223,7 @@ resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd" integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg== -"@types/mocha@^10.0.0": +"@types/mocha@^10.0.6": version "10.0.10" resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-10.0.10.tgz#91f62905e8d23cbd66225312f239454a23bebfa0" integrity sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q== @@ -1954,6 +2233,13 @@ resolved "https://registry.yarnpkg.com/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78" integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== +"@types/mute-stream@^0.0.4": + version "0.0.4" + resolved "https://registry.yarnpkg.com/@types/mute-stream/-/mute-stream-0.0.4.tgz#77208e56a08767af6c5e1237be8888e2f255c478" + integrity sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow== + dependencies: + "@types/node" "*" + "@types/node-fetch@^2.6.12": version "2.6.12" resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.12.tgz#8ab5c3ef8330f13100a7479e2cd56d3386830a03" @@ -1976,6 +2262,20 @@ dependencies: undici-types "~5.26.4" +"@types/node@^20.1.0", "@types/node@^20.1.1", "@types/node@^20.11.28", "@types/node@^20.11.30": + version "20.17.32" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.17.32.tgz#cb9703514cd8e172c11beff582c66006644c2d88" + integrity sha512-zeMXFn8zQ+UkjK4ws0RiOC9EWByyW1CcVmLe+2rQocXRsGEDxUCwPEIVgpsGcLHS/P8JkT0oa3839BRABS0oPw== + dependencies: + undici-types "~6.19.2" + +"@types/node@^22.5.5": + version "22.15.3" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.15.3.tgz#b7fb9396a8ec5b5dfb1345d8ac2502060e9af68b" + integrity sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw== + dependencies: + undici-types "~6.21.0" + "@types/normalize-package-data@^2.4.1": version "2.4.4" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz#56e2cc26c397c038fab0e3a917a12d5c5909e901" @@ -2009,6 +2309,11 @@ dependencies: "@types/node" "*" +"@types/sinonjs__fake-timers@^8.1.5": + version "8.1.5" + resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz#5fd3592ff10c1e9695d377020c033116cc2889f2" + integrity sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ== + "@types/stack-utils@^2.0.0": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" @@ -2034,6 +2339,11 @@ resolved "https://registry.yarnpkg.com/@types/which/-/which-2.0.2.tgz#54541d02d6b1daee5ec01ac0d1b37cecf37db1ae" integrity sha512-113D3mDkZDjo+EeUEHCFy0qniNc1ZpecGiAU7WSo7YDoSzolZIQKpYFHrPpjkB2nuyahcKfrmLXeQlh7gqJYdw== +"@types/wrap-ansi@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz#18b97a972f94f60a679fd5c796d96421b9abb9fd" + integrity sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g== + "@types/ws@^8.5.3": version "8.5.14" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.14.tgz#93d44b268c9127d96026cf44353725dd9b6c3c21" @@ -2190,7 +2500,14 @@ dependencies: tinyrainbow "^1.2.0" -"@vitest/snapshot@^2.0.3", "@vitest/snapshot@^2.0.4": +"@vitest/pretty-format@2.1.9": + version "2.1.9" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-2.1.9.tgz#434ff2f7611689f9ce70cd7d567eceb883653fdf" + integrity sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ== + dependencies: + tinyrainbow "^1.2.0" + +"@vitest/snapshot@^2.0.3": version "2.1.8" resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-2.1.8.tgz#d5dc204f4b95dc8b5e468b455dfc99000047d2de" integrity sha512-20T7xRFbmnkfcmgVEz+z3AU/3b0cEzZOt/zmnvZEctg64/QZbSDJEVm9fLnnlSi74KibmRsO9/Qabi+t0vCRPg== @@ -2199,58 +2516,67 @@ magic-string "^0.30.12" pathe "^1.1.2" -"@wdio/browserstack-service@^8.10.5": - version "8.41.0" - resolved "https://registry.yarnpkg.com/@wdio/browserstack-service/-/browserstack-service-8.41.0.tgz#16b1b2fbe4fe6a7e1381939dca8ec614584fba43" - integrity sha512-ghpXZdixEVRU/2Z1gmle3gXJDCnWUTn1LdSP8WP56JpGx9m7CROHUOj6iYxCoVf1rEDYOYCX3k/7QARMhybj3g== +"@vitest/snapshot@^2.0.5", "@vitest/snapshot@^2.1.1": + version "2.1.9" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-2.1.9.tgz#24260b93f798afb102e2dcbd7e61c6dfa118df91" + integrity sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ== dependencies: - "@browserstack/ai-sdk-node" "1.5.9" - "@percy/appium-app" "^2.0.1" - "@percy/selenium-webdriver" "^2.0.3" + "@vitest/pretty-format" "2.1.9" + magic-string "^0.30.12" + pathe "^1.1.2" + +"@wdio/browserstack-service@^9.12.7": + version "9.12.7" + resolved "https://registry.yarnpkg.com/@wdio/browserstack-service/-/browserstack-service-9.12.7.tgz#83bd1400798641360fe72cea2340180da098511f" + integrity sha512-NXYWQ4mnW1pUC8eOSfQIirm5udXUXGWcBVz356dVXYyAE6cFvDaBGgqWlGwXZdph4alkZEcG6/JmZ9xs1ZO8Gg== + dependencies: + "@browserstack/ai-sdk-node" "1.5.17" + "@percy/appium-app" "^2.0.9" + "@percy/selenium-webdriver" "^2.2.2" "@types/gitconfiglocal" "^2.0.1" - "@wdio/logger" "8.38.0" - "@wdio/reporter" "8.41.0" - "@wdio/types" "8.41.0" + "@wdio/logger" "9.4.4" + "@wdio/reporter" "9.12.6" + "@wdio/types" "9.12.6" browserstack-local "^1.5.1" chalk "^5.3.0" csv-writer "^1.6.0" formdata-node "5.0.1" git-repo-info "^2.1.1" gitconfiglocal "^2.1.0" - got "^12.6.1" + undici "^6.20.1" uuid "^10.0.0" - webdriverio "8.41.0" + webdriverio "9.12.7" winston-transport "^4.5.0" yauzl "^3.0.0" -"@wdio/cli@^8.10.5": - version "8.41.0" - resolved "https://registry.yarnpkg.com/@wdio/cli/-/cli-8.41.0.tgz#f53576736d179dd4df32c68bbbaaad12b149168d" - integrity sha512-+f4McBz6M8/oEJLeoYxHNyfvQ4NTGRACKjtw/FdTJ+GYRu8DkU9sgXOo70NIPCMUajVIOtbDVUbLjJKuxWTEFQ== +"@wdio/cli@^9.12.7": + version "9.12.7" + resolved "https://registry.yarnpkg.com/@wdio/cli/-/cli-9.12.7.tgz#c2df972730540c1ac269b217529fcc0b6d86c53c" + integrity sha512-X764hL/nHcbMTepvr7zNF/pSvb4r3twoa5lKllkIIraRDI0cg1/AKHreX24htjHpoA5OLzjEJaydQVJpZ3RzmA== dependencies: - "@types/node" "^22.2.0" - "@vitest/snapshot" "^2.0.4" - "@wdio/config" "8.41.0" - "@wdio/globals" "8.41.0" - "@wdio/logger" "8.38.0" - "@wdio/protocols" "8.40.3" - "@wdio/types" "8.41.0" - "@wdio/utils" "8.41.0" + "@types/node" "^20.1.1" + "@vitest/snapshot" "^2.1.1" + "@wdio/config" "9.12.6" + "@wdio/globals" "9.12.7" + "@wdio/logger" "9.4.4" + "@wdio/protocols" "9.12.5" + "@wdio/types" "9.12.6" + "@wdio/utils" "9.12.6" async-exit-hook "^2.0.1" chalk "^5.2.0" chokidar "^4.0.0" - cli-spinners "^2.9.0" dotenv "^16.3.1" ejs "^3.1.9" - execa "^8.0.1" + execa "^9.2.0" import-meta-resolve "^4.0.0" - inquirer "9.2.12" + inquirer "^11.0.1" lodash.flattendeep "^4.4.0" lodash.pickby "^4.6.0" lodash.union "^4.6.0" - read-pkg-up "10.0.0" + read-pkg-up "^10.0.0" recursive-readdir "^2.2.3" - webdriverio "8.41.0" + tsx "^4.7.2" + webdriverio "9.12.7" yargs "^17.7.2" "@wdio/config@8.41.0": @@ -2266,7 +2592,36 @@ glob "^10.2.2" import-meta-resolve "^4.0.0" -"@wdio/globals@8.41.0", "@wdio/globals@^8.29.3": +"@wdio/config@9.12.6": + version "9.12.6" + resolved "https://registry.yarnpkg.com/@wdio/config/-/config-9.12.6.tgz#2b1d1b7950450940563d54ef988eec2dcc2a3bb3" + integrity sha512-zlOJixJUHxeoyfIN/KdM797HwJj/oNgBaEdftgJARqbXt5AVZu18vJ3zljb+wzbY2M0pl7Y4+5OFH06WlDgQ+A== + dependencies: + "@wdio/logger" "9.4.4" + "@wdio/types" "9.12.6" + "@wdio/utils" "9.12.6" + deepmerge-ts "^7.0.3" + glob "^10.2.2" + import-meta-resolve "^4.0.0" + +"@wdio/dot-reporter@9.12.6": + version "9.12.6" + resolved "https://registry.yarnpkg.com/@wdio/dot-reporter/-/dot-reporter-9.12.6.tgz#84490a9c0d3a0e7b14768b98e3b23ae4d526a8cf" + integrity sha512-den2sRD+blw6ymI97X808ESxQ5cVVuNeu5V2VUJk3NWA7Q36cuaJ8s/rPKzcF0CdfdbkkKF8pEQWUVDrrWsK7Q== + dependencies: + "@wdio/reporter" "9.12.6" + "@wdio/types" "9.12.6" + chalk "^5.0.1" + +"@wdio/globals@9.12.7": + version "9.12.7" + resolved "https://registry.yarnpkg.com/@wdio/globals/-/globals-9.12.7.tgz#c700a4e769d2c27766e7787581b4fa66b1e86e5c" + integrity sha512-WanmrLXRMmW3hwsXCm+x618gDsdGwkrxhiirgMC9Ny0g78qt7JLSOvAHKx+dCZtk77QwvFuNpLCd+Nxnszon9Q== + optionalDependencies: + expect-webdriverio "^5.1.0" + webdriverio "9.12.7" + +"@wdio/globals@^8.29.3": version "8.41.0" resolved "https://registry.yarnpkg.com/@wdio/globals/-/globals-8.41.0.tgz#3c68a814ff993201834517ddfbbd728d89bdfc54" integrity sha512-xfUpEppdKzMHy4qoSoQN1cXoBPPh7oMeX+U/jtdvOtla+dd/YZ8pu47zLhQ/GM3gDVrBGnO4w3u4L6Zf/P3KEw== @@ -2274,16 +2629,16 @@ expect-webdriverio "^4.11.2" webdriverio "8.41.0" -"@wdio/local-runner@^8.10.5": - version "8.41.0" - resolved "https://registry.yarnpkg.com/@wdio/local-runner/-/local-runner-8.41.0.tgz#ae3c2c1e0f7e40b3a239a96d64124643a35a564c" - integrity sha512-A5msAjAC8gqiWvtFl+VNm9BBlVb5q3a2o7i+L+Cw7idV3aFY5etigB2wLYMtyBWgB8cXvbZaxXizHhGvZ+iB8Q== +"@wdio/local-runner@^9.12.7": + version "9.12.7" + resolved "https://registry.yarnpkg.com/@wdio/local-runner/-/local-runner-9.12.7.tgz#959752a6e2457254caa5a6545ed8f33341264270" + integrity sha512-DabNXK0VkF7dZfhdGsfOXHnBNQ4Xy+TgFxJOXAcDKHw6nJ+WFf/pzvatV8Cye4oEvPeyhFHZlcLYJiMKUt9LKw== dependencies: - "@types/node" "^22.2.0" - "@wdio/logger" "8.38.0" - "@wdio/repl" "8.40.3" - "@wdio/runner" "8.41.0" - "@wdio/types" "8.41.0" + "@types/node" "^20.1.0" + "@wdio/logger" "9.4.4" + "@wdio/repl" "9.4.4" + "@wdio/runner" "9.12.7" + "@wdio/types" "9.12.6" async-exit-hook "^2.0.1" split2 "^4.1.0" stream-buffers "^3.0.2" @@ -2298,23 +2653,38 @@ loglevel-plugin-prefix "^0.8.4" strip-ansi "^7.1.0" -"@wdio/mocha-framework@^8.10.4": - version "8.41.0" - resolved "https://registry.yarnpkg.com/@wdio/mocha-framework/-/mocha-framework-8.41.0.tgz#3582434e60501c3589b0c9602418c127593f50a2" - integrity sha512-wVSU/kZOp//QiwF+V5xl3tDtZixJdGIoBn3FJ/0qltDkBME7wj+Rvn3Sp3KbbwgaNFwx0HpTbfYqnfCCSMibXw== +"@wdio/logger@9.4.4", "@wdio/logger@^9.1.3": + version "9.4.4" + resolved "https://registry.yarnpkg.com/@wdio/logger/-/logger-9.4.4.tgz#e4851256a076e2b9401f45caaa7a34d6f0278d4a" + integrity sha512-BXx8RXFUW2M4dcO6t5Le95Hi2ZkTQBRsvBQqLekT2rZ6Xmw8ZKZBPf0FptnoftFGg6dYmwnDidYv/0+4PiHjpQ== dependencies: - "@types/mocha" "^10.0.0" - "@types/node" "^22.2.0" - "@wdio/logger" "8.38.0" - "@wdio/types" "8.41.0" - "@wdio/utils" "8.41.0" - mocha "^10.0.0" + chalk "^5.1.2" + loglevel "^1.6.0" + loglevel-plugin-prefix "^0.8.4" + strip-ansi "^7.1.0" + +"@wdio/mocha-framework@^9.12.6": + version "9.12.6" + resolved "https://registry.yarnpkg.com/@wdio/mocha-framework/-/mocha-framework-9.12.6.tgz#e2f2503e3fd58122aa60be081b1631ec3c64a9d6" + integrity sha512-vBAtVY+PLCGTZqTqsfxNtPVthk6tKI4JSffgGNlWDK/uCcjUOZdvsRRs7VRLr8COyeP1QQFzJ8cHDpCu8nd7Fw== + dependencies: + "@types/mocha" "^10.0.6" + "@types/node" "^20.11.28" + "@wdio/logger" "9.4.4" + "@wdio/types" "9.12.6" + "@wdio/utils" "9.12.6" + mocha "^10.3.0" "@wdio/protocols@8.40.3": version "8.40.3" resolved "https://registry.yarnpkg.com/@wdio/protocols/-/protocols-8.40.3.tgz#cf823f4a571b650750b12b9033b65cf177fdb367" integrity sha512-wK7+eyrB3TAei8RwbdkcyoNk2dPu+mduMBOdPJjp8jf/mavd15nIUXLID1zA+w5m1Qt1DsT1NbvaeO9+aJQ33A== +"@wdio/protocols@9.12.5": + version "9.12.5" + resolved "https://registry.yarnpkg.com/@wdio/protocols/-/protocols-9.12.5.tgz#68f21c03c0ed19cde435fa119b9dd6fee7c9ac0c" + integrity sha512-i+yc0EZtZOh5fFuwHxvcnXeTXk2ZjFICRbcAxTNE0F2Jr4uOydvcAOw4EIIRmb9NWUSPf/bGZAA+4SEXmxmjUA== + "@wdio/repl@8.40.3": version "8.40.3" resolved "https://registry.yarnpkg.com/@wdio/repl/-/repl-8.40.3.tgz#897b225b4ea1b961ac014ff0a6cb51c8917bd139" @@ -2322,56 +2692,62 @@ dependencies: "@types/node" "^22.2.0" -"@wdio/reporter@8.41.0": - version "8.41.0" - resolved "https://registry.yarnpkg.com/@wdio/reporter/-/reporter-8.41.0.tgz#0d68a6f0b4b81c02a9b3ba4df69fb0a991d2e53b" - integrity sha512-LmQ6tnZA3fxctBjJiMzQHDMx7+EbsYojTR2ZFiy5eOYr/pbieQiMl8OHv7xq4ketmBJIKOYBsG4stirbGf6RYg== +"@wdio/repl@9.4.4": + version "9.4.4" + resolved "https://registry.yarnpkg.com/@wdio/repl/-/repl-9.4.4.tgz#47a5393e908990a3ba34da8561cd87b8fd1a2c1a" + integrity sha512-kchPRhoG/pCn4KhHGiL/ocNhdpR8OkD2e6sANlSUZ4TGBVi86YSIEjc2yXUwLacHknC/EnQk/SFnqd4MsNjGGg== dependencies: - "@types/node" "^22.2.0" - "@wdio/logger" "8.38.0" - "@wdio/types" "8.41.0" + "@types/node" "^20.1.0" + +"@wdio/reporter@9.12.6": + version "9.12.6" + resolved "https://registry.yarnpkg.com/@wdio/reporter/-/reporter-9.12.6.tgz#116641dc4e416eddbdc999ba1d509ffdcdfacbcc" + integrity sha512-8cR74tEp5nzC8nP59n4hkDUpoaHUNDbJvP3jD9EfX+ZO4OgPMyJMVTGubo1o88Wuty/Gd2jvOZLHoGD8KlHcKw== + dependencies: + "@types/node" "^20.1.0" + "@wdio/logger" "9.4.4" + "@wdio/types" "9.12.6" diff "^7.0.0" object-inspect "^1.12.0" -"@wdio/runner@8.41.0": - version "8.41.0" - resolved "https://registry.yarnpkg.com/@wdio/runner/-/runner-8.41.0.tgz#fe4063bf55f7812b02fd23dfb01a7c5582e9345a" - integrity sha512-eQ9vZaHIXBLw7XqiKsasiUGjC8PgJawnHFMPKS0i/4ds+5arHo6ciX0s2uhJ3j/EHw3PYvFPCREp/sXetRuNlQ== +"@wdio/runner@9.12.7": + version "9.12.7" + resolved "https://registry.yarnpkg.com/@wdio/runner/-/runner-9.12.7.tgz#f40d720809bab54aa3f1ee7402459c7f37b7e752" + integrity sha512-rslRCDT712SUxCyCpIvLZAnfB5+pMQA3asXfVzLD+CKs5MP5ywh7yW+BnYAT6HvT+Br4MrHt+0rVCJkrsXq4JA== dependencies: - "@types/node" "^22.2.0" - "@wdio/config" "8.41.0" - "@wdio/globals" "8.41.0" - "@wdio/logger" "8.38.0" - "@wdio/types" "8.41.0" - "@wdio/utils" "8.41.0" - deepmerge-ts "^5.1.0" - expect-webdriverio "^4.12.0" - gaze "^1.1.3" - webdriver "8.41.0" - webdriverio "8.41.0" + "@types/node" "^20.11.28" + "@wdio/config" "9.12.6" + "@wdio/dot-reporter" "9.12.6" + "@wdio/globals" "9.12.7" + "@wdio/logger" "9.4.4" + "@wdio/types" "9.12.6" + "@wdio/utils" "9.12.6" + deepmerge-ts "^7.0.3" + expect-webdriverio "^5.1.0" + webdriver "9.12.6" + webdriverio "9.12.7" -"@wdio/shared-store-service@^8.10.5": - version "8.41.0" - resolved "https://registry.yarnpkg.com/@wdio/shared-store-service/-/shared-store-service-8.41.0.tgz#b0042b3f1c73fe4a208f00649310e3e3e5ecb05b" - integrity sha512-mS0FB5p1mEouaudlHJatQ3bR24/Kz3CIDBllytzsY1DFElL4h46YImqVfFjqubN9wkszyDExEMLNMsyCighXYw== +"@wdio/shared-store-service@^9.12.7": + version "9.12.7" + resolved "https://registry.yarnpkg.com/@wdio/shared-store-service/-/shared-store-service-9.12.7.tgz#0efa9a1d4035e10e534ec5d8048812b74d16e259" + integrity sha512-ZfgwS/RcoEmFD74nfx4CQDIzbOAyOCPtXB5fjXZwdVpveWlNkaBb+/x6u17Gup5elQ3zn7AQWB4bQUFvquhXJg== dependencies: "@polka/parse" "^1.0.0-next.0" - "@wdio/logger" "8.38.0" - "@wdio/types" "8.41.0" - got "^12.6.1" + "@wdio/logger" "9.4.4" + "@wdio/types" "9.12.6" polka "^0.5.2" - webdriverio "8.41.0" + webdriverio "9.12.7" -"@wdio/spec-reporter@^8.10.5": - version "8.41.0" - resolved "https://registry.yarnpkg.com/@wdio/spec-reporter/-/spec-reporter-8.41.0.tgz#a6df1877d2059230cb1b3f226379ca1150d54c11" - integrity sha512-JrIp6Chc6e0athApSoMJFLVnIqrNRUcVjhg93BbesaDDq6yuB0vJ+rHSHKlUqCPpVYO7YJLmLP4kKPDcVf+4ow== +"@wdio/spec-reporter@^9.12.6": + version "9.12.6" + resolved "https://registry.yarnpkg.com/@wdio/spec-reporter/-/spec-reporter-9.12.6.tgz#ce14e871c6b8424d22bbcd606c90055591f1116b" + integrity sha512-zLBbp5tuCdwyxCdh7IXdFk7fideP/e/U8GjuWpRMMuGHCrfIdVxShM5CEq1XEv7Lnw4RWkO6Yo00LU4F0Lafmg== dependencies: - "@wdio/reporter" "8.41.0" - "@wdio/types" "8.41.0" + "@wdio/reporter" "9.12.6" + "@wdio/types" "9.12.6" chalk "^5.1.2" easy-table "^1.2.0" - pretty-ms "^7.0.0" + pretty-ms "^9.0.0" "@wdio/types@8.41.0": version "8.41.0" @@ -2380,6 +2756,13 @@ dependencies: "@types/node" "^22.2.0" +"@wdio/types@9.12.6": + version "9.12.6" + resolved "https://registry.yarnpkg.com/@wdio/types/-/types-9.12.6.tgz#62b243fa49c5de76f265882c506f63cb4c6a9622" + integrity sha512-WzZhaN834du9wjqT/Go9qPyB7VkzV2bjr6pr06DrIzxIpJq/snWOv96C6OjJu8nmYNRjV769mAxyggBUf+sUoQ== + dependencies: + "@types/node" "^20.1.0" + "@wdio/utils@8.41.0": version "8.41.0" resolved "https://registry.yarnpkg.com/@wdio/utils/-/utils-8.41.0.tgz#1640e94bc6f71a2d99535d71483c3b54f9732c4a" @@ -2399,6 +2782,25 @@ split2 "^4.2.0" wait-port "^1.0.4" +"@wdio/utils@9.12.6": + version "9.12.6" + resolved "https://registry.yarnpkg.com/@wdio/utils/-/utils-9.12.6.tgz#eb789fccb9b1a0c44b72dd6ad7f63785d407ed6e" + integrity sha512-JfI4CxBRQCOgToJeQNaZLv+wYNIGyJG1gqrpxUOvkrJvBgdOAmIu3dzlcKP/WviXlcxvwLQF2FK8bQVTjHv0fQ== + dependencies: + "@puppeteer/browsers" "^2.2.0" + "@wdio/logger" "9.4.4" + "@wdio/types" "9.12.6" + decamelize "^6.0.0" + deepmerge-ts "^7.0.3" + edgedriver "^6.1.1" + geckodriver "^5.0.0" + get-port "^7.0.0" + import-meta-resolve "^4.0.0" + locate-app "^2.2.24" + safaridriver "^1.0.0" + split2 "^4.2.0" + wait-port "^1.1.0" + "@xmldom/xmldom@^0.8.3", "@xmldom/xmldom@^0.8.7": version "0.8.10" resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.10.tgz#a1337ca426aa61cef9fe15b5b28e340a72f6fa99" @@ -2414,6 +2816,11 @@ resolved "https://registry.yarnpkg.com/@zip.js/zip.js/-/zip.js-2.7.57.tgz#66a7ddc071f3e3aa789af50647c04a525685e1a4" integrity sha512-BtonQ1/jDnGiMed6OkV6rZYW78gLmLswkHOzyMrMb+CAR7CZO8phOHO6c2qw6qb1g1betN7kwEHhhZk30dv+NA== +"@zip.js/zip.js@^2.7.53": + version "2.7.60" + resolved "https://registry.yarnpkg.com/@zip.js/zip.js/-/zip.js-2.7.60.tgz#0de96b93519cad804c82f96faebceda836cb24c0" + integrity sha512-vA3rLyqdxBrVo1FWSsbyoecaqWTV+vgPRf0QKeM7kVDG0r+lHUqd7zQDv1TO9k4BcAoNzNDSNrrel24Mk6addA== + abbrev@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-3.0.0.tgz#c29a6337e167ac61a84b41b80461b29c5c271a27" @@ -2584,7 +2991,7 @@ archiver-utils@^5.0.0, archiver-utils@^5.0.2: normalize-path "^3.0.0" readable-stream "^4.0.0" -archiver@^7.0.0: +archiver@^7.0.0, archiver@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/archiver/-/archiver-7.0.1.tgz#c9d91c350362040b8927379c7aa69c0655122f61" integrity sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ== @@ -2612,7 +3019,7 @@ argparse@^2.0.1: resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== -aria-query@5.3.2, aria-query@^5.0.0: +aria-query@5.3.2, aria-query@^5.0.0, aria-query@^5.3.0: version "5.3.2" resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.2.tgz#93f81a43480e33a338f19163a3d10a50c01dcd59" integrity sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw== @@ -3231,7 +3638,7 @@ chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.1, chalk@^4.1.2: ansi-styles "^4.1.0" supports-color "^7.1.0" -chalk@^5.1.2, chalk@^5.2.0, chalk@^5.3.0: +chalk@^5.0.1, chalk@^5.1.2, chalk@^5.2.0, chalk@^5.3.0: version "5.4.1" resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.4.1.tgz#1b48bf0963ec158dce2aacf69c093ae2dd2092d8" integrity sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w== @@ -3255,6 +3662,35 @@ chartjs-plugin-zoom@~2.0.1: dependencies: hammerjs "^2.0.8" +cheerio-select@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-2.1.0.tgz#4d8673286b8126ca2a8e42740d5e3c4884ae21b4" + integrity sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g== + dependencies: + boolbase "^1.0.0" + css-select "^5.1.0" + css-what "^6.1.0" + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.0.1" + +cheerio@^1.0.0-rc.12: + version "1.0.0" + resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0.tgz#1ede4895a82f26e8af71009f961a9b8cb60d6a81" + integrity sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww== + dependencies: + cheerio-select "^2.1.0" + dom-serializer "^2.0.0" + domhandler "^5.0.3" + domutils "^3.1.0" + encoding-sniffer "^0.2.0" + htmlparser2 "^9.1.0" + parse5 "^7.1.2" + parse5-htmlparser2-tree-adapter "^7.0.0" + parse5-parser-stream "^7.1.2" + undici "^6.19.5" + whatwg-mimetype "^4.0.0" + chokidar@^3.5.1, chokidar@^3.5.3: version "3.6.0" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" @@ -3335,7 +3771,7 @@ cli-cursor@^5.0.0: dependencies: restore-cursor "^5.0.0" -cli-spinners@^2.5.0, cli-spinners@^2.9.0: +cli-spinners@^2.5.0: version "2.9.2" resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.9.2.tgz#1773a8f4b9c4d6ac31563df53b3fc1d79462fe41" integrity sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg== @@ -3766,6 +4202,11 @@ deepmerge-ts@^5.0.0, deepmerge-ts@^5.1.0: resolved "https://registry.yarnpkg.com/deepmerge-ts/-/deepmerge-ts-5.1.0.tgz#c55206cc4c7be2ded89b9c816cf3608884525d7a" integrity sha512-eS8dRJOckyo9maw9Tu5O5RUi/4inFLrnoLkBe3cPfDMx3WZioXtmOew4TXQaxq7Rhl4xjDtR7c6x8nNTxOvbFw== +deepmerge-ts@^7.0.3: + version "7.1.5" + resolved "https://registry.yarnpkg.com/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz#ff818564007f5c150808d2b7b732cac83aa415ab" + integrity sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw== + defaults@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.4.tgz#b0b02062c1e2aa62ff5d9528f0f98baa90978d7a" @@ -4006,6 +4447,21 @@ edgedriver@^5.5.0: node-fetch "^3.3.2" which "^4.0.0" +edgedriver@^6.1.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/edgedriver/-/edgedriver-6.1.1.tgz#9374160a0aa1bab48d3ed21dc7af8f6fe2c3103f" + integrity sha512-/dM/PoBf22Xg3yypMWkmRQrBKEnSyNaZ7wHGCT9+qqT14izwtFT+QvdR89rjNkMfXwW+bSFoqOfbcvM+2Cyc7w== + dependencies: + "@wdio/logger" "^9.1.3" + "@zip.js/zip.js" "^2.7.53" + decamelize "^6.0.0" + edge-paths "^3.0.5" + fast-xml-parser "^4.5.0" + http-proxy-agent "^7.0.2" + https-proxy-agent "^7.0.5" + node-fetch "^3.3.2" + which "^5.0.0" + ejs@^3.1.9: version "3.1.10" resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.10.tgz#69ab8358b14e896f80cc39e62087b88500c3ac3b" @@ -4046,6 +4502,14 @@ emoji-regex@^9.2.2: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== +encoding-sniffer@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz#799569d66d443babe82af18c9f403498365ef1d5" + integrity sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg== + dependencies: + iconv-lite "^0.6.3" + whatwg-encoding "^3.1.1" + encoding@^0.1.13: version "0.1.13" resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" @@ -4244,6 +4708,37 @@ esbuild@0.24.2, esbuild@^0.24.2: "@esbuild/win32-ia32" "0.24.2" "@esbuild/win32-x64" "0.24.2" +esbuild@~0.25.0: + version "0.25.3" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.3.tgz#371f7cb41283e5b2191a96047a7a89562965a285" + integrity sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q== + optionalDependencies: + "@esbuild/aix-ppc64" "0.25.3" + "@esbuild/android-arm" "0.25.3" + "@esbuild/android-arm64" "0.25.3" + "@esbuild/android-x64" "0.25.3" + "@esbuild/darwin-arm64" "0.25.3" + "@esbuild/darwin-x64" "0.25.3" + "@esbuild/freebsd-arm64" "0.25.3" + "@esbuild/freebsd-x64" "0.25.3" + "@esbuild/linux-arm" "0.25.3" + "@esbuild/linux-arm64" "0.25.3" + "@esbuild/linux-ia32" "0.25.3" + "@esbuild/linux-loong64" "0.25.3" + "@esbuild/linux-mips64el" "0.25.3" + "@esbuild/linux-ppc64" "0.25.3" + "@esbuild/linux-riscv64" "0.25.3" + "@esbuild/linux-s390x" "0.25.3" + "@esbuild/linux-x64" "0.25.3" + "@esbuild/netbsd-arm64" "0.25.3" + "@esbuild/netbsd-x64" "0.25.3" + "@esbuild/openbsd-arm64" "0.25.3" + "@esbuild/openbsd-x64" "0.25.3" + "@esbuild/sunos-x64" "0.25.3" + "@esbuild/win32-arm64" "0.25.3" + "@esbuild/win32-ia32" "0.25.3" + "@esbuild/win32-x64" "0.25.3" + escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" @@ -4264,11 +4759,6 @@ escape-string-regexp@^4.0.0: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== -escape-string-regexp@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8" - integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw== - escodegen@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-2.1.0.tgz#ba93bbb7a43986d29d6041f99f5262da773e2e17" @@ -4499,27 +4989,30 @@ evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: md5.js "^1.3.4" safe-buffer "^5.1.1" -execa@^8.0.1: - version "8.0.1" - resolved "https://registry.yarnpkg.com/execa/-/execa-8.0.1.tgz#51f6a5943b580f963c3ca9c6321796db8cc39b8c" - integrity sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg== +execa@^9.2.0: + version "9.5.2" + resolved "https://registry.yarnpkg.com/execa/-/execa-9.5.2.tgz#a4551034ee0795e241025d2f987dab3f4242dff2" + integrity sha512-EHlpxMCpHWSAh1dgS6bVeoLAXGnJNdR93aabr4QCGbzOM73o5XmRfM/e5FUqsw3aagP8S8XEWUWFAxnRBnAF0Q== dependencies: + "@sindresorhus/merge-streams" "^4.0.0" cross-spawn "^7.0.3" - get-stream "^8.0.1" - human-signals "^5.0.0" - is-stream "^3.0.0" - merge-stream "^2.0.0" - npm-run-path "^5.1.0" - onetime "^6.0.0" + figures "^6.1.0" + get-stream "^9.0.0" + human-signals "^8.0.0" + is-plain-obj "^4.1.0" + is-stream "^4.0.1" + npm-run-path "^6.0.0" + pretty-ms "^9.0.0" signal-exit "^4.1.0" - strip-final-newline "^3.0.0" + strip-final-newline "^4.0.0" + yoctocolors "^2.0.0" expand-template@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== -expect-webdriverio@^4.11.2, expect-webdriverio@^4.12.0, expect-webdriverio@^4.2.3: +expect-webdriverio@^4.11.2, expect-webdriverio@^4.2.3: version "4.15.4" resolved "https://registry.yarnpkg.com/expect-webdriverio/-/expect-webdriverio-4.15.4.tgz#0d0c572e6aa6477c5094b0c689106bc8890d1416" integrity sha512-Op1xZoevlv1pohCq7g2Og5Gr3xP2NhY7MQueOApmopVxgweoJ/BqJxyvMNP0A//QsMg8v0WsN/1j81Sx2er9Wg== @@ -4533,6 +5026,16 @@ expect-webdriverio@^4.11.2, expect-webdriverio@^4.12.0, expect-webdriverio@^4.2. "@wdio/logger" "^8.28.0" webdriverio "^8.29.3" +expect-webdriverio@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/expect-webdriverio/-/expect-webdriverio-5.1.0.tgz#53fe0ffdd9226cb0ee28c5f179b3aa1f47436abf" + integrity sha512-4u3q+Dqx/lXNgvCx1gKia4CfS28z1UxGGfVUkoMNbrsBlTBB2fYqXG+4+YtYoerxvp/XPwIb/+89IGEdyPbDXQ== + dependencies: + "@vitest/snapshot" "^2.0.5" + expect "^29.7.0" + jest-matcher-utils "^29.7.0" + lodash.isequal "^4.5.0" + expect@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/expect/-/expect-29.7.0.tgz#578874590dcb3214514084c08115d8aee61e11bc" @@ -4563,7 +5066,7 @@ external-editor@^3.1.0: iconv-lite "^0.4.24" tmp "^0.0.33" -extract-zip@2.0.1: +extract-zip@2.0.1, extract-zip@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a" integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg== @@ -4622,6 +5125,13 @@ fast-xml-parser@^4.4.1: dependencies: strnum "^1.0.5" +fast-xml-parser@^4.5.0: + version "4.5.3" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz#c54d6b35aa0f23dc1ea60b6c884340c006dc6efb" + integrity sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig== + dependencies: + strnum "^1.1.1" + fastest-levenshtein@^1.0.16: version "1.0.16" resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz#210e61b6ff181de91ea9b3d1b84fdedd47e034e5" @@ -4654,13 +5164,12 @@ fetch-blob@^3.1.2, fetch-blob@^3.1.4: node-domexception "^1.0.0" web-streams-polyfill "^3.0.3" -figures@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/figures/-/figures-5.0.0.tgz#126cd055052dea699f8a54e8c9450e6ecfc44d5f" - integrity sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg== +figures@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/figures/-/figures-6.1.0.tgz#935479f51865fa7479f6fa94fc6fc7ac14e62c4a" + integrity sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg== dependencies: - escape-string-regexp "^5.0.0" - is-unicode-supported "^1.2.0" + is-unicode-supported "^2.0.0" file-entry-cache@^10.0.5: version "10.0.6" @@ -4866,12 +5375,19 @@ functions-have-names@^1.2.3: resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== -gaze@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/gaze/-/gaze-1.1.3.tgz#c441733e13b927ac8c0ff0b4c3b033f28812924a" - integrity sha512-BRdNm8hbWzFzWHERTrejLqwHDfS4GibPoq5wjTPIoJHoBtKGPg3xAFfxmM+9ztbXelxcf2hwQcaz1PtmFeue8g== +geckodriver@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/geckodriver/-/geckodriver-5.0.0.tgz#88437f3812075988bb05b5e19dc4aaa42d200577" + integrity sha512-vn7TtQ3b9VMJtVXsyWtQQl1fyBVFhQy7UvJF96kPuuJ0or5THH496AD3eUyaDD11+EqCxH9t6V+EP9soZQk4YQ== dependencies: - globule "^1.0.0" + "@wdio/logger" "^9.1.3" + "@zip.js/zip.js" "^2.7.53" + decamelize "^6.0.0" + http-proxy-agent "^7.0.2" + https-proxy-agent "^7.0.5" + node-fetch "^3.3.2" + tar-fs "^3.0.6" + which "^5.0.0" geckodriver@~4.2.0: version "4.2.1" @@ -4943,10 +5459,13 @@ get-stream@^6.0.1: resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== -get-stream@^8.0.1: - version "8.0.1" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-8.0.1.tgz#def9dfd71742cd7754a7761ed43749a27d02eca2" - integrity sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA== +get-stream@^9.0.0: + version "9.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-9.0.1.tgz#95157d21df8eb90d1647102b63039b1df60ebd27" + integrity sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA== + dependencies: + "@sec-ant/readable-stream" "^0.4.1" + is-stream "^4.0.1" get-symbol-description@^1.1.0: version "1.1.0" @@ -4957,6 +5476,13 @@ get-symbol-description@^1.1.0: es-errors "^1.3.0" get-intrinsic "^1.2.6" +get-tsconfig@^4.7.5: + version "4.10.0" + resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.10.0.tgz#403a682b373a823612475a4c2928c7326fc0f6bb" + integrity sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A== + dependencies: + resolve-pkg-maps "^1.0.0" + get-uri@^6.0.1: version "6.0.4" resolved "https://registry.yarnpkg.com/get-uri/-/get-uri-6.0.4.tgz#6daaee9e12f9759e19e55ba313956883ef50e0a7" @@ -5037,18 +5563,6 @@ glob@^8.1.0: minimatch "^5.0.1" once "^1.3.0" -glob@~7.1.1: - version "7.1.7" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90" - integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - global-modules@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780" @@ -5110,15 +5624,6 @@ globjoin@^0.1.4: resolved "https://registry.yarnpkg.com/globjoin/-/globjoin-0.1.4.tgz#2f4494ac8919e3767c5cbb691e9f463324285d43" integrity sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg== -globule@^1.0.0: - version "1.3.4" - resolved "https://registry.yarnpkg.com/globule/-/globule-1.3.4.tgz#7c11c43056055a75a6e68294453c17f2796170fb" - integrity sha512-OPTIfhMBh7JbBYDpa5b+Q5ptmMWKwcNcFSR/0c6t8V4f3ZAVBEsKNY37QdVqmLRYSMhOUGYrY0QhSoEpzGr/Eg== - dependencies: - glob "~7.1.1" - lodash "^4.17.21" - minimatch "~3.0.2" - gopd@^1.0.1, gopd@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" @@ -5146,7 +5651,7 @@ graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.2, resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== -grapheme-splitter@^1.0.2: +grapheme-splitter@^1.0.2, grapheme-splitter@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e" integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ== @@ -5282,6 +5787,11 @@ html-tags@^3.3.1: resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.3.1.tgz#a04026a18c882e4bba8a01a3d39cfe465d40b5ce" integrity sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ== +htmlfy@^0.6.0: + version "0.6.7" + resolved "https://registry.yarnpkg.com/htmlfy/-/htmlfy-0.6.7.tgz#598172336a75915e41e4abf558656e11fcc4c449" + integrity sha512-r8hRd+oIM10lufovN+zr3VKPTYEIvIwqXGucidh2XQufmiw6sbUXFUFjWlfjo3AnefIDTyzykVzQ8IUVuT1peQ== + htmlparser2@^8.0.0: version "8.0.2" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.2.tgz#f002151705b383e62433b5cf466f5b716edaec21" @@ -5307,7 +5817,7 @@ http-cache-semantics@^4.1.1: resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a" integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ== -http-proxy-agent@^7.0.0: +http-proxy-agent@^7.0.0, http-proxy-agent@^7.0.1, http-proxy-agent@^7.0.2: version "7.0.2" resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e" integrity sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig== @@ -5328,7 +5838,7 @@ https-browserify@^1.0.0: resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" integrity sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg== -https-proxy-agent@7.0.6, https-proxy-agent@^7.0.1, https-proxy-agent@^7.0.2, https-proxy-agent@^7.0.6: +https-proxy-agent@7.0.6, https-proxy-agent@^7.0.1, https-proxy-agent@^7.0.2, https-proxy-agent@^7.0.5, https-proxy-agent@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9" integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== @@ -5344,10 +5854,17 @@ https-proxy-agent@^5.0.1: agent-base "6" debug "4" -human-signals@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-5.0.0.tgz#42665a284f9ae0dade3ba41ebc37eb4b852f3a28" - integrity sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ== +human-signals@^8.0.0: + version "8.0.1" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-8.0.1.tgz#f08bb593b6d1db353933d06156cedec90abe51fb" + integrity sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ== + +iconv-lite@0.6.3, iconv-lite@^0.6.2, iconv-lite@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" iconv-lite@^0.4.24, iconv-lite@~0.4.24: version "0.4.24" @@ -5356,13 +5873,6 @@ iconv-lite@^0.4.24, iconv-lite@~0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" -iconv-lite@^0.6.2: - version "0.6.3" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" - integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== - dependencies: - safer-buffer ">= 2.1.2 < 3.0.0" - ieee754@^1.1.13, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -5446,26 +5956,19 @@ ini@^1.3.2, ini@^1.3.5, ini@~1.3.0: resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== -inquirer@9.2.12: - version "9.2.12" - resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-9.2.12.tgz#0348e9311765b7c93fce143bb1c0ef1ae879b1d7" - integrity sha512-mg3Fh9g2zfuVWJn6lhST0O7x4n03k7G8Tx5nvikJkbq8/CK47WDVm+UznF0G6s5Zi0KcyUisr6DU8T67N5U+1Q== +inquirer@^11.0.1: + version "11.1.0" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-11.1.0.tgz#aa540337ae049a4a0ac6bab07f46f4664c4dab0c" + integrity sha512-CmLAZT65GG/v30c+D2Fk8+ceP6pxD6RL+hIUOWAltCmeyEqWYwqu9v76q03OvjyZ3AB0C1Ala2stn1z/rMqGEw== dependencies: - "@ljharb/through" "^2.3.11" + "@inquirer/core" "^9.2.1" + "@inquirer/prompts" "^6.0.1" + "@inquirer/type" "^2.0.0" + "@types/mute-stream" "^0.0.4" ansi-escapes "^4.3.2" - chalk "^5.3.0" - cli-cursor "^3.1.0" - cli-width "^4.1.0" - external-editor "^3.1.0" - figures "^5.0.0" - lodash "^4.17.21" - mute-stream "1.0.0" - ora "^5.4.1" + mute-stream "^1.0.0" run-async "^3.0.0" rxjs "^7.8.1" - string-width "^4.2.3" - strip-ansi "^6.0.1" - wrap-ansi "^6.2.0" internal-slot@^1.1.0: version "1.1.0" @@ -5732,10 +6235,10 @@ is-stream@^2.0.1: resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== -is-stream@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-3.0.0.tgz#e6bfd7aa6bef69f4f472ce9bb681e3e57b4319ac" - integrity sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA== +is-stream@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-4.0.1.tgz#375cf891e16d2e4baec250b85926cffc14720d9b" + integrity sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A== is-string@^1.0.7, is-string@^1.1.1: version "1.1.1" @@ -5766,10 +6269,10 @@ is-unicode-supported@^0.1.0: resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== -is-unicode-supported@^1.2.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz#d824984b616c292a2e198207d4a609983842f714" - integrity sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ== +is-unicode-supported@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz#09f0ab0de6d3744d48d265ebb98f65d11f2a9b3a" + integrity sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ== is-weakmap@^2.0.2: version "2.0.2" @@ -6129,7 +6632,7 @@ lmdb@3.2.2: "@lmdb/lmdb-linux-x64" "3.2.2" "@lmdb/lmdb-win32-x64" "3.2.2" -locate-app@^2.1.0: +locate-app@^2.1.0, locate-app@^2.2.24: version "2.5.0" resolved "https://registry.yarnpkg.com/locate-app/-/locate-app-2.5.0.tgz#4c1e0e78678bffa8cb3bf363ee2560fb69ebe467" integrity sha512-xIqbzPMBYArJRmPGUZD9CzV9wOqmVtQnaAn3wrj3s6WYW0bQvPI7x+sPYUGmDTYMHefVK//zc6HEYZ1qnxIK+Q== @@ -6204,7 +6707,7 @@ lodash.zip@^4.2.0: resolved "https://registry.yarnpkg.com/lodash.zip/-/lodash.zip-4.2.0.tgz#ec6662e4896408ed4ab6c542a3990b72cc080020" integrity sha512-C7IOaBBK/0gMORRBd8OETNx3kmOkgIWIPvyDpZSCTwUrpYmgZwJkjZeOD8ww4xbOUOs4/attY+pciKvadNfFbg== -lodash@^4.17.15, lodash@^4.17.21: +lodash@^4.17.15: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -6402,11 +6905,6 @@ meow@^13.2.0: resolved "https://registry.yarnpkg.com/meow/-/meow-13.2.0.tgz#6b7d63f913f984063b3cc261b6e8800c4cd3474f" integrity sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA== -merge-stream@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" - integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== - merge2@^1.3.0, merge2@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" @@ -6450,11 +6948,6 @@ mimic-fn@^2.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== -mimic-fn@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-4.0.0.tgz#60a90550d5cb0b239cca65d893b1a53b29871ecc" - integrity sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw== - mimic-function@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/mimic-function/-/mimic-function-5.0.1.tgz#acbe2b3349f99b9deaca7fb70e48b83e94e67076" @@ -6508,13 +7001,6 @@ minimatch@^9.0.0, minimatch@^9.0.4, minimatch@^9.0.5: dependencies: brace-expansion "^2.0.1" -minimatch@~3.0.2: - version "3.0.8" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.8.tgz#5e6a59bd11e2ab0de1cfb843eb2d82e546c321c1" - integrity sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q== - dependencies: - brace-expansion "^1.1.7" - minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.6, minimist@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" @@ -6619,7 +7105,7 @@ mkdirp@^3.0.1: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50" integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg== -mocha@^10.0.0: +mocha@^10.3.0: version "10.8.2" resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.8.2.tgz#8d8342d016ed411b12a429eb731b825f961afb96" integrity sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg== @@ -6696,7 +7182,7 @@ msgpackr@^1.11.2: optionalDependencies: msgpackr-extract "^3.0.2" -mute-stream@1.0.0, mute-stream@^1.0.0: +mute-stream@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-1.0.0.tgz#e31bd9fe62f0aed23520aa4324ea6671531e013e" integrity sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA== @@ -6976,12 +7462,13 @@ npm-run-path@^4.0.1: dependencies: path-key "^3.0.0" -npm-run-path@^5.1.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-5.3.0.tgz#e23353d0ebb9317f174e93417e4a4d82d0249e9f" - integrity sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ== +npm-run-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-6.0.0.tgz#25cfdc4eae04976f3349c0b1afc089052c362537" + integrity sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA== dependencies: path-key "^4.0.0" + unicorn-magic "^0.3.0" nth-check@^2.0.1: version "2.1.1" @@ -7063,13 +7550,6 @@ onetime@^5.1.0: dependencies: mimic-fn "^2.1.0" -onetime@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/onetime/-/onetime-6.0.0.tgz#7c24c18ed1fd2e9bca4bd26806a33613c77d34b4" - integrity sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ== - dependencies: - mimic-fn "^4.0.0" - onetime@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/onetime/-/onetime-7.0.0.tgz#9f16c92d8c9ef5120e3acd9dd9957cceecc1ab60" @@ -7089,7 +7569,7 @@ optionator@^0.9.3: type-check "^0.4.0" word-wrap "^1.2.5" -ora@5.4.1, ora@^5.4.1: +ora@5.4.1: version "5.4.1" resolved "https://registry.yarnpkg.com/ora/-/ora-5.4.1.tgz#1b2678426af4ac4a509008e5e4ac9e9959db9e18" integrity sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ== @@ -7216,6 +7696,20 @@ pac-proxy-agent@^7.0.1: pac-resolver "^7.0.1" socks-proxy-agent "^8.0.5" +pac-proxy-agent@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz#9cfaf33ff25da36f6147a20844230ec92c06e5df" + integrity sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA== + dependencies: + "@tootallnate/quickjs-emscripten" "^0.23.0" + agent-base "^7.1.2" + debug "^4.3.4" + get-uri "^6.0.1" + http-proxy-agent "^7.0.0" + https-proxy-agent "^7.0.6" + pac-resolver "^7.0.1" + socks-proxy-agent "^8.0.5" + pac-resolver@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/pac-resolver/-/pac-resolver-7.0.1.tgz#54675558ea368b64d210fd9c92a640b5f3b8abb6" @@ -7305,10 +7799,10 @@ parse-json@^7.0.0: lines-and-columns "^2.0.3" type-fest "^3.8.0" -parse-ms@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/parse-ms/-/parse-ms-2.1.0.tgz#348565a753d4391fa524029956b172cb7753097d" - integrity sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA== +parse-ms@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/parse-ms/-/parse-ms-4.0.0.tgz#c0c058edd47c2a590151a718990533fd62803df4" + integrity sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw== parse5-html-rewriting-stream@7.0.0: version "7.0.0" @@ -7319,6 +7813,21 @@ parse5-html-rewriting-stream@7.0.0: parse5 "^7.0.0" parse5-sax-parser "^7.0.0" +parse5-htmlparser2-tree-adapter@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz#b5a806548ed893a43e24ccb42fbb78069311e81b" + integrity sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g== + dependencies: + domhandler "^5.0.3" + parse5 "^7.0.0" + +parse5-parser-stream@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz#d7c20eadc37968d272e2c02660fff92dd27e60e1" + integrity sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow== + dependencies: + parse5 "^7.0.0" + parse5-sax-parser@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/parse5-sax-parser/-/parse5-sax-parser-7.0.0.tgz#4c05064254f0488676aca75fb39ca069ec96dee5" @@ -7542,12 +8051,12 @@ pretty-format@^29.7.0: ansi-styles "^5.0.0" react-is "^18.0.0" -pretty-ms@^7.0.0: - version "7.0.1" - resolved "https://registry.yarnpkg.com/pretty-ms/-/pretty-ms-7.0.1.tgz#7d903eaab281f7d8e03c66f867e239dc32fb73e8" - integrity sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q== +pretty-ms@^9.0.0: + version "9.2.0" + resolved "https://registry.yarnpkg.com/pretty-ms/-/pretty-ms-9.2.0.tgz#e14c0aad6493b69ed63114442a84133d7e560ef0" + integrity sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg== dependencies: - parse-ms "^2.1.0" + parse-ms "^4.0.0" primeng@^17: version "17.18.15" @@ -7571,7 +8080,7 @@ process@^0.11.10: resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== -progress@2.0.3: +progress@2.0.3, progress@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== @@ -7603,6 +8112,20 @@ proxy-agent@6.3.1: proxy-from-env "^1.1.0" socks-proxy-agent "^8.0.2" +proxy-agent@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/proxy-agent/-/proxy-agent-6.5.0.tgz#9e49acba8e4ee234aacb539f89ed9c23d02f232d" + integrity sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A== + dependencies: + agent-base "^7.1.2" + debug "^4.3.4" + http-proxy-agent "^7.0.1" + https-proxy-agent "^7.0.6" + lru-cache "^7.14.1" + pac-proxy-agent "^7.1.0" + proxy-from-env "^1.1.0" + socks-proxy-agent "^8.0.5" + proxy-from-env@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" @@ -7678,7 +8201,7 @@ qs@^6.12.3: dependencies: side-channel "^1.1.0" -query-selector-shadow-dom@^1.0.0: +query-selector-shadow-dom@^1.0.0, query-selector-shadow-dom@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz#1c7b0058eff4881ac44f45d8f84ede32e9a2f349" integrity sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw== @@ -7738,16 +8261,16 @@ react-is@^18.0.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== -read-pkg-up@10.0.0: - version "10.0.0" - resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-10.0.0.tgz#0542d21ff1001d2bfff1f6eac8b4d1d1dc486617" - integrity sha512-jgmKiS//w2Zs+YbX039CorlkOp8FIVbSAN8r8GJHDsGlmNPXo+VeHkqAwCiQVTTx5/LwLZTcEw59z3DvcLbr0g== +read-pkg-up@^10.0.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-10.1.0.tgz#2d13ab732d2f05d6e8094167c2112e2ee50644f4" + integrity sha512-aNtBq4jR8NawpKJQldrQcSW9y/d+KWH4v24HWkHljOZ7H0av+YTGANBzRh9A5pw7v/bLVsLVPpOhJ7gHNVy8lA== dependencies: find-up "^6.3.0" - read-pkg "^8.0.0" - type-fest "^3.12.0" + read-pkg "^8.1.0" + type-fest "^4.2.0" -read-pkg@^8.0.0: +read-pkg@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-8.1.0.tgz#6cf560b91d90df68bce658527e7e3eee75f7c4c7" integrity sha512-PORM8AgzXeskHO/WEv312k9U03B8K9JSiWF/8N9sUuFjBa+9SF2u6K7VClzXwDXab51jCd8Nd36CNM+zR97ScQ== @@ -7882,6 +8405,11 @@ resolve-from@^5.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== +resolve-pkg-maps@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f" + integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== + resolve@1.22.10, resolve@^1.17.0, resolve@^1.22.4: version "1.22.10" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39" @@ -7898,7 +8426,7 @@ responselike@^3.0.0: dependencies: lowercase-keys "^3.0.0" -resq@^1.9.1: +resq@^1.11.0, resq@^1.9.1: version "1.11.0" resolved "https://registry.yarnpkg.com/resq/-/resq-1.11.0.tgz#edec8c58be9af800fd628118c0ca8815283de196" integrity sha512-G10EBz+zAAy3zUd/CDoBbXRL6ia9kOo3xRHrMDsHljI0GDkhYlyjwoCx5+3eCC4swi1uCoZQhskuJkj7Gp57Bw== @@ -8069,6 +8597,11 @@ safaridriver@^0.1.0: resolved "https://registry.yarnpkg.com/safaridriver/-/safaridriver-0.1.2.tgz#166571d5881c7d6f884900d92d51ee1309c05aa4" integrity sha512-4R309+gWflJktzPXBQCobbWEHlzC4aK3a+Ov3tz2Ib2aBxiwd11phkdIBH1l0EO22x24CJMUQkpKFumRriCSRg== +safaridriver@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/safaridriver/-/safaridriver-1.0.0.tgz#bccb5edf9df13b75ca08f23081420f3025ae83ed" + integrity sha512-J92IFbskyo7OYB3Dt4aTdyhag1GlInrfbPCmMteb7aBK7PwlnGz1HI0+oyNN97j7pV9DqUAVoVgkNRMrfY47mQ== + safe-array-concat@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.3.tgz#c9e54ec4f603b0bbb8e7e5007a5ee7aecd1538c3" @@ -8285,7 +8818,12 @@ semver@^7.0.0, semver@^7.1.1, semver@^7.3.4, semver@^7.3.5, semver@^7.5.3, semve resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.0.tgz#9c6fe61d0c6f9fa9e26575162ee5a9180361b09c" integrity sha512-DrfFnPzblFmNrIZzg5RzHegbiRWg7KMR7btwi2yjHwx06zsUbO5g613sVwEV7FTwmzJu+Io0lJe2GJ3LxqpvBQ== -serialize-error@^11.0.1: +semver@^7.7.1: + version "7.7.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.1.tgz#abd5098d82b18c6c81f6074ff2647fd3e7220c9f" + integrity sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA== + +serialize-error@^11.0.1, serialize-error@^11.0.3: version "11.0.3" resolved "https://registry.yarnpkg.com/serialize-error/-/serialize-error-11.0.3.tgz#b54f439e15da5b4961340fbbd376b6b04aa52e92" integrity sha512-2G2y++21dhj2R7iHAdd0FIzjGwuKZld+7Pl/bTU6YIkrC2ZMbVUjm+luj6A6V34Rv9XfKJDKpTWu9W4Gse1D9g== @@ -8789,10 +9327,10 @@ strip-bom@^3.0.0: resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" integrity sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA== -strip-final-newline@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-3.0.0.tgz#52894c313fbff318835280aed60ff71ebf12b8fd" - integrity sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw== +strip-final-newline@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-4.0.0.tgz#35a369ec2ac43df356e3edd5dcebb6429aa1fa5c" + integrity sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw== strip-json-comments@3.1.1, strip-json-comments@^3.1.1: version "3.1.1" @@ -8809,6 +9347,11 @@ strnum@^1.0.5: resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.0.5.tgz#5c4e829fe15ad4ff0d20c3db5ac97b73c9b072db" integrity sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA== +strnum@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.1.2.tgz#57bca4fbaa6f271081715dbc9ed7cee5493e28e4" + integrity sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA== + stylelint-config-sass-guidelines@^11.0.0: version "11.1.0" resolved "https://registry.yarnpkg.com/stylelint-config-sass-guidelines/-/stylelint-config-sass-guidelines-11.1.0.tgz#0106f3ec4991a598823b55841bf45fce63268c8c" @@ -8977,7 +9520,7 @@ tar-fs@^2.0.0: pump "^3.0.0" tar-stream "^2.1.4" -tar-fs@^3.0.4: +tar-fs@^3.0.4, tar-fs@^3.0.6, tar-fs@^3.0.8: version "3.0.8" resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-3.0.8.tgz#8f62012537d5ff89252d01e48690dc4ebed33ab7" integrity sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg== @@ -9163,6 +9706,16 @@ tslib@2, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.4.0, t resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== +tsx@^4.7.2: + version "4.19.4" + resolved "https://registry.yarnpkg.com/tsx/-/tsx-4.19.4.tgz#647b4141f4fdd9d773a9b564876773d2846901f4" + integrity sha512-gK5GVzDkJK1SI1zwHf32Mqxf2tSJkNx+eYcNly5+nHvWqXUJYUkWBQtKauoESz3ymezAI++ZwT855x5p5eop+Q== + dependencies: + esbuild "~0.25.0" + get-tsconfig "^4.7.5" + optionalDependencies: + fsevents "~2.3.3" + tty-browserify@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.1.tgz#3f05251ee17904dfd0677546670db9651682b811" @@ -9211,7 +9764,7 @@ type-fest@^2.12.2: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b" integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== -type-fest@^3.12.0, type-fest@^3.8.0: +type-fest@^3.8.0: version "3.13.1" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-3.13.1.tgz#bb744c1f0678bea7543a2d1ec24e83e68e8c8706" integrity sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g== @@ -9327,11 +9880,31 @@ undici-types@~5.26.4: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== +undici-types@~6.19.2: + version "6.19.8" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" + integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== + undici-types@~6.20.0: version "6.20.0" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433" integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg== +undici-types@~6.21.0: + version "6.21.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" + integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== + +undici@^6.19.5, undici@^6.20.1: + version "6.21.2" + resolved "https://registry.yarnpkg.com/undici/-/undici-6.21.2.tgz#49c5884e8f9039c65a89ee9018ef3c8e2f1f4928" + integrity sha512-uROZWze0R0itiAKVPsYhFov9LxrPMHLMEQFszeI2gCN6bnIIZ8twzBCJcN2LJrBBLfrP0t1FW0g+JmKVl8Vk1g== + +unicorn-magic@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/unicorn-magic/-/unicorn-magic-0.3.0.tgz#4efd45c85a69e0dd576d25532fbfa22aa5c8a104" + integrity sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA== + unique-filename@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-4.0.0.tgz#a06534d370e7c977a939cd1d11f7f0ab8f1fed13" @@ -9400,7 +9973,7 @@ url@^0.11.4: punycode "^1.4.1" qs "^6.12.3" -urlpattern-polyfill@10.0.0: +urlpattern-polyfill@10.0.0, urlpattern-polyfill@^10.0.0: version "10.0.0" resolved "https://registry.yarnpkg.com/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz#f0a03a97bfb03cdf33553e5e79a2aadd22cac8ec" integrity sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg== @@ -9593,7 +10166,7 @@ vscode-uri@^3.0.2: resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.8.tgz#1770938d3e72588659a172d0fd4642780083ff9f" integrity sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw== -wait-port@^1.0.4: +wait-port@^1.0.4, wait-port@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/wait-port/-/wait-port-1.1.0.tgz#e5d64ee071118d985e2b658ae7ad32b2ce29b6b5" integrity sha512-3e04qkoN3LxTMLakdqeWth8nih8usyg+sf1Bgdf9wwUkp05iuK1eSY/QpLvscT/+F/gA89+LpUmmgBtesbqI2Q== @@ -9649,6 +10222,22 @@ webdriver@8.41.0: ky "^0.33.0" ws "^8.8.0" +webdriver@9.12.6: + version "9.12.6" + resolved "https://registry.yarnpkg.com/webdriver/-/webdriver-9.12.6.tgz#dc5d5bd7030dcacf66119409771e846a7fa03ae0" + integrity sha512-Alz+JiaVW15b/Qy6zSmJeYXxvmtMIVpEAg7QDfCWqG9miZSKJYWwgWE3xoSrwYn5kTylUszqb17Pb5wyrj7YFw== + dependencies: + "@types/node" "^20.1.0" + "@types/ws" "^8.5.3" + "@wdio/config" "9.12.6" + "@wdio/logger" "9.4.4" + "@wdio/protocols" "9.12.5" + "@wdio/types" "9.12.6" + "@wdio/utils" "9.12.6" + deepmerge-ts "^7.0.3" + undici "^6.20.1" + ws "^8.8.0" + webdriverio@8.41.0, webdriverio@^8.29.3: version "8.41.0" resolved "https://registry.yarnpkg.com/webdriverio/-/webdriverio-8.41.0.tgz#94837f81123bf8f941bd8375c7c7f6a8a2b20fa2" @@ -9680,6 +10269,37 @@ webdriverio@8.41.0, webdriverio@^8.29.3: serialize-error "^11.0.1" webdriver "8.41.0" +webdriverio@9.12.7: + version "9.12.7" + resolved "https://registry.yarnpkg.com/webdriverio/-/webdriverio-9.12.7.tgz#07e5367ff42a883aa1d1902288708d9923c25cb4" + integrity sha512-HxpLQrFuadfE65dqh+Qc2pdvz18FbsdpdiZogy8VUBtxRanijbOsi4cV84ffGXab8Ownzu+bNBJuJjlTBDX00Q== + dependencies: + "@types/node" "^20.11.30" + "@types/sinonjs__fake-timers" "^8.1.5" + "@wdio/config" "9.12.6" + "@wdio/logger" "9.4.4" + "@wdio/protocols" "9.12.5" + "@wdio/repl" "9.4.4" + "@wdio/types" "9.12.6" + "@wdio/utils" "9.12.6" + archiver "^7.0.1" + aria-query "^5.3.0" + cheerio "^1.0.0-rc.12" + css-shorthand-properties "^1.1.1" + css-value "^0.0.1" + grapheme-splitter "^1.0.4" + htmlfy "^0.6.0" + is-plain-obj "^4.1.0" + jszip "^3.10.1" + lodash.clonedeep "^4.5.0" + lodash.zip "^4.2.0" + query-selector-shadow-dom "^1.0.1" + resq "^1.11.0" + rgb2hex "0.2.5" + serialize-error "^11.0.3" + urlpattern-polyfill "^10.0.0" + webdriver "9.12.6" + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" @@ -9693,6 +10313,18 @@ webrtc-polyfill@^1.1.10: node-datachannel "^v0.12.0" node-domexception "^1.0.0" +whatwg-encoding@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz#d0f4ef769905d426e1688f3e34381a99b60b76e5" + integrity sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ== + dependencies: + iconv-lite "0.6.3" + +whatwg-mimetype@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz#bc1bf94a985dc50388d54a9258ac405c3ca2fc0a" + integrity sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg== + whatwg-url@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" @@ -10025,6 +10657,11 @@ yoctocolors-cjs@^2.1.2: resolved "https://registry.yarnpkg.com/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz#f4b905a840a37506813a7acaa28febe97767a242" integrity sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA== +yoctocolors@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/yoctocolors/-/yoctocolors-2.1.1.tgz#e0167474e9fbb9e8b3ecca738deaa61dd12e56fc" + integrity sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ== + zip-stream@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-6.0.1.tgz#e141b930ed60ccaf5d7fa9c8260e0d1748a2bbfb" diff --git a/config/default.yaml b/config/default.yaml index 6e5dae6f6..74bc01f80 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -957,7 +957,7 @@ instance: # Enabling it will allow other administrators to know that you are mainly federating sensitive content # Moreover, the NSFW checkbox on video upload will be automatically checked by default is_nsfw: false - # By default, `do_not_list` or `blur` or `display` NSFW videos + # By default, `do_not_list`, `blur`, `warn` or `display` NSFW videos # Could be overridden per user with a setting default_nsfw_policy: 'do_not_list' diff --git a/config/production.yaml.example b/config/production.yaml.example index e2fd77185..5660ed159 100644 --- a/config/production.yaml.example +++ b/config/production.yaml.example @@ -967,7 +967,7 @@ instance: # Enabling it will allow other administrators to know that you are mainly federating sensitive content # Moreover, the NSFW checkbox on video upload will be automatically checked by default is_nsfw: false - # By default, `do_not_list` or `blur` or `display` NSFW videos + # By default, `do_not_list`, `blur`, `warn` or `display` NSFW videos # Could be overridden per user with a setting default_nsfw_policy: 'do_not_list' diff --git a/packages/models/src/activitypub/objects/common-objects.ts b/packages/models/src/activitypub/objects/common-objects.ts index ad2842384..3b7cbca3f 100644 --- a/packages/models/src/activitypub/objects/common-objects.ts +++ b/packages/models/src/activitypub/objects/common-objects.ts @@ -1,4 +1,5 @@ import { AbusePredefinedReasonsString } from '../../moderation/abuse/abuse-reason.model.js' +import { NSFWFlagString } from '../../videos/nsfw-flag.enum.js' export interface ActivityIdentifierObject { identifier: string @@ -122,11 +123,17 @@ export interface ActivityFlagReasonObject { name: AbusePredefinedReasonsString } +export interface ActivitySensitiveTagObject { + type: 'SensitiveTag' + name: NSFWFlagString +} + export type ActivityTagObject = | ActivityPlaylistSegmentHashesObject | ActivityStreamingPlaylistInfohashesObject | ActivityVideoUrlObject | ActivityHashTagObject + | ActivitySensitiveTagObject | ActivityMentionObject | ActivityBitTorrentUrlObject | ActivityMagnetUrlObject diff --git a/packages/models/src/activitypub/objects/video-object.ts b/packages/models/src/activitypub/objects/video-object.ts index ab44530ab..a19a21432 100644 --- a/packages/models/src/activitypub/objects/video-object.ts +++ b/packages/models/src/activitypub/objects/video-object.ts @@ -24,6 +24,7 @@ export interface VideoObject { views: number sensitive: boolean + summary: string isLiveBroadcast: boolean liveSaveReplay: boolean @@ -80,7 +81,7 @@ export interface VideoObject { export interface ActivityPubStoryboard { type: 'Image' - rel: [ 'storyboard' ] + rel: ['storyboard'] url: { href: string mediaType: string diff --git a/packages/models/src/search/videos-common-query.model.ts b/packages/models/src/search/videos-common-query.model.ts index 81a2f8035..9dbb26393 100644 --- a/packages/models/src/search/videos-common-query.model.ts +++ b/packages/models/src/search/videos-common-query.model.ts @@ -9,6 +9,8 @@ export interface VideosCommonQuery { sort?: string nsfw?: BooleanBothQuery + nsfwFlagsIncluded?: number + nsfwFlagsExcluded?: number isLive?: boolean diff --git a/packages/models/src/users/user-update-me.model.ts b/packages/models/src/users/user-update-me.model.ts index ba9672136..fd7080604 100644 --- a/packages/models/src/users/user-update-me.model.ts +++ b/packages/models/src/users/user-update-me.model.ts @@ -3,7 +3,12 @@ import { NSFWPolicyType } from '../videos/nsfw-policy.type.js' export interface UserUpdateMe { displayName?: string description?: string + nsfwPolicy?: NSFWPolicyType + nsfwFlagsDisplayed?: number + nsfwFlagsHidden?: number + nsfwFlagsBlurred?: number + nsfwFlagsWarned?: number p2pEnabled?: boolean diff --git a/packages/models/src/users/user.model.ts b/packages/models/src/users/user.model.ts index ea03e08ca..6ae5a9d8c 100644 --- a/packages/models/src/users/user.model.ts +++ b/packages/models/src/users/user.model.ts @@ -14,7 +14,12 @@ export interface User { emailVerified: boolean emailPublic: boolean + nsfwPolicy: NSFWPolicyType + nsfwFlagsDisplayed: number + nsfwFlagsHidden: number + nsfwFlagsBlurred: number + nsfwFlagsWarned: number adminFlags?: UserAdminFlagType diff --git a/packages/models/src/videos/import/video-import-create.model.ts b/packages/models/src/videos/import/video-import-create.model.ts index 81618631a..ce3f1437f 100644 --- a/packages/models/src/videos/import/video-import-create.model.ts +++ b/packages/models/src/videos/import/video-import-create.model.ts @@ -1,6 +1,6 @@ -import { VideoUpdate } from '../video-update.model.js' +import { VideoCreateUpdateCommon } from '../video-create-update-common.model.js' -export interface VideoImportCreate extends VideoUpdate { +export interface VideoImportCreate extends VideoCreateUpdateCommon { targetUrl?: string magnetUri?: string torrentfile?: Blob diff --git a/packages/models/src/videos/index.ts b/packages/models/src/videos/index.ts index 24f4cf5c7..5cf14d2e3 100644 --- a/packages/models/src/videos/index.ts +++ b/packages/models/src/videos/index.ts @@ -14,6 +14,7 @@ export * from './transcoding/index.js' export * from './channel-sync/index.js' export * from './chapter/index.js' +export * from './nsfw-flag.enum.js' export * from './nsfw-policy.type.js' export * from './storyboard.model.js' @@ -21,6 +22,7 @@ export * from './thumbnail.type.js' export * from './video-constant.model.js' export * from './video-create.model.js' +export * from './video-create-update-common.model.js' export * from './video-privacy.enum.js' export * from './video-include.enum.js' diff --git a/packages/models/src/videos/nsfw-flag.enum.ts b/packages/models/src/videos/nsfw-flag.enum.ts new file mode 100644 index 000000000..23add25c2 --- /dev/null +++ b/packages/models/src/videos/nsfw-flag.enum.ts @@ -0,0 +1,49 @@ +export const NSFWFlag = { + NONE: 0 << 0, + VIOLENT: 1 << 0, + SHOCKING_DISTURBING: 1 << 1, + EXPLICIT_SEX: 1 << 2 +} as const + +export type NSFWFlagType = typeof NSFWFlag[keyof typeof NSFWFlag] + +export type NSFWFlagString = + | 'violent' + | 'shockingOrDisturbing' + | 'explicitSex' + +const nsfwFlagsToStringMap: { + [key in NSFWFlagString]: NSFWFlagType +} = { + violent: NSFWFlag.VIOLENT, + shockingOrDisturbing: NSFWFlag.SHOCKING_DISTURBING, + explicitSex: NSFWFlag.EXPLICIT_SEX +} as const + +const nsfwFlagsStringToEnumMap: { + [key in NSFWFlagType]: NSFWFlagString +} = { + [NSFWFlag.VIOLENT]: 'violent', + [NSFWFlag.SHOCKING_DISTURBING]: 'shockingOrDisturbing', + [NSFWFlag.EXPLICIT_SEX]: 'explicitSex' +} as const + +export function nsfwFlagToString (nsfwFlag: NSFWFlagType): NSFWFlagString { + return nsfwFlagsStringToEnumMap[nsfwFlag] +} + +export function nsfwFlagsToString (nsfwFlags: number): NSFWFlagString[] { + const acc: NSFWFlagString[] = [] + + for (const [ flagString, flag ] of Object.entries(nsfwFlagsToStringMap)) { + if ((nsfwFlags & flag) === flag) { + acc.push(flagString as NSFWFlagString) + } + } + + return acc +} + +export function stringToNSFWFlag (nsfwFlag: NSFWFlagString): NSFWFlagType { + return nsfwFlagsToStringMap[nsfwFlag] +} diff --git a/packages/models/src/videos/nsfw-policy.type.ts b/packages/models/src/videos/nsfw-policy.type.ts index dc0032a14..a0948dc74 100644 --- a/packages/models/src/videos/nsfw-policy.type.ts +++ b/packages/models/src/videos/nsfw-policy.type.ts @@ -1 +1 @@ -export type NSFWPolicyType = 'do_not_list' | 'blur' | 'display' +export type NSFWPolicyType = 'do_not_list' | 'warn' | 'blur' | 'display' diff --git a/packages/models/src/videos/video-create-update-common.model.ts b/packages/models/src/videos/video-create-update-common.model.ts new file mode 100644 index 000000000..f0b5692d0 --- /dev/null +++ b/packages/models/src/videos/video-create-update-common.model.ts @@ -0,0 +1,32 @@ +import { VideoCommentPolicyType } from './comment/video-comment-policy.enum.js' +import { VideoPrivacyType } from './video-privacy.enum.js' +import { VideoScheduleUpdate } from './video-schedule-update.model.js' + +export interface VideoCreateUpdateCommon { + name?: string + category?: number + licence?: number + language?: string + description?: string + support?: string + privacy?: VideoPrivacyType + tags?: string[] + + // TODO: remove, deprecated in 6.2 + commentsEnabled?: boolean + commentsPolicy?: VideoCommentPolicyType + + downloadEnabled?: boolean + + nsfw?: boolean + nsfwSummary?: string + nsfwFlags?: number + + waitTranscoding?: boolean + channelId?: number + thumbnailfile?: Blob + previewfile?: Blob + scheduleUpdate?: VideoScheduleUpdate + originallyPublishedAt?: Date | string + videoPasswords?: string[] +} diff --git a/packages/models/src/videos/video-create.model.ts b/packages/models/src/videos/video-create.model.ts index 8179a4705..b4223f6c5 100644 --- a/packages/models/src/videos/video-create.model.ts +++ b/packages/models/src/videos/video-create.model.ts @@ -1,32 +1,10 @@ -import { VideoCommentPolicyType } from './comment/video-comment-policy.enum.js' +import { VideoCreateUpdateCommon } from './video-create-update-common.model.js' import { VideoPrivacyType } from './video-privacy.enum.js' -import { VideoScheduleUpdate } from './video-schedule-update.model.js' -export interface VideoCreate { +export interface VideoCreate extends VideoCreateUpdateCommon { name: string channelId: number - - category?: number - licence?: number - language?: string - description?: string - support?: string - nsfw?: boolean - waitTranscoding?: boolean - tags?: string[] - - // TODO: remove, deprecated in 6.2 - commentsEnabled?: boolean - commentsPolicy?: VideoCommentPolicyType - - downloadEnabled?: boolean privacy: VideoPrivacyType - scheduleUpdate?: VideoScheduleUpdate - originallyPublishedAt?: Date | string - videoPasswords?: string[] - - thumbnailfile?: Blob - previewfile?: Blob // Default is true if the feature is enabled by the instance admin generateTranscription?: boolean diff --git a/packages/models/src/videos/video-update.model.ts b/packages/models/src/videos/video-update.model.ts index 5e7580a44..8dec37e30 100644 --- a/packages/models/src/videos/video-update.model.ts +++ b/packages/models/src/videos/video-update.model.ts @@ -1,30 +1,5 @@ -import { VideoCommentPolicyType } from './index.js' -import { VideoPrivacyType } from './video-privacy.enum.js' -import { VideoScheduleUpdate } from './video-schedule-update.model.js' - -export interface VideoUpdate { - name?: string - category?: number - licence?: number - language?: string - description?: string - support?: string - privacy?: VideoPrivacyType - tags?: string[] - - // TODO: remove, deprecated in 6.2 - commentsEnabled?: boolean - commentsPolicy?: VideoCommentPolicyType - - downloadEnabled?: boolean - nsfw?: boolean - waitTranscoding?: boolean - channelId?: number - thumbnailfile?: Blob - previewfile?: Blob - scheduleUpdate?: VideoScheduleUpdate - originallyPublishedAt?: Date | string - videoPasswords?: string[] +import { VideoCreateUpdateCommon } from './video-create-update-common.model.js' +export interface VideoUpdate extends VideoCreateUpdateCommon { pluginData?: any } diff --git a/packages/models/src/videos/video.model.ts b/packages/models/src/videos/video.model.ts index b6262fb32..daf7bdc8b 100644 --- a/packages/models/src/videos/video.model.ts +++ b/packages/models/src/videos/video.model.ts @@ -54,6 +54,8 @@ export interface Video extends Partial { comments: number nsfw: boolean + nsfwFlags: number + nsfwSummary: string account: AccountSummary channel: VideoChannelSummary diff --git a/packages/server-commands/src/search/search-command.ts b/packages/server-commands/src/search/search-command.ts index e766a2861..48e374467 100644 --- a/packages/server-commands/src/search/search-command.ts +++ b/packages/server-commands/src/search/search-command.ts @@ -11,10 +11,11 @@ import { import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' export class SearchCommand extends AbstractCommand { - - searchChannels (options: OverrideCommandOptions & { - search: string - }) { + searchChannels ( + options: OverrideCommandOptions & { + search: string + } + ) { return this.advancedChannelSearch({ ...options, @@ -22,9 +23,11 @@ export class SearchCommand extends AbstractCommand { }) } - advancedChannelSearch (options: OverrideCommandOptions & { - search: VideoChannelsSearchQuery - }) { + advancedChannelSearch ( + options: OverrideCommandOptions & { + search: VideoChannelsSearchQuery + } + ) { const { search } = options const path = '/api/v1/search/video-channels' @@ -38,9 +41,11 @@ export class SearchCommand extends AbstractCommand { }) } - searchPlaylists (options: OverrideCommandOptions & { - search: string - }) { + searchPlaylists ( + options: OverrideCommandOptions & { + search: string + } + ) { return this.advancedPlaylistSearch({ ...options, @@ -48,9 +53,11 @@ export class SearchCommand extends AbstractCommand { }) } - advancedPlaylistSearch (options: OverrideCommandOptions & { - search: VideoPlaylistsSearchQuery - }) { + advancedPlaylistSearch ( + options: OverrideCommandOptions & { + search: VideoPlaylistsSearchQuery + } + ) { const { search } = options const path = '/api/v1/search/video-playlists' @@ -64,10 +71,12 @@ export class SearchCommand extends AbstractCommand { }) } - searchVideos (options: OverrideCommandOptions & { - search: string - sort?: string - }) { + searchVideos ( + options: OverrideCommandOptions & { + search?: string + sort?: string + } + ) { const { search, sort } = options return this.advancedVideoSearch({ @@ -80,9 +89,11 @@ export class SearchCommand extends AbstractCommand { }) } - advancedVideoSearch (options: OverrideCommandOptions & { - search: VideosSearchQuery - }) { + advancedVideoSearch ( + options: OverrideCommandOptions & { + search?: VideosSearchQuery + } + ) { const { search } = options const path = '/api/v1/search/videos' diff --git a/packages/server-commands/src/videos/live-command.ts b/packages/server-commands/src/videos/live-command.ts index f6a7a0e7a..c71e79e2c 100644 --- a/packages/server-commands/src/videos/live-command.ts +++ b/packages/server-commands/src/videos/live-command.ts @@ -106,12 +106,23 @@ export class LiveCommand extends AbstractCommand { async create ( options: OverrideCommandOptions & { - fields: Omit & { thumbnailfile?: string | Blob, previewfile?: string | Blob } + fields: Omit & { + thumbnailfile?: string | Blob + previewfile?: string | Blob + channelId?: number + } } ) { const { fields } = options const path = '/api/v1/videos/live' + let defaultChannelId = 1 + + try { + const { videoChannels } = await this.server.users.getMyInfo({ token: options.token }) + defaultChannelId = videoChannels[0].id + } catch (e) { /* empty */ } + const attaches: any = {} if (fields.thumbnailfile) attaches.thumbnailfile = fields.thumbnailfile if (fields.previewfile) attaches.previewfile = fields.previewfile @@ -121,7 +132,11 @@ export class LiveCommand extends AbstractCommand { path, attaches, - fields: omit(fields, [ 'thumbnailfile', 'previewfile' ]), + fields: { + channelId: defaultChannelId, + + ...omit(fields, [ 'thumbnailfile', 'previewfile' ]) + }, implicitToken: true, defaultExpectedStatus: HttpStatusCode.OK_200 })) diff --git a/packages/server-commands/src/videos/videos-command.ts b/packages/server-commands/src/videos/videos-command.ts index e3b33a2ac..8a0970729 100644 --- a/packages/server-commands/src/videos/videos-command.ts +++ b/packages/server-commands/src/videos/videos-command.ts @@ -649,6 +649,8 @@ export class VideosCommand extends AbstractCommand { 'count', 'sort', 'nsfw', + 'nsfwFlagsExcluded', + 'nsfwFlagsIncluded', 'isLive', 'categoryOneOf', 'licenceOneOf', diff --git a/packages/tests/src/api/check-params/live.ts b/packages/tests/src/api/check-params/live.ts index 6793fb6e8..ff75fe68c 100644 --- a/packages/tests/src/api/check-params/live.ts +++ b/packages/tests/src/api/check-params/live.ts @@ -5,6 +5,7 @@ import { HttpStatusCode, LiveVideoCreate, LiveVideoLatencyMode, + NSFWFlag, VideoCommentPolicy, VideoCreateResult, VideoPrivacy @@ -109,6 +110,32 @@ describe('Test video lives API validator', function () { await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) }) + it('Should fail with a bad NSFW', async function () { + { + const fields = { ...baseCorrectParams, nsfw: false, nsfwFlags: NSFWFlag.EXPLICIT_SEX } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + } + + { + const fields = { ...baseCorrectParams, nsfw: false, nsfwSummary: 'toto' } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + } + + { + const fields = { ...baseCorrectParams, nsfw: true, nsfwFlags: 'toto' as any } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + } + + { + const fields = { ...baseCorrectParams, nsfw: true, nsfwSummary: 't' } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + } + }) + it('Should fail with a bad category', async function () { const fields = { ...baseCorrectParams, category: 125 } @@ -346,7 +373,6 @@ describe('Test video lives API validator', function () { }) describe('When getting live information', function () { - it('Should fail with a bad access token', async function () { await command.get({ token: 'toto', videoId: video.id, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) }) @@ -395,7 +421,6 @@ describe('Test video lives API validator', function () { }) describe('When getting live sessions', function () { - it('Should fail with a bad access token', async function () { await command.listSessions({ token: 'toto', videoId: video.id, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) }) @@ -426,7 +451,6 @@ describe('Test video lives API validator', function () { }) describe('When getting live session of a replay', function () { - it('Should fail with a bad video id', async function () { await command.getReplaySession({ videoId: 'toto', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) }) @@ -441,7 +465,6 @@ describe('Test video lives API validator', function () { }) describe('When updating live information', async function () { - it('Should fail without access token', async function () { await command.update({ token: '', videoId: video.id, fields: {}, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) }) @@ -510,7 +533,6 @@ describe('Test video lives API validator', function () { await command.update({ videoId: video.shortUUID, fields: { saveReplay: false } }) await command.update({ videoId: video.id, fields: { saveReplay: true, replaySettings: { privacy: VideoPrivacy.PUBLIC } } }) - }) it('Should fail to update replay status if replay is not allowed on the instance', async function () { diff --git a/packages/tests/src/api/check-params/my-user.ts b/packages/tests/src/api/check-params/my-user.ts index 4872ba804..84e12fad9 100644 --- a/packages/tests/src/api/check-params/my-user.ts +++ b/packages/tests/src/api/check-params/my-user.ts @@ -1,9 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' -import { MockSmtpServer } from '@tests/shared/mock-servers/index.js' -import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' import { HttpStatusCode, UserRole, VideoCreateResult } from '@peertube/peertube-models' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' import { cleanupTests, createSingleServer, @@ -14,6 +12,8 @@ import { setAccessTokensToServers, UsersCommand } from '@peertube/peertube-server-commands' +import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' +import { MockSmtpServer } from '@tests/shared/mock-servers/index.js' describe('Test my user API validators', function () { const path = '/api/v1/users/' @@ -153,6 +153,36 @@ describe('Test my user API validators', function () { await makePutBodyRequest({ url: server.url, path: path + 'me', token: userToken, fields }) }) + it('Should fail with an invalid NSFW flags attribute', async function () { + for (const key of [ 'nsfwFlagsDisplayed', 'nsfwFlagsHidden', 'nsfwFlagsBlurred', 'nsfwFlagsWarned' ]) { + const fields = { + [key]: 'hello' + } + + await makePutBodyRequest({ url: server.url, path: path + 'me', token: userToken, fields }) + } + }) + + it('Should fail with a conflicted NSFW flags attributes', async function () { + { + const fields = { + nsfwFlagsDisplayed: 1, + nsfwFlagsWarned: 1 + } + + await makePutBodyRequest({ url: server.url, path: path + 'me', token: userToken, fields }) + } + + { + const fields = { + nsfwFlagsHidden: 1, + nsfwFlagsBlurred: 1 + } + + await makePutBodyRequest({ url: server.url, path: path + 'me', token: userToken, fields }) + } + }) + it('Should fail with an invalid autoPlayVideo attribute', async function () { const fields = { autoPlayVideo: -1 @@ -253,7 +283,11 @@ describe('Test my user API validators', function () { const fields = { currentPassword: 'password', password: 'my super password', - nsfwPolicy: 'blur', + nsfwPolicy: 'warn', + nsfwFlagsDisplayed: 0, + nsfwFlagsHidden: 1, + nsfwFlagsWarned: 2, + nsfwFlagsBlurred: 0, autoPlayVideo: false, email: 'super_email@example.com', theme: 'default', @@ -273,7 +307,7 @@ describe('Test my user API validators', function () { it('Should succeed without password change with the correct params', async function () { const fields = { - nsfwPolicy: 'blur', + nsfwPolicy: 'warn', autoPlayVideo: false } diff --git a/packages/tests/src/api/check-params/video-imports.ts b/packages/tests/src/api/check-params/video-imports.ts index 354d2c196..4866bdb75 100644 --- a/packages/tests/src/api/check-params/video-imports.ts +++ b/packages/tests/src/api/check-params/video-imports.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ import { omit } from '@peertube/peertube-core-utils' -import { HttpStatusCode, VideoCommentPolicy, VideoImportCreate, VideoPrivacy } from '@peertube/peertube-models' +import { HttpStatusCode, NSFWFlag, VideoCommentPolicy, VideoImportCreate, VideoPrivacy } from '@peertube/peertube-models' import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' import { PeerTubeServer, @@ -158,6 +158,32 @@ describe('Test video imports API validator', function () { await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) }) + it('Should fail with a bad NSFW', async function () { + { + const fields = { ...baseCorrectParams, nsfw: false, nsfwFlags: NSFWFlag.EXPLICIT_SEX } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + } + + { + const fields = { ...baseCorrectParams, nsfw: false, nsfwSummary: 'toto' } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + } + + { + const fields = { ...baseCorrectParams, nsfw: true, nsfwFlags: 'toto' as any } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + } + + { + const fields = { ...baseCorrectParams, nsfw: true, nsfwSummary: 't' } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + } + }) + it('Should fail with a bad category', async function () { const fields = { ...baseCorrectParams, category: 125 } @@ -302,6 +328,20 @@ describe('Test video imports API validator', function () { fields: baseCorrectParams, expectedStatus: HttpStatusCode.OK_200 }) + + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields: { + ...baseCorrectParams, + + nsfw: true, + nsfwFlags: NSFWFlag.EXPLICIT_SEX, + nsfwSummary: 'toto' + }, + expectedStatus: HttpStatusCode.OK_200 + }) }) it('Should forbid to import http videos', async function () { diff --git a/packages/tests/src/api/check-params/videos-common-filters.ts b/packages/tests/src/api/check-params/videos-common-filters.ts index 3920d9b72..0ca9fee84 100644 --- a/packages/tests/src/api/check-params/videos-common-filters.ts +++ b/packages/tests/src/api/check-params/videos-common-filters.ts @@ -1,14 +1,8 @@ +/* eslint-disable @typescript-eslint/indent */ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { - HttpStatusCode, - HttpStatusCodeType, - UserRole, - VideoInclude, - VideoIncludeType, - VideoPrivacy, - VideoPrivacyType -} from '@peertube/peertube-models' +import { pick } from '@peertube/peertube-core-utils' +import { HttpStatusCode, HttpStatusCodeType, UserRole, VideoInclude, VideoPrivacy, VideosCommonQuery } from '@peertube/peertube-models' import { cleanupTests, createSingleServer, @@ -23,6 +17,13 @@ describe('Test video filters validators', function () { let userAccessToken: string let moderatorAccessToken: string + const validIncludes = [ + VideoInclude.NONE, + VideoInclude.BLOCKED_OWNER, + VideoInclude.NOT_PUBLISHED_STATE | VideoInclude.BLACKLISTED, + VideoInclude.SOURCE + ] + // --------------------------------------------------------------- before(async function () { @@ -43,62 +44,80 @@ describe('Test video filters validators', function () { moderatorAccessToken = await server.login.getAccessToken(moderator) }) - describe('When setting video filters', function () { - const validIncludes = [ - VideoInclude.NONE, - VideoInclude.BLOCKED_OWNER, - VideoInclude.NOT_PUBLISHED_STATE | VideoInclude.BLACKLISTED, - VideoInclude.SOURCE + async function testEndpoints ( + options: + & Pick< + VideosCommonQuery, + | 'isLocal' + | 'include' + | 'privacyOneOf' + | 'autoTagOneOf' + | 'excludeAlreadyWatched' + | 'nsfw' + | 'nsfwFlagsExcluded' + | 'nsfwFlagsIncluded' + > + & { + token?: string + expectedStatus: HttpStatusCodeType + unauthenticatedUser?: boolean + filter?: string + } + ) { + const paths = [ + '/api/v1/video-channels/root_channel/videos', + '/api/v1/accounts/root/videos', + '/api/v1/videos', + '/api/v1/search/videos' ] - async function testEndpoints (options: { - token?: string - isLocal?: boolean - include?: VideoIncludeType - privacyOneOf?: VideoPrivacyType[] - autoTagOneOf?: string[] - expectedStatus: HttpStatusCodeType - excludeAlreadyWatched?: boolean - unauthenticatedUser?: boolean - filter?: string - }) { - const paths = [ - '/api/v1/video-channels/root_channel/videos', - '/api/v1/accounts/root/videos', - '/api/v1/videos', - '/api/v1/search/videos' - ] - - if (options.unauthenticatedUser !== true) { - paths.push('/api/v1/users/me/videos') - } - - for (const path of paths) { - const token = options.unauthenticatedUser - ? undefined - : options.token || server.accessToken - - await makeGetRequest({ - url: server.url, - path, - token, - query: { - isLocal: options.isLocal, - privacyOneOf: options.privacyOneOf, - autoTagOneOf: options.autoTagOneOf, - include: options.include, - excludeAlreadyWatched: options.excludeAlreadyWatched, - filter: options.filter - }, - expectedStatus: options.expectedStatus - }) - } + if (options.unauthenticatedUser !== true) { + paths.push('/api/v1/users/me/videos') } + for (const path of paths) { + const token = options.unauthenticatedUser + ? undefined + : options.token || server.accessToken + + await makeGetRequest({ + url: server.url, + path, + token, + query: pick(options, [ + 'isLocal', + 'privacyOneOf', + 'autoTagOneOf', + 'include', + 'excludeAlreadyWatched', + 'filter', + 'nsfw', + 'nsfwFlagsExcluded', + 'nsfwFlagsIncluded' + ]), + expectedStatus: options.expectedStatus + }) + } + } + + describe('Local filter', function () { it('Should fail with the old filter query param', async function () { await testEndpoints({ filter: 'all-local', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) }) + it('Should succeed on the feeds endpoint with the local filter', async function () { + await makeGetRequest({ + url: server.url, + path: '/feeds/videos.json', + expectedStatus: HttpStatusCode.OK_200, + query: { + isLocal: true + } + }) + }) + }) + + describe('Privacy', function () { it('Should fail with a bad privacyOneOf', async function () { await testEndpoints({ privacyOneOf: [ 'toto' ] as any, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) }) @@ -114,7 +133,9 @@ describe('Test video filters validators', function () { expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) }) + }) + describe('Auto tag', function () { it('Should fail to use autoTagOneOf with a simple user', async function () { await testEndpoints({ autoTagOneOf: [ 'test' ], @@ -130,7 +151,9 @@ describe('Test video filters validators', function () { expectedStatus: HttpStatusCode.OK_200 }) }) + }) + describe('Include', function () { it('Should fail with a bad include', async function () { await testEndpoints({ include: 'toto' as any, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) }) @@ -172,18 +195,9 @@ describe('Test video filters validators', function () { }) } }) + }) - it('Should succeed on the feeds endpoint with the local filter', async function () { - await makeGetRequest({ - url: server.url, - path: '/feeds/videos.json', - expectedStatus: HttpStatusCode.OK_200, - query: { - isLocal: true - } - }) - }) - + describe('Exclude already watched', function () { it('Should fail when trying to exclude already watched videos for an unlogged user', async function () { await testEndpoints({ excludeAlreadyWatched: true, unauthenticatedUser: true, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) }) @@ -193,6 +207,37 @@ describe('Test video filters validators', function () { }) }) + describe('NSFW', function () { + it('Should fail with an invalid nsfw', async function () { + await testEndpoints({ nsfw: 'hello' as any, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with an invalid nsfwFlagsExcluded', async function () { + await testEndpoints({ nsfwFlagsExcluded: 'hello' as any, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with an invalid nsfwFlagsIncluded', async function () { + await testEndpoints({ nsfwFlagsIncluded: 'hello' as any, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with conflicted nsfwFlagsIncluded and nsfwFlagsExcluded', async function () { + await testEndpoints({ + nsfwFlagsIncluded: 1, + nsfwFlagsExcluded: 1, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should succeed with the correct NSFW params', async function () { + await testEndpoints({ + nsfw: 'true', + nsfwFlagsIncluded: 2, + nsfwFlagsExcluded: 1, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + }) + after(async function () { await cleanupTests([ server ]) }) diff --git a/packages/tests/src/api/check-params/videos.ts b/packages/tests/src/api/check-params/videos.ts index ce86caa73..61120c4e2 100644 --- a/packages/tests/src/api/check-params/videos.ts +++ b/packages/tests/src/api/check-params/videos.ts @@ -1,7 +1,14 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ import { omit, randomInt } from '@peertube/peertube-core-utils' -import { HttpStatusCode, PeerTubeProblemDocument, VideoCommentPolicy, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models' +import { + HttpStatusCode, + NSFWFlag, + PeerTubeProblemDocument, + VideoCommentPolicy, + VideoCreateResult, + VideoPrivacy +} from '@peertube/peertube-models' import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' import { PeerTubeServer, @@ -268,6 +275,36 @@ describe('Test videos API validator', function () { await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) }) + it('Should fail with a bad NSFW', async function () { + { + const fields = { ...baseCorrectParams, nsfw: false, nsfwFlags: NSFWFlag.EXPLICIT_SEX } + const attaches = baseCorrectAttaches + + await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) + } + + { + const fields = { ...baseCorrectParams, nsfw: false, nsfwSummary: 'toto' } + const attaches = baseCorrectAttaches + + await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) + } + + { + const fields = { ...baseCorrectParams, nsfw: true, nsfwFlags: 'toto' as any } + const attaches = baseCorrectAttaches + + await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) + } + + { + const fields = { ...baseCorrectParams, nsfw: true, nsfwSummary: 't' } + const attaches = baseCorrectAttaches + + await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) + } + }) + it('Should fail with a bad category', async function () { const fields = { ...baseCorrectParams, category: 125 } const attaches = baseCorrectAttaches @@ -521,7 +558,7 @@ describe('Test videos API validator', function () { await checkUploadVideoParam({ ...baseOptions(), - attributes: { ...fields, ...attaches }, + attributes: { ...fields, ...attaches, nsfw: true, nsfwFlags: NSFWFlag.EXPLICIT_SEX, nsfwSummary: 'toto' }, expectedStatus: HttpStatusCode.OK_200 }) } @@ -584,6 +621,32 @@ describe('Test videos API validator', function () { await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields }) }) + it('Should fail with a bad NSFW', async function () { + { + const fields = { ...baseCorrectParams, nsfw: false, nsfwFlags: NSFWFlag.EXPLICIT_SEX } + + await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields }) + } + + { + const fields = { ...baseCorrectParams, nsfw: false, nsfwSummary: 'toto' } + + await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields }) + } + + { + const fields = { ...baseCorrectParams, nsfw: true, nsfwFlags: 'toto' as any } + + await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields }) + } + + { + const fields = { ...baseCorrectParams, nsfw: true, nsfwSummary: 't' } + + await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields }) + } + }) + it('Should fail with a bad category', async function () { const fields = { ...baseCorrectParams, category: 125 } @@ -761,6 +824,20 @@ describe('Test videos API validator', function () { fields, expectedStatus: HttpStatusCode.NO_CONTENT_204 }) + + await makePutBodyRequest({ + url: server.url, + path: path + video.shortUUID, + token: server.accessToken, + fields: { + ...fields, + + nsfw: true, + nsfwFlags: NSFWFlag.EXPLICIT_SEX, + nsfwSummary: 'toto' + }, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) }) }) diff --git a/packages/tests/src/api/server/config.ts b/packages/tests/src/api/server/config.ts index 1ac0675c0..6e880d519 100644 --- a/packages/tests/src/api/server/config.ts +++ b/packages/tests/src/api/server/config.ts @@ -168,7 +168,7 @@ function buildNewCustomConfig (server: PeerTubeServer): CustomConfig { categories: [ 1, 2 ], isNSFW: true, - defaultNSFWPolicy: 'blur' as 'blur', + defaultNSFWPolicy: 'warn' as 'warn', serverCountry: 'France', support: { @@ -450,7 +450,6 @@ describe('Test config', function () { }) describe('Config keys', function () { - it('Should have the correct default config', async function () { const data = await server.config.getConfig() @@ -612,7 +611,6 @@ describe('Test config', function () { }) describe('Image files', function () { - async function checkAndGetServerImages () { const { instance } = await server.config.getAbout() const htmlConfig = await server.config.getConfig() diff --git a/packages/tests/src/api/videos/video-nsfw.ts b/packages/tests/src/api/videos/video-nsfw.ts index 162d6e2bf..ac6b67ea5 100644 --- a/packages/tests/src/api/videos/video-nsfw.ts +++ b/packages/tests/src/api/videos/video-nsfw.ts @@ -1,58 +1,63 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ +import { + CustomConfig, + NSFWFlag, + NSFWPolicyType, + ResultList, + Video, + VideoPrivacy, + VideosCommonQuery, + VideosOverview +} from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + sendRTMPStream, + setAccessTokensToServers, + stopFfmpeg, + waitJobs +} from '@peertube/peertube-server-commands' +import { FIXTURE_URLS } from '@tests/shared/fixture-urls.js' import { expect } from 'chai' -import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@peertube/peertube-server-commands' -import { BooleanBothQuery, CustomConfig, ResultList, Video, VideosOverview } from '@peertube/peertube-models' function createOverviewRes (overview: VideosOverview) { const videos = overview.categories[0].videos + return { data: videos, total: videos.length } } describe('Test video NSFW policy', function () { - let server: PeerTubeServer + let servers: PeerTubeServer[] let userAccessToken: string let customConfig: CustomConfig - async function getVideosFunctions (token?: string, query: { nsfw?: BooleanBothQuery } = {}) { - const user = await server.users.getMyInfo() + async function getVideosFunctions ( + token?: string, + query: Partial> = {} + ) { + const user = await servers[0].users.getMyInfo() const channelName = user.videoChannels[0].name const accountName = user.account.name + '@' + user.account.host const hasQuery = Object.keys(query).length !== 0 - let promises: Promise>[] - if (token) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - promises = [ - server.search.advancedVideoSearch({ token, search: { search: 'n', sort: '-publishedAt', ...query } }), - server.videos.listWithToken({ token, ...query }), - server.videos.listByAccount({ token, handle: accountName, ...query }), - server.videos.listByChannel({ token, handle: channelName, ...query }) - ] + const promises = [ + token + ? servers[0].videos.listWithToken({ token, ...query }) + : servers[0].videos.list(query), - // Overviews do not support video filters - if (!hasQuery) { - const p = server.overviews.getVideos({ page: 1, token }) - .then(res => createOverviewRes(res)) - promises.push(p) - } - - return Promise.all(promises) - } - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - promises = [ - server.search.searchVideos({ search: 'n', sort: '-publishedAt' }), - server.videos.list(), - server.videos.listByAccount({ token: null, handle: accountName }), - server.videos.listByChannel({ token: null, handle: channelName }) + servers[0].search.advancedVideoSearch({ token: token || null, search: { sort: '-publishedAt', ...query } }), + servers[0].videos.listByAccount({ token: token || null, handle: accountName, ...query }), + servers[0].videos.listByChannel({ token: token || null, handle: channelName, ...query }) ] // Overviews do not support video filters if (!hasQuery) { - const p = server.overviews.getVideos({ page: 1 }) + const p = servers[0].overviews.getVideos({ page: 1, token }) .then(res => createOverviewRes(res)) promises.push(p) @@ -61,47 +66,236 @@ describe('Test video NSFW policy', function () { return Promise.all(promises) } + async function checkHasAll (token?: string) { + for (const body of await getVideosFunctions(token)) { + expect(body.total).to.equal(5) + + const videos = body.data + expect(videos).to.have.lengthOf(5) + + expect(videos.map(v => v.name)).to.have.members([ 'not nsfw', 'nsfw simple', 'nsfw sex', 'import violent', 'live violent' ]) + } + } + before(async function () { this.timeout(50000) - server = await createSingleServer(1) + + servers = await createMultipleServers(2) // Get the access tokens - await setAccessTokensToServers([ server ]) + await setAccessTokensToServers(servers) - { - const attributes = { name: 'nsfw', nsfw: true, category: 1 } - await server.videos.upload({ attributes }) - } + customConfig = await servers[0].config.getCustomConfig() - { - const attributes = { name: 'normal', nsfw: false, category: 1 } - await server.videos.upload({ attributes }) - } + await doubleFollow(servers[0], servers[1]) + }) - customConfig = await server.config.getCustomConfig() + describe('NSFW federation', function () { + let videoUUID: string + + it('Should upload a video without NSFW', async function () { + // Add category to have results in overview + const { uuid } = await servers[0].videos.upload({ attributes: { name: 'not nsfw', nsfw: false, category: 1 } }) + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + + expect(video.nsfw).to.be.false + expect(video.nsfwFlags).to.equal(0) + expect(video.nsfwSummary).to.be.null + } + }) + + it('Should upload a video with NSFW but without NSFW flags and summary', async function () { + const { uuid } = await servers[0].videos.upload({ attributes: { name: 'nsfw simple', nsfw: true, category: 1 } }) + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + + expect(video.nsfw).to.be.true + expect(video.nsfwFlags).to.equal(0) + expect(video.nsfwSummary).to.be.null + } + }) + + it('Should upload a video with NSFW and flags and summary', async function () { + const { uuid } = await servers[0].videos.upload({ + attributes: { + name: 'nsfw sex', + nsfw: true, + nsfwFlags: NSFWFlag.SHOCKING_DISTURBING | NSFWFlag.EXPLICIT_SEX, + nsfwSummary: 'This is a shocking and disturbing video', + category: 1 + } + }) + videoUUID = uuid + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + + expect(video.nsfw).to.be.true + expect(video.nsfwFlags).to.equal(6) + expect(video.nsfwSummary).to.equal('This is a shocking and disturbing video') + } + }) + + it('Should update a NSFW tags of a video', async function () { + { + await servers[0].videos.update({ id: videoUUID, attributes: { nsfw: false } }) + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + + expect(video.nsfw).to.be.false + expect(video.nsfwFlags).to.equal(0) + expect(video.nsfwSummary).to.be.null + } + } + + { + await servers[0].videos.update({ id: videoUUID, attributes: { nsfw: true, nsfwFlags: NSFWFlag.VIOLENT } }) + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + + expect(video.nsfw).to.be.true + expect(video.nsfwFlags).to.equal(NSFWFlag.VIOLENT) + expect(video.nsfwSummary).to.be.null + } + } + + { + await servers[0].videos.update({ id: videoUUID, attributes: { nsfw: true, nsfwFlags: NSFWFlag.EXPLICIT_SEX, nsfwSummary: 'test' } }) + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + + expect(video.nsfw).to.be.true + expect(video.nsfwFlags).to.equal(NSFWFlag.EXPLICIT_SEX) + expect(video.nsfwSummary).to.equal('test') + } + } + }) + + it('Should import a video with NSFW', async function () { + const { video: { uuid } } = await servers[0].videoImports.importVideo({ + attributes: { + targetUrl: FIXTURE_URLS.goodVideo, + name: 'import violent', + nsfw: true, + nsfwFlags: NSFWFlag.VIOLENT, + nsfwSummary: 'This is a violent video', + privacy: VideoPrivacy.PUBLIC, + category: 1 + } + }) + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + + expect(video.nsfw).to.be.true + expect(video.nsfwFlags).to.equal(1) + expect(video.nsfwSummary).to.equal('This is a violent video') + } + }) + + it('Should create a live with a replay with NSFW', async function () { + await servers[0].config.save() + await servers[0].config.enableMinimumTranscoding() + await servers[0].config.enableLive({ allowReplay: true, transcoding: true, resolutions: 'min' }) + + const checkVideo = (video: Video) => { + expect(video.nsfw).to.be.true + expect(video.nsfwFlags).to.equal(1) + expect(video.nsfwSummary).to.equal('This is a violent live') + } + + const { uuid } = await servers[0].live.create({ + fields: { + name: 'live violent', + saveReplay: true, + permanentLive: false, + privacy: VideoPrivacy.PUBLIC, + nsfw: true, + nsfwFlags: NSFWFlag.VIOLENT, + nsfwSummary: 'This is a violent live', + category: 1 + } + }) + const live = await servers[0].live.get({ videoId: uuid }) + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + + checkVideo(video) + } + + const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) + await servers[0].live.waitUntilPublished({ videoId: uuid }) + + await stopFfmpeg(ffmpegCommand) + + await servers[0].live.waitUntilReplacedByReplay({ videoId: uuid }) + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + + checkVideo(video) + } + + await servers[0].config.rollback() + }) }) describe('Instance default NSFW policy', function () { - it('Should display NSFW videos with display default NSFW policy', async function () { - const serverConfig = await server.config.getConfig() + const serverConfig = await servers[0].config.getConfig() expect(serverConfig.instance.defaultNSFWPolicy).to.equal('display') - for (const body of await getVideosFunctions()) { + await checkHasAll() + }) + + it('Should hide some content with nsfwFlagsExcluded', async function () { + for (const body of await getVideosFunctions(undefined, { nsfwFlagsExcluded: NSFWFlag.VIOLENT })) { + expect(body.total).to.equal(3) + + const videos = body.data + expect(videos).to.have.lengthOf(3) + + expect(videos.map(v => v.name)).to.not.have.members([ 'live violent', 'import violent' ]) + } + + for ( + const body of await getVideosFunctions(undefined, { nsfwFlagsExcluded: NSFWFlag.VIOLENT | NSFWFlag.EXPLICIT_SEX }) + ) { expect(body.total).to.equal(2) const videos = body.data expect(videos).to.have.lengthOf(2) - expect(videos[0].name).to.equal('normal') - expect(videos[1].name).to.equal('nsfw') + + expect(videos.map(v => v.name)).to.not.have.members([ 'live violent', 'import violent', 'nsfw sex' ]) } }) it('Should not display NSFW videos with do_not_list default NSFW policy', async function () { customConfig.instance.defaultNSFWPolicy = 'do_not_list' - await server.config.updateCustomConfig({ newCustomConfig: customConfig }) + await servers[0].config.updateCustomConfig({ newCustomConfig: customConfig }) - const serverConfig = await server.config.getConfig() + const serverConfig = await servers[0].config.getConfig() expect(serverConfig.instance.defaultNSFWPolicy).to.equal('do_not_list') for (const body of await getVideosFunctions()) { @@ -109,122 +303,255 @@ describe('Test video NSFW policy', function () { const videos = body.data expect(videos).to.have.lengthOf(1) - expect(videos[0].name).to.equal('normal') + expect(videos[0].name).to.equal('not nsfw') } }) - it('Should display NSFW videos with blur default NSFW policy', async function () { - customConfig.instance.defaultNSFWPolicy = 'blur' - await server.config.updateCustomConfig({ newCustomConfig: customConfig }) - - const serverConfig = await server.config.getConfig() - expect(serverConfig.instance.defaultNSFWPolicy).to.equal('blur') - - for (const body of await getVideosFunctions()) { - expect(body.total).to.equal(2) + it('Should display NSFW videos with nsfwFlagsIncluded', async function () { + for (const body of await getVideosFunctions(undefined, { nsfwFlagsIncluded: NSFWFlag.VIOLENT })) { + expect(body.total).to.equal(3) const videos = body.data - expect(videos).to.have.lengthOf(2) - expect(videos[0].name).to.equal('normal') - expect(videos[1].name).to.equal('nsfw') + expect(videos).to.have.lengthOf(3) + + expect(videos.map(v => v.name)).to.have.members([ 'not nsfw', 'live violent', 'import violent' ]) + } + + for ( + const body of await getVideosFunctions(undefined, { nsfwFlagsIncluded: NSFWFlag.VIOLENT | NSFWFlag.EXPLICIT_SEX }) + ) { + expect(body.total).to.equal(4) + + const videos = body.data + expect(videos).to.have.lengthOf(4) + + expect(videos.map(v => v.name)).to.have.members([ 'not nsfw', 'live violent', 'import violent', 'nsfw sex' ]) + } + }) + + it('Should display NSFW videos with warn/warn_and_blur default NSFW policy', async function () { + for (const policy of [ 'warn', 'blur' ] as NSFWPolicyType[]) { + customConfig.instance.defaultNSFWPolicy = policy + await servers[0].config.updateCustomConfig({ newCustomConfig: customConfig }) + + const serverConfig = await servers[0].config.getConfig() + expect(serverConfig.instance.defaultNSFWPolicy).to.equal(policy) + + await checkHasAll() + } + }) + + it('Should hide some content with nsfwFlagsExcluded', async function () { + for (const body of await getVideosFunctions(undefined, { nsfwFlagsExcluded: NSFWFlag.VIOLENT })) { + expect(body.total).to.equal(3) + + const videos = body.data + expect(videos).to.have.lengthOf(3) + + expect(videos.map(v => v.name)).to.not.have.members([ 'live violent', 'import violent' ]) } }) }) describe('User NSFW policy', function () { + async function checkNSFWFlag (options: { + token: string + check: (results: ResultList