Support broadcast messages

pull/2816/head
Chocobozzz 2020-05-28 11:15:38 +02:00 committed by Chocobozzz
parent 8adf0a767f
commit 72c33e716f
20 changed files with 281 additions and 19 deletions

View File

@ -276,6 +276,58 @@
</div> </div>
</div> </div>
<div class="form-row mt-4"> <!-- broadcast grid -->
<div class="form-group col-12 col-lg-4 col-xl-3">
<div i18n class="inner-form-title">BROADCAST MESSAGE</div>
<div i18n class="inner-for-description">
Display a message on your instance
</div>
</div>
<div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
<ng-container formGroupName="broadcastMessage">
<div class="form-group">
<my-peertube-checkbox
inputName="broadcastMessageEnabled" formControlName="enabled"
i18n-labelText labelText="Enable broadcast message"
></my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox
inputName="broadcastMessageDismissable" formControlName="dismissable"
i18n-labelText labelText="Allow users to dismiss the broadcast message "
></my-peertube-checkbox>
</div>
<div class="form-group">
<label i18n for="broadcastMessageLevel">Broadcast message level</label>
<div class="peertube-select-container">
<select id="broadcastMessageLevel" formControlName="level" class="form-control">
<option value="info">info</option>
<option value="warning">warning</option>
<option value="error">error</option>
</select>
</div>
<div *ngIf="formErrors.broadcastMessage.level" class="form-error">{{ formErrors.broadcastMessage.level }}</div>
</div>
<div class="form-group">
<label i18n for="broadcastMessageMessage">Message</label><my-help helpType="markdownText"></my-help>
<my-markdown-textarea
name="broadcastMessageMessage" formControlName="message" textareaMaxWidth="500px"
[classes]="{ 'input-error': formErrors['broadcastMessage.message'] }"
></my-markdown-textarea>
<div *ngIf="formErrors.broadcastMessage.message" class="form-error">{{ formErrors.broadcastMessage.message }}</div>
</div>
</ng-container>
</div>
</div>
<div class="form-row mt-4"> <!-- new users grid --> <div class="form-row mt-4"> <!-- new users grid -->
<div class="form-group col-12 col-lg-4 col-xl-3"> <div class="form-group col-12 col-lg-4 col-xl-3">
<div i18n class="inner-form-title">NEW USERS</div> <div i18n class="inner-form-title">NEW USERS</div>
@ -801,8 +853,9 @@
<div class="form-row mt-4"> <!-- submit placement block --> <div class="form-row mt-4"> <!-- submit placement block -->
<div class="col-md-7 col-xl-5"></div> <div class="col-md-7 col-xl-5"></div>
<div class="col-md-5 col-xl-5"> <div class="col-md-5 col-xl-5">
<span class="form-error submit-error" i18n *ngIf="!form.valid">It seems like the configuration is invalid. Please search for potential errors in the different tabs.</span>
<input (click)="formValidated()" type="submit" i18n-value value="Update configuration" [disabled]="!form.valid"> <input (click)="formValidated()" type="submit" i18n-value value="Update configuration" [disabled]="!form.valid">
<span class="form-error" i18n *ngIf="!form.valid">It seems like the configuration is invalid. Please search for potential errors in the different tabs.</span>
</div> </div>
</div> </div>
</form> </form>

View File

@ -76,4 +76,8 @@ ngb-tabset:not(.previews) ::ng-deep {
.nav-link { .nav-link {
font-size: 105%; font-size: 105%;
} }
} }
.submit-error {
margin-bottom: 20px;
}

View File

@ -215,6 +215,12 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
indexUrl: this.customConfigValidatorsService.INDEX_URL indexUrl: this.customConfigValidatorsService.INDEX_URL
} }
} }
},
broadcastMessage: {
enabled: null,
level: null,
dismissable: null,
message: null
} }
} }

View File

@ -25,6 +25,16 @@
<div id="content" tabindex="-1" class="main-col" [ngClass]="{ expanded: menu.isMenuDisplayed === false }"> <div id="content" tabindex="-1" class="main-col" [ngClass]="{ expanded: menu.isMenuDisplayed === false }">
<div class="main-row"> <div class="main-row">
<div *ngIf="broadcastMessage" class="broadcast-message alert" [ngClass]="broadcastMessage.class">
<div [innerHTML]="broadcastMessage.message"></div>
<my-global-icon
*ngIf="broadcastMessage.dismissable" (click)="hideBroadcastMessage()"
iconName="cross" role="button" title="Close this message" i18n-title
></my-global-icon>
</div>
<router-outlet></router-outlet> <router-outlet></router-outlet>
</div> </div>
</div> </div>

View File

@ -1,5 +1,7 @@
@import '_variables'; @import '_variables';
@import '_mixins'; @import '_mixins';
@import '~bootstrap/scss/functions';
@import '~bootstrap/scss/variables';
.peertube-container { .peertube-container {
padding-bottom: 20px; padding-bottom: 20px;
@ -88,3 +90,39 @@
flex: 1; flex: 1;
} }
} }
.broadcast-message {
min-height: 50px;
text-align: center;
margin-bottom: 0;
border-radius: 0;
display: grid;
grid-template-columns: 1fr 30px;
column-gap: 10px;
my-global-icon {
justify-self: center;
align-self: center;
cursor: pointer;
width: 20px;
}
@each $color, $value in $theme-colors {
&.alert-#{$color} {
my-global-icon {
@include apply-svg-color(theme-color-level($color, $alert-color-level));
}
}
}
::ng-deep {
p {
font-size: 16px;
}
p:last-child {
margin-bottom: 0;
}
}
}

View File

@ -4,7 +4,7 @@ import { Event, GuardsCheckStart, NavigationEnd, Router, Scroll } from '@angular
import { AuthService, RedirectService, ServerService, ThemeService } from '@app/core' import { AuthService, RedirectService, ServerService, ThemeService } from '@app/core'
import { is18nPath } from '../../../shared/models/i18n' import { is18nPath } from '../../../shared/models/i18n'
import { ScreenService } from '@app/shared/misc/screen.service' import { ScreenService } from '@app/shared/misc/screen.service'
import { filter, map, pairwise } from 'rxjs/operators' import { filter, map, pairwise, first } from 'rxjs/operators'
import { Hotkey, HotkeysService } from 'angular2-hotkeys' import { Hotkey, HotkeysService } from 'angular2-hotkeys'
import { I18n } from '@ngx-translate/i18n-polyfill' import { I18n } from '@ngx-translate/i18n-polyfill'
import { PlatformLocation, ViewportScroller } from '@angular/common' import { PlatformLocation, ViewportScroller } from '@angular/common'
@ -19,6 +19,10 @@ import { ServerConfig, UserRole } from '@shared/models'
import { User } from '@app/shared' import { User } from '@app/shared'
import { InstanceService } from '@app/shared/instance/instance.service' import { InstanceService } from '@app/shared/instance/instance.service'
import { MenuService } from './core/menu/menu.service' import { MenuService } from './core/menu/menu.service'
import { BroadcastMessageLevel } from '@shared/models/server'
import { MarkdownService } from './shared/renderer'
import { concat } from 'rxjs'
import { peertubeLocalStorage } from './shared/misc/peertube-web-storage'
@Component({ @Component({
selector: 'my-app', selector: 'my-app',
@ -26,11 +30,14 @@ import { MenuService } from './core/menu/menu.service'
styleUrls: [ './app.component.scss' ] styleUrls: [ './app.component.scss' ]
}) })
export class AppComponent implements OnInit, AfterViewInit { export class AppComponent implements OnInit, AfterViewInit {
private static BROADCAST_MESSAGE_KEY = 'app-broadcast-message-dismissed'
@ViewChild('welcomeModal') welcomeModal: WelcomeModalComponent @ViewChild('welcomeModal') welcomeModal: WelcomeModalComponent
@ViewChild('instanceConfigWarningModal') instanceConfigWarningModal: InstanceConfigWarningModalComponent @ViewChild('instanceConfigWarningModal') instanceConfigWarningModal: InstanceConfigWarningModalComponent
@ViewChild('customModal') customModal: CustomModalComponent @ViewChild('customModal') customModal: CustomModalComponent
customCSS: SafeHtml customCSS: SafeHtml
broadcastMessage: { message: string, dismissable: boolean, class: string } | null = null
private serverConfig: ServerConfig private serverConfig: ServerConfig
@ -50,6 +57,7 @@ export class AppComponent implements OnInit, AfterViewInit {
private hooks: HooksService, private hooks: HooksService,
private location: PlatformLocation, private location: PlatformLocation,
private modalService: NgbModal, private modalService: NgbModal,
private markdownService: MarkdownService,
public menu: MenuService public menu: MenuService
) { } ) { }
@ -81,6 +89,7 @@ export class AppComponent implements OnInit, AfterViewInit {
this.initRouteEvents() this.initRouteEvents()
this.injectJS() this.injectJS()
this.injectCSS() this.injectCSS()
this.injectBroadcastMessage()
this.initHotkeys() this.initHotkeys()
@ -97,6 +106,12 @@ export class AppComponent implements OnInit, AfterViewInit {
return this.authService.isLoggedIn() return this.authService.isLoggedIn()
} }
hideBroadcastMessage () {
peertubeLocalStorage.setItem(AppComponent.BROADCAST_MESSAGE_KEY, this.serverConfig.broadcastMessage.message)
this.broadcastMessage = null
}
private initRouteEvents () { private initRouteEvents () {
let resetScroll = true let resetScroll = true
const eventsObs = this.router.events const eventsObs = this.router.events
@ -165,6 +180,36 @@ export class AppComponent implements OnInit, AfterViewInit {
).subscribe(() => this.menu.isMenuDisplayed = false) // User clicked on a link in the menu, change the page ).subscribe(() => this.menu.isMenuDisplayed = false) // User clicked on a link in the menu, change the page
} }
private injectBroadcastMessage () {
concat(
this.serverService.getConfig().pipe(first()),
this.serverService.configReloaded
).subscribe(async config => {
this.broadcastMessage = null
const messageConfig = config.broadcastMessage
if (messageConfig.enabled) {
// Already dismissed this message?
if (messageConfig.dismissable && localStorage.getItem(AppComponent.BROADCAST_MESSAGE_KEY) === messageConfig.message) {
return
}
const classes: { [id in BroadcastMessageLevel]: string } = {
info: 'alert-info',
warning: 'alert-warning',
error: 'alert-danger'
}
this.broadcastMessage = {
message: await this.markdownService.completeMarkdownToHTML(messageConfig.message),
dismissable: messageConfig.dismissable,
class: classes[messageConfig.level]
}
}
})
}
private injectJS () { private injectJS () {
// Inject JS // Inject JS
this.serverService.getConfig() this.serverService.getConfig()
@ -182,17 +227,19 @@ export class AppComponent implements OnInit, AfterViewInit {
private injectCSS () { private injectCSS () {
// Inject CSS if modified (admin config settings) // Inject CSS if modified (admin config settings)
this.serverService.configReloaded concat(
.subscribe(() => { this.serverService.getConfig().pipe(first()),
const headStyle = document.querySelector('style.custom-css-style') this.serverService.configReloaded
if (headStyle) headStyle.parentNode.removeChild(headStyle) ).subscribe(config => {
const headStyle = document.querySelector('style.custom-css-style')
if (headStyle) headStyle.parentNode.removeChild(headStyle)
// We test customCSS if the admin removed the css // We test customCSS if the admin removed the css
if (this.customCSS || this.serverConfig.instance.customizations.css) { if (this.customCSS || config.instance.customizations.css) {
const styleTag = '<style>' + this.serverConfig.instance.customizations.css + '</style>' const styleTag = '<style>' + config.instance.customizations.css + '</style>'
this.customCSS = this.domSanitizer.bypassSecurityTrustHtml(styleTag) this.customCSS = this.domSanitizer.bypassSecurityTrustHtml(styleTag)
} }
}) })
} }
private async loadPlugins () { private async loadPlugins () {

View File

@ -21,7 +21,7 @@ export class ServerService {
private static CONFIG_LOCAL_STORAGE_KEY = 'server-config' private static CONFIG_LOCAL_STORAGE_KEY = 'server-config'
configReloaded = new Subject<void>() configReloaded = new Subject<ServerConfig>()
private localeObservable: Observable<any> private localeObservable: Observable<any>
private videoLicensesObservable: Observable<VideoConstant<number>[]> private videoLicensesObservable: Observable<VideoConstant<number>[]>
@ -139,6 +139,12 @@ export class ServerService {
indexUrl: 'https://instances.joinpeertube.org' indexUrl: 'https://instances.joinpeertube.org'
} }
} }
},
broadcastMessage: {
enabled: false,
message: '',
level: 'info',
dismissable: false
} }
} }
@ -162,6 +168,11 @@ export class ServerService {
resetConfig () { resetConfig () {
this.configLoaded = false this.configLoaded = false
this.configReset = true this.configReset = true
// Notify config update
this.getConfig().subscribe(() => {
// empty, to fire a reset config event
})
} }
getConfig () { getConfig () {
@ -175,9 +186,9 @@ export class ServerService {
this.config = config this.config = config
this.configLoaded = true this.configLoaded = true
}), }),
tap(() => { tap(config => {
if (this.configReset) { if (this.configReset) {
this.configReloaded.next() this.configReloaded.next(config)
this.configReset = false this.configReset = false
} }
}), }),

View File

@ -372,3 +372,9 @@ followings:
theme: theme:
default: 'default' default: 'default'
broadcast_message:
enabled: false
message: '' # Support markdown
level: 'info' # 'info' | 'warning' | 'error'
dismissable: false

View File

@ -386,3 +386,9 @@ followings:
theme: theme:
default: 'default' default: 'default'
broadcast_message:
enabled: false
message: '' # Support markdown
level: 'info' # 'info' | 'warning' | 'error'
dismissable: false

View File

@ -172,6 +172,13 @@ async function getConfig (req: express.Request, res: express.Response) {
indexUrl: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_INDEX.INDEX_URL indexUrl: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_INDEX.INDEX_URL
} }
} }
},
broadcastMessage: {
enabled: CONFIG.BROADCAST_MESSAGE.ENABLED,
message: CONFIG.BROADCAST_MESSAGE.MESSAGE,
level: CONFIG.BROADCAST_MESSAGE.LEVEL,
dismissable: CONFIG.BROADCAST_MESSAGE.DISMISSABLE
} }
} }
@ -432,6 +439,12 @@ function customConfig (): CustomConfig {
indexUrl: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_INDEX.INDEX_URL indexUrl: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_INDEX.INDEX_URL
} }
} }
},
broadcastMessage: {
enabled: CONFIG.BROADCAST_MESSAGE.ENABLED,
message: CONFIG.BROADCAST_MESSAGE.MESSAGE,
level: CONFIG.BROADCAST_MESSAGE.LEVEL,
dismissable: CONFIG.BROADCAST_MESSAGE.DISMISSABLE
} }
} }
} }

View File

@ -107,6 +107,10 @@ function checkConfig () {
} }
} }
if (CONFIG.STORAGE.VIDEOS_DIR === CONFIG.STORAGE.REDUNDANCY_DIR) {
logger.warn('Redundancy directory should be different than the videos folder.')
}
// Transcoding // Transcoding
if (CONFIG.TRANSCODING.ENABLED) { if (CONFIG.TRANSCODING.ENABLED) {
if (CONFIG.TRANSCODING.WEBTORRENT.ENABLED === false && CONFIG.TRANSCODING.HLS.ENABLED === false) { if (CONFIG.TRANSCODING.WEBTORRENT.ENABLED === false && CONFIG.TRANSCODING.HLS.ENABLED === false) {
@ -114,8 +118,14 @@ function checkConfig () {
} }
} }
if (CONFIG.STORAGE.VIDEOS_DIR === CONFIG.STORAGE.REDUNDANCY_DIR) { // Broadcast message
logger.warn('Redundancy directory should be different than the videos folder.') if (CONFIG.BROADCAST_MESSAGE.ENABLED) {
const currentLevel = CONFIG.BROADCAST_MESSAGE.LEVEL
const available = [ 'info', 'warning', 'error' ]
if (available.includes(currentLevel) === false) {
return 'Broadcast message level should be ' + available.join(' or ') + ' instead of ' + currentLevel
}
} }
return null return null

View File

@ -6,6 +6,7 @@ import { buildPath, parseBytes, parseDurationToMs, root } from '../helpers/core-
import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type' import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type'
import * as bytes from 'bytes' import * as bytes from 'bytes'
import { VideoRedundancyConfigFilter } from '@shared/models/redundancy/video-redundancy-config-filter.type' import { VideoRedundancyConfigFilter } from '@shared/models/redundancy/video-redundancy-config-filter.type'
import { BroadcastMessageLevel } from '@shared/models/server'
// Use a variable to reload the configuration if we need // Use a variable to reload the configuration if we need
let config: IConfig = require('config') let config: IConfig = require('config')
@ -285,6 +286,12 @@ const CONFIG = {
}, },
THEME: { THEME: {
get DEFAULT () { return config.get<string>('theme.default') } get DEFAULT () { return config.get<string>('theme.default') }
},
BROADCAST_MESSAGE: {
get ENABLED () { return config.get<boolean>('broadcast_message.enabled') },
get MESSAGE () { return config.get<string>('broadcast_message.message') },
get LEVEL () { return config.get<BroadcastMessageLevel>('broadcast_message.level') },
get DISMISSABLE () { return config.get<boolean>('broadcast_message.dismissable') }
} }
} }

View File

@ -55,6 +55,11 @@ const customConfigUpdateValidator = [
body('theme.default').custom(v => isThemeNameValid(v) && isThemeRegistered(v)).withMessage('Should have a valid theme'), body('theme.default').custom(v => isThemeNameValid(v) && isThemeRegistered(v)).withMessage('Should have a valid theme'),
body('broadcastMessage.enabled').isBoolean().withMessage('Should have a valid broadcast message enabled boolean'),
body('broadcastMessage.message').exists().withMessage('Should have a valid broadcast message'),
body('broadcastMessage.level').exists().withMessage('Should have a valid broadcast level'),
body('broadcastMessage.dismissable').exists().withMessage('Should have a valid broadcast dismissable boolean'),
(req: express.Request, res: express.Response, next: express.NextFunction) => { (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking customConfigUpdateValidator parameters', { parameters: req.body }) logger.debug('Checking customConfigUpdateValidator parameters', { parameters: req.body })

View File

@ -133,6 +133,12 @@ describe('Test config API validators', function () {
indexUrl: 'https://index.example.com' indexUrl: 'https://index.example.com'
} }
} }
},
broadcastMessage: {
enabled: true,
dismissable: true,
message: 'super message',
level: 'warning'
} }
} }

View File

@ -87,6 +87,11 @@ function checkInitialConfig (server: ServerInfo, data: CustomConfig) {
expect(data.followings.instance.autoFollowBack.enabled).to.be.false expect(data.followings.instance.autoFollowBack.enabled).to.be.false
expect(data.followings.instance.autoFollowIndex.enabled).to.be.false expect(data.followings.instance.autoFollowIndex.enabled).to.be.false
expect(data.followings.instance.autoFollowIndex.indexUrl).to.equal('') expect(data.followings.instance.autoFollowIndex.indexUrl).to.equal('')
expect(data.broadcastMessage.enabled).to.be.false
expect(data.broadcastMessage.level).to.equal('info')
expect(data.broadcastMessage.message).to.equal('')
expect(data.broadcastMessage.dismissable).to.be.false
} }
function checkUpdatedConfig (data: CustomConfig) { function checkUpdatedConfig (data: CustomConfig) {
@ -155,6 +160,11 @@ function checkUpdatedConfig (data: CustomConfig) {
expect(data.followings.instance.autoFollowBack.enabled).to.be.true expect(data.followings.instance.autoFollowBack.enabled).to.be.true
expect(data.followings.instance.autoFollowIndex.enabled).to.be.true expect(data.followings.instance.autoFollowIndex.enabled).to.be.true
expect(data.followings.instance.autoFollowIndex.indexUrl).to.equal('https://updated.example.com') expect(data.followings.instance.autoFollowIndex.indexUrl).to.equal('https://updated.example.com')
expect(data.broadcastMessage.enabled).to.be.true
expect(data.broadcastMessage.level).to.equal('error')
expect(data.broadcastMessage.message).to.equal('super bad message')
expect(data.broadcastMessage.dismissable).to.be.true
} }
describe('Test config', function () { describe('Test config', function () {
@ -324,6 +334,12 @@ describe('Test config', function () {
indexUrl: 'https://updated.example.com' indexUrl: 'https://updated.example.com'
} }
} }
},
broadcastMessage: {
enabled: true,
level: 'error',
message: 'super bad message',
dismissable: true
} }
} }
await updateCustomConfig(server.url, server.accessToken, newCustomConfig) await updateCustomConfig(server.url, server.accessToken, newCustomConfig)

View File

@ -159,6 +159,12 @@ function updateCustomSubConfig (url: string, token: string, newConfig: DeepParti
enabled: false enabled: false
} }
} }
},
broadcastMessage: {
enabled: true,
level: 'warning',
message: 'hello',
dismissable: true
} }
} }

View File

@ -0,0 +1 @@
export type BroadcastMessageLevel = 'info' | 'warning' | 'error'

View File

@ -1,4 +1,5 @@
import { NSFWPolicyType } from '../videos/nsfw-policy.type' import { NSFWPolicyType } from '../videos/nsfw-policy.type'
import { BroadcastMessageLevel } from './broadcast-message-level.type'
export interface CustomConfig { export interface CustomConfig {
instance: { instance: {
@ -131,4 +132,11 @@ export interface CustomConfig {
} }
} }
} }
broadcastMessage: {
enabled: boolean
message: string
level: BroadcastMessageLevel
dismissable: boolean
}
} }

View File

@ -1,4 +1,5 @@
export * from './about.model' export * from './about.model'
export * from './broadcast-message-level.type'
export * from './contact-form.model' export * from './contact-form.model'
export * from './custom-config.model' export * from './custom-config.model'
export * from './debug.model' export * from './debug.model'

View File

@ -1,5 +1,6 @@
import { NSFWPolicyType } from '../videos/nsfw-policy.type'
import { ClientScript } from '../plugins/plugin-package-json.model' import { ClientScript } from '../plugins/plugin-package-json.model'
import { NSFWPolicyType } from '../videos/nsfw-policy.type'
import { BroadcastMessageLevel } from './broadcast-message-level.type'
export interface ServerConfigPlugin { export interface ServerConfigPlugin {
name: string name: string
@ -161,4 +162,11 @@ export interface ServerConfig {
} }
} }
} }
broadcastMessage: {
enabled: boolean
message: string
level: BroadcastMessageLevel
dismissable: boolean
}
} }