aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app/shared
diff options
context:
space:
mode:
Diffstat (limited to 'client/src/app/shared')
-rw-r--r--client/src/app/shared/actor/actor.model.ts2
-rw-r--r--client/src/app/shared/buttons/action-dropdown.component.html2
-rw-r--r--client/src/app/shared/buttons/action-dropdown.component.scss9
-rw-r--r--client/src/app/shared/buttons/button.component.html2
-rw-r--r--client/src/app/shared/buttons/button.component.scss35
-rw-r--r--client/src/app/shared/buttons/button.component.ts3
-rw-r--r--client/src/app/shared/buttons/delete-button.component.html2
-rw-r--r--client/src/app/shared/buttons/edit-button.component.html2
-rw-r--r--client/src/app/shared/confirm/confirm.component.html26
-rw-r--r--client/src/app/shared/confirm/confirm.component.scss17
-rw-r--r--client/src/app/shared/confirm/confirm.component.ts68
-rw-r--r--client/src/app/shared/forms/form-reactive.ts48
-rw-r--r--client/src/app/shared/forms/form-validators/form-validator.service.ts33
-rw-r--r--client/src/app/shared/forms/form-validators/index.ts1
-rw-r--r--client/src/app/shared/forms/form-validators/instance-validators.service.ts48
-rw-r--r--client/src/app/shared/forms/form-validators/user-validators.service.ts20
-rw-r--r--client/src/app/shared/forms/form-validators/video-abuse-validators.service.ts8
-rw-r--r--client/src/app/shared/forms/form-validators/video-channel-validators.service.ts20
-rw-r--r--client/src/app/shared/forms/markdown-textarea.component.ts2
-rw-r--r--client/src/app/shared/forms/peertube-checkbox.component.html4
-rw-r--r--client/src/app/shared/forms/peertube-checkbox.component.ts5
-rw-r--r--client/src/app/shared/forms/reactive-file.component.ts17
-rw-r--r--client/src/app/shared/icons/global-icon.component.html0
-rw-r--r--client/src/app/shared/icons/global-icon.component.scss4
-rw-r--r--client/src/app/shared/icons/global-icon.component.ts48
-rw-r--r--client/src/app/shared/instance/instance.service.ts36
-rw-r--r--client/src/app/shared/menu/top-menu-dropdown.component.html21
-rw-r--r--client/src/app/shared/menu/top-menu-dropdown.component.scss18
-rw-r--r--client/src/app/shared/menu/top-menu-dropdown.component.ts83
-rw-r--r--client/src/app/shared/misc/help.component.html5
-rw-r--r--client/src/app/shared/misc/help.component.scss43
-rw-r--r--client/src/app/shared/misc/help.component.ts2
-rw-r--r--client/src/app/shared/misc/utils.ts13
-rw-r--r--client/src/app/shared/moderation/user-ban-modal.component.html7
-rw-r--r--client/src/app/shared/moderation/user-ban-modal.component.ts12
-rw-r--r--client/src/app/shared/moderation/user-moderation-dropdown.component.ts130
-rw-r--r--client/src/app/shared/renderer/html-renderer.service.ts35
-rw-r--r--client/src/app/shared/renderer/index.ts3
-rw-r--r--client/src/app/shared/renderer/linkifier.service.ts115
-rw-r--r--client/src/app/shared/renderer/markdown.service.ts79
-rw-r--r--client/src/app/shared/rest/component-pagination.model.ts11
-rw-r--r--client/src/app/shared/rest/rest-extractor.service.ts1
-rw-r--r--client/src/app/shared/shared.module.ts30
-rw-r--r--client/src/app/shared/user-subscription/remote-subscribe.component.ts23
-rw-r--r--client/src/app/shared/user-subscription/subscribe-button.component.ts30
-rw-r--r--client/src/app/shared/users/index.ts1
-rw-r--r--client/src/app/shared/users/user-history.service.ts45
-rw-r--r--client/src/app/shared/users/user-notification.model.ts155
-rw-r--r--client/src/app/shared/users/user-notification.service.ts86
-rw-r--r--client/src/app/shared/users/user-notifications.component.html101
-rw-r--r--client/src/app/shared/users/user-notifications.component.scss51
-rw-r--r--client/src/app/shared/users/user-notifications.component.ts87
-rw-r--r--client/src/app/shared/users/user.model.ts37
-rw-r--r--client/src/app/shared/video-abuse/video-abuse.service.ts4
-rw-r--r--client/src/app/shared/video-blacklist/video-blacklist.service.ts7
-rw-r--r--client/src/app/shared/video/abstract-video-list.html5
-rw-r--r--client/src/app/shared/video/abstract-video-list.scss2
-rw-r--r--client/src/app/shared/video/abstract-video-list.ts7
-rw-r--r--client/src/app/shared/video/feed.component.html9
-rw-r--r--client/src/app/shared/video/feed.component.scss17
-rw-r--r--client/src/app/shared/video/video-miniature.component.scss4
-rw-r--r--client/src/app/shared/video/video.model.ts4
62 files changed, 1463 insertions, 282 deletions
diff --git a/client/src/app/shared/actor/actor.model.ts b/client/src/app/shared/actor/actor.model.ts
index 811afb449..adecec1fc 100644
--- a/client/src/app/shared/actor/actor.model.ts
+++ b/client/src/app/shared/actor/actor.model.ts
@@ -16,7 +16,7 @@ export abstract class Actor implements ActorServer {
16 16
17 avatarUrl: string 17 avatarUrl: string
18 18
19 static GET_ACTOR_AVATAR_URL (actor: { avatar: Avatar }) { 19 static GET_ACTOR_AVATAR_URL (actor: { avatar?: { path: string } }) {
20 const absoluteAPIUrl = getAbsoluteAPIUrl() 20 const absoluteAPIUrl = getAbsoluteAPIUrl()
21 21
22 if (actor && actor.avatar) return absoluteAPIUrl + actor.avatar.path 22 if (actor && actor.avatar) return absoluteAPIUrl + actor.avatar.path
diff --git a/client/src/app/shared/buttons/action-dropdown.component.html b/client/src/app/shared/buttons/action-dropdown.component.html
index 90651f217..114b1d71f 100644
--- a/client/src/app/shared/buttons/action-dropdown.component.html
+++ b/client/src/app/shared/buttons/action-dropdown.component.html
@@ -3,7 +3,7 @@
3 class="action-button" [ngClass]="{ small: buttonSize === 'small', grey: theme === 'grey', orange: theme === 'orange' }" 3 class="action-button" [ngClass]="{ small: buttonSize === 'small', grey: theme === 'grey', orange: theme === 'orange' }"
4 ngbDropdownToggle role="button" 4 ngbDropdownToggle role="button"
5 > 5 >
6 <span *ngIf="!label" class="icon icon-action"></span> 6 <my-global-icon *ngIf="!label" class="more-icon" iconName="more"></my-global-icon>
7 <span *ngIf="label" class="dropdown-toggle">{{ label }}</span> 7 <span *ngIf="label" class="dropdown-toggle">{{ label }}</span>
8 </div> 8 </div>
9 9
diff --git a/client/src/app/shared/buttons/action-dropdown.component.scss b/client/src/app/shared/buttons/action-dropdown.component.scss
index a4fcceeee..985b2ca88 100644
--- a/client/src/app/shared/buttons/action-dropdown.component.scss
+++ b/client/src/app/shared/buttons/action-dropdown.component.scss
@@ -24,14 +24,11 @@
24 } 24 }
25 25
26 &:hover, &:active, &:focus { 26 &:hover, &:active, &:focus {
27 background-color: $grey-color; 27 background-color: $grey-background-color;
28 } 28 }
29 29
30 .icon-action { 30 .more-icon {
31 @include icon(21px); 31 width: 21px;
32
33 background-image: url('../../../assets/images/video/more.svg');
34 top: -1px;
35 } 32 }
36 33
37 &.small { 34 &.small {
diff --git a/client/src/app/shared/buttons/button.component.html b/client/src/app/shared/buttons/button.component.html
index 87a8daccf..b6df67102 100644
--- a/client/src/app/shared/buttons/button.component.html
+++ b/client/src/app/shared/buttons/button.component.html
@@ -1,4 +1,4 @@
1<span class="action-button" [ngClass]="className" [title]="getTitle()"> 1<span class="action-button" [ngClass]="className" [title]="getTitle()">
2 <span class="icon" [ngClass]="icon"></span> 2 <my-global-icon [iconName]="icon"></my-global-icon>
3 <span class="button-label">{{ label }}</span> 3 <span class="button-label">{{ label }}</span>
4</span> 4</span>
diff --git a/client/src/app/shared/buttons/button.component.scss b/client/src/app/shared/buttons/button.component.scss
index 168102f09..04199a2a9 100644
--- a/client/src/app/shared/buttons/button.component.scss
+++ b/client/src/app/shared/buttons/button.component.scss
@@ -3,41 +3,18 @@
3 3
4.action-button { 4.action-button {
5 @include peertube-button-link; 5 @include peertube-button-link;
6 @include button-with-icon(21px, 0, -2px);
6 7
7 font-size: 15px;
8 font-weight: $font-semibold; 8 font-weight: $font-semibold;
9 color: #585858; 9 color: $grey-foreground-color;
10 background-color: #E5E5E5; 10 background-color: $grey-background-color;
11 11
12 &:hover { 12 &:hover {
13 background-color: #EFEFEF; 13 background-color: $grey-background-hover-color;
14 } 14 }
15 15
16 .icon { 16 my-global-icon {
17 @include icon(21px); 17 @include apply-svg-color($grey-foreground-color);
18
19 position: relative;
20 top: -2px;
21
22 &.icon-edit {
23 background-image: url('../../../assets/images/global/edit-grey.svg');
24 }
25
26 &.icon-delete-grey {
27 background-image: url('../../../assets/images/global/delete-grey.svg');
28 }
29
30 &.icon-im-with-her {
31 background-image: url('../../../assets/images/global/im-with-her.svg');
32 }
33
34 &.icon-tick {
35 background-image: url('../../../assets/images/global/tick.svg');
36 }
37
38 &.icon-cross {
39 background-image: url('../../../assets/images/global/cross.svg');
40 }
41 } 18 }
42} 19}
43 20
diff --git a/client/src/app/shared/buttons/button.component.ts b/client/src/app/shared/buttons/button.component.ts
index 1a1162f09..a91e9c7eb 100644
--- a/client/src/app/shared/buttons/button.component.ts
+++ b/client/src/app/shared/buttons/button.component.ts
@@ -1,4 +1,5 @@
1import { Component, Input } from '@angular/core' 1import { Component, Input } from '@angular/core'
2import { GlobalIconName } from '@app/shared/icons/global-icon.component'
2 3
3@Component({ 4@Component({
4 selector: 'my-button', 5 selector: 'my-button',
@@ -9,7 +10,7 @@ import { Component, Input } from '@angular/core'
9export class ButtonComponent { 10export class ButtonComponent {
10 @Input() label = '' 11 @Input() label = ''
11 @Input() className: string = undefined 12 @Input() className: string = undefined
12 @Input() icon: string = undefined 13 @Input() icon: GlobalIconName = undefined
13 @Input() title: string = undefined 14 @Input() title: string = undefined
14 15
15 getTitle () { 16 getTitle () {
diff --git a/client/src/app/shared/buttons/delete-button.component.html b/client/src/app/shared/buttons/delete-button.component.html
index 6c55d8104..4d12a84c0 100644
--- a/client/src/app/shared/buttons/delete-button.component.html
+++ b/client/src/app/shared/buttons/delete-button.component.html
@@ -1,5 +1,5 @@
1<span class="action-button action-button-delete" [title]="getTitle()" role="button"> 1<span class="action-button action-button-delete" [title]="getTitle()" role="button">
2 <span class="icon icon-delete-grey"></span> 2 <my-global-icon iconName="delete"></my-global-icon>
3 3
4 <span class="button-label" *ngIf="label">{{ label }}</span> 4 <span class="button-label" *ngIf="label">{{ label }}</span>
5 <span class="button-label" i18n *ngIf="!label">Delete</span> 5 <span class="button-label" i18n *ngIf="!label">Delete</span>
diff --git a/client/src/app/shared/buttons/edit-button.component.html b/client/src/app/shared/buttons/edit-button.component.html
index cecb780f3..da3addbae 100644
--- a/client/src/app/shared/buttons/edit-button.component.html
+++ b/client/src/app/shared/buttons/edit-button.component.html
@@ -1,5 +1,5 @@
1<a class="action-button action-button-edit" [routerLink]="routerLink" i18n-title title="Edit"> 1<a class="action-button action-button-edit" [routerLink]="routerLink" i18n-title title="Edit">
2 <span class="icon icon-edit"></span> 2 <my-global-icon iconName="edit"></my-global-icon>
3 3
4 <span class="button-label" *ngIf="label">{{ label }}</span> 4 <span class="button-label" *ngIf="label">{{ label }}</span>
5 <span i18n class="button-label" *ngIf="!label">Edit</span> 5 <span i18n class="button-label" *ngIf="!label">Edit</span>
diff --git a/client/src/app/shared/confirm/confirm.component.html b/client/src/app/shared/confirm/confirm.component.html
new file mode 100644
index 000000000..65df1cd4d
--- /dev/null
+++ b/client/src/app/shared/confirm/confirm.component.html
@@ -0,0 +1,26 @@
1<ng-template #confirmModal let-close="close" let-dismiss="dismiss">
2
3 <div class="modal-header">
4 <h4 class="modal-title">{{ title }}</h4>
5
6 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="dismiss()"></my-global-icon>
7 </div>
8
9 <div class="modal-body" >
10 <div [innerHtml]="message"></div>
11
12 <div *ngIf="inputLabel && expectedInputValue" class="form-group">
13 <label for="confirmInput">{{ inputLabel }}</label>
14 <input type="text" id="confirmInput" name="confirmInput" [(ngModel)]="inputValue" />
15 </div>
16 </div>
17
18 <div class="modal-footer inputs">
19 <span i18n class="action-button action-button-cancel" (click)="dismiss()" role="button">Cancel</span>
20
21 <input
22 type="submit" [value]="confirmButtonText" class="action-button-submit" [disabled]="isConfirmationDisabled()"
23 (click)="close()"
24 >
25 </div>
26</ng-template>
diff --git a/client/src/app/shared/confirm/confirm.component.scss b/client/src/app/shared/confirm/confirm.component.scss
new file mode 100644
index 000000000..93dd7926b
--- /dev/null
+++ b/client/src/app/shared/confirm/confirm.component.scss
@@ -0,0 +1,17 @@
1@import '_variables';
2@import '_mixins';
3
4.button {
5 padding: 0 13px;
6}
7
8input[type=text] {
9 @include peertube-input-text(100%);
10 display: block;
11}
12
13.form-group {
14 margin: 20px 0;
15}
16
17
diff --git a/client/src/app/shared/confirm/confirm.component.ts b/client/src/app/shared/confirm/confirm.component.ts
new file mode 100644
index 000000000..63c163da6
--- /dev/null
+++ b/client/src/app/shared/confirm/confirm.component.ts
@@ -0,0 +1,68 @@
1import { Component, ElementRef, HostListener, OnInit, ViewChild } from '@angular/core'
2import { ConfirmService } from '@app/core/confirm/confirm.service'
3import { I18n } from '@ngx-translate/i18n-polyfill'
4import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
5import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
6
7@Component({
8 selector: 'my-confirm',
9 templateUrl: './confirm.component.html',
10 styleUrls: [ './confirm.component.scss' ]
11})
12export class ConfirmComponent implements OnInit {
13 @ViewChild('confirmModal') confirmModal: ElementRef
14
15 title = ''
16 message = ''
17 expectedInputValue = ''
18 inputLabel = ''
19
20 inputValue = ''
21 confirmButtonText = ''
22
23 private openedModal: NgbModalRef
24
25 constructor (
26 private modalService: NgbModal,
27 private confirmService: ConfirmService,
28 private i18n: I18n
29 ) { }
30
31 ngOnInit () {
32 this.confirmService.showConfirm.subscribe(
33 ({ title, message, expectedInputValue, inputLabel, confirmButtonText }) => {
34 this.title = title
35 this.message = message
36
37 this.inputLabel = inputLabel
38 this.expectedInputValue = expectedInputValue
39
40 this.confirmButtonText = confirmButtonText || this.i18n('Confirm')
41
42 this.showModal()
43 }
44 )
45 }
46
47 @HostListener('document:keydown.enter')
48 confirm () {
49 if (this.openedModal) this.openedModal.close()
50 }
51
52 isConfirmationDisabled () {
53 // No input validation
54 if (!this.inputLabel || !this.expectedInputValue) return false
55
56 return this.expectedInputValue !== this.inputValue
57 }
58
59 showModal () {
60 this.inputValue = ''
61
62 this.openedModal = this.modalService.open(this.confirmModal)
63
64 this.openedModal.result
65 .then(() => this.confirmService.confirmResponse.next(true))
66 .catch(() => this.confirmService.confirmResponse.next(false))
67 }
68}
diff --git a/client/src/app/shared/forms/form-reactive.ts b/client/src/app/shared/forms/form-reactive.ts
index 0bb7d25e6..b9873af2c 100644
--- a/client/src/app/shared/forms/form-reactive.ts
+++ b/client/src/app/shared/forms/form-reactive.ts
@@ -1,11 +1,9 @@
1import { FormGroup } from '@angular/forms' 1import { FormGroup } from '@angular/forms'
2import { BuildFormArgument, BuildFormDefaultValues, FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 2import { BuildFormArgument, BuildFormDefaultValues, FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
3 3
4export type FormReactiveErrors = { [ id: string ]: string } 4export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors }
5export type FormReactiveValidationMessages = { 5export type FormReactiveValidationMessages = {
6 [ id: string ]: { 6 [ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages
7 [ name: string ]: string
8 }
9} 7}
10 8
11export abstract class FormReactive { 9export abstract class FormReactive {
@@ -13,7 +11,7 @@ export abstract class FormReactive {
13 protected formChanged = false 11 protected formChanged = false
14 12
15 form: FormGroup 13 form: FormGroup
16 formErrors: FormReactiveErrors 14 formErrors: any // To avoid casting in template because of string | FormReactiveErrors
17 validationMessages: FormReactiveValidationMessages 15 validationMessages: FormReactiveValidationMessages
18 16
19 buildForm (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) { 17 buildForm (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) {
@@ -23,29 +21,49 @@ export abstract class FormReactive {
23 this.formErrors = formErrors 21 this.formErrors = formErrors
24 this.validationMessages = validationMessages 22 this.validationMessages = validationMessages
25 23
26 this.form.valueChanges.subscribe(() => this.onValueChanged(false)) 24 this.form.valueChanges.subscribe(() => this.onValueChanged(this.form, this.formErrors, this.validationMessages, false))
25 }
26
27 protected forceCheck () {
28 return this.onValueChanged(this.form, this.formErrors, this.validationMessages, true)
29 }
30
31 protected check () {
32 return this.onValueChanged(this.form, this.formErrors, this.validationMessages, false)
27 } 33 }
28 34
29 protected onValueChanged (forceCheck = false) { 35 private onValueChanged (
30 for (const field in this.formErrors) { 36 form: FormGroup,
37 formErrors: FormReactiveErrors,
38 validationMessages: FormReactiveValidationMessages,
39 forceCheck = false
40 ) {
41 for (const field of Object.keys(formErrors)) {
42 if (formErrors[field] && typeof formErrors[field] === 'object') {
43 this.onValueChanged(
44 form.controls[field] as FormGroup,
45 formErrors[field] as FormReactiveErrors,
46 validationMessages[field] as FormReactiveValidationMessages,
47 forceCheck
48 )
49 continue
50 }
51
31 // clear previous error message (if any) 52 // clear previous error message (if any)
32 this.formErrors[ field ] = '' 53 formErrors[ field ] = ''
33 const control = this.form.get(field) 54 const control = form.get(field)
34 55
35 if (control.dirty) this.formChanged = true 56 if (control.dirty) this.formChanged = true
36 57
37 // Don't care if dirty on force check 58 // Don't care if dirty on force check
38 const isDirty = control.dirty || forceCheck === true 59 const isDirty = control.dirty || forceCheck === true
39 if (control && isDirty && !control.valid) { 60 if (control && isDirty && !control.valid) {
40 const messages = this.validationMessages[ field ] 61 const messages = validationMessages[ field ]
41 for (const key in control.errors) { 62 for (const key in control.errors) {
42 this.formErrors[ field ] += messages[ key ] + ' ' 63 formErrors[ field ] += messages[ key ] + ' '
43 } 64 }
44 } 65 }
45 } 66 }
46 } 67 }
47 68
48 protected forceCheck () {
49 return this.onValueChanged(true)
50 }
51} 69}
diff --git a/client/src/app/shared/forms/form-validators/form-validator.service.ts b/client/src/app/shared/forms/form-validators/form-validator.service.ts
index 19a8bef25..249fdf119 100644
--- a/client/src/app/shared/forms/form-validators/form-validator.service.ts
+++ b/client/src/app/shared/forms/form-validators/form-validator.service.ts
@@ -7,10 +7,10 @@ export type BuildFormValidator = {
7 MESSAGES: { [ name: string ]: string } 7 MESSAGES: { [ name: string ]: string }
8} 8}
9export type BuildFormArgument = { 9export type BuildFormArgument = {
10 [ id: string ]: BuildFormValidator 10 [ id: string ]: BuildFormValidator | BuildFormArgument
11} 11}
12export type BuildFormDefaultValues = { 12export type BuildFormDefaultValues = {
13 [ name: string ]: string | string[] 13 [ name: string ]: string | string[] | BuildFormDefaultValues
14} 14}
15 15
16@Injectable() 16@Injectable()
@@ -29,7 +29,16 @@ export class FormValidatorService {
29 formErrors[name] = '' 29 formErrors[name] = ''
30 30
31 const field = obj[name] 31 const field = obj[name]
32 if (field && field.MESSAGES) validationMessages[name] = field.MESSAGES 32 if (this.isRecursiveField(field)) {
33 const result = this.buildForm(field as BuildFormArgument, defaultValues[name] as BuildFormDefaultValues)
34 group[name] = result.form
35 formErrors[name] = result.formErrors
36 validationMessages[name] = result.validationMessages
37
38 continue
39 }
40
41 if (field && field.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string }
33 42
34 const defaultValue = defaultValues[name] || '' 43 const defaultValue = defaultValues[name] || ''
35 44
@@ -52,13 +61,27 @@ export class FormValidatorService {
52 formErrors[name] = '' 61 formErrors[name] = ''
53 62
54 const field = obj[name] 63 const field = obj[name]
55 if (field && field.MESSAGES) validationMessages[name] = field.MESSAGES 64 if (this.isRecursiveField(field)) {
65 this.updateForm(
66 form[name],
67 formErrors[name] as FormReactiveErrors,
68 validationMessages[name] as FormReactiveValidationMessages,
69 obj[name] as BuildFormArgument,
70 defaultValues[name] as BuildFormDefaultValues
71 )
72 continue
73 }
74
75 if (field && field.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string }
56 76
57 const defaultValue = defaultValues[name] || '' 77 const defaultValue = defaultValues[name] || ''
58 78
59 if (field && field.VALIDATORS) form.addControl(name, new FormControl(defaultValue, field.VALIDATORS)) 79 if (field && field.VALIDATORS) form.addControl(name, new FormControl(defaultValue, field.VALIDATORS as ValidatorFn[]))
60 else form.addControl(name, new FormControl(defaultValue)) 80 else form.addControl(name, new FormControl(defaultValue))
61 } 81 }
62 } 82 }
63 83
84 private isRecursiveField (field: any) {
85 return field && typeof field === 'object' && !field.MESSAGES && !field.VALIDATORS
86 }
64} 87}
diff --git a/client/src/app/shared/forms/form-validators/index.ts b/client/src/app/shared/forms/form-validators/index.ts
index 74e385b3d..fdcbedb71 100644
--- a/client/src/app/shared/forms/form-validators/index.ts
+++ b/client/src/app/shared/forms/form-validators/index.ts
@@ -1,6 +1,7 @@
1export * from './custom-config-validators.service' 1export * from './custom-config-validators.service'
2export * from './form-validator.service' 2export * from './form-validator.service'
3export * from './host' 3export * from './host'
4export * from './instance-validators.service'
4export * from './login-validators.service' 5export * from './login-validators.service'
5export * from './reset-password-validators.service' 6export * from './reset-password-validators.service'
6export * from './user-validators.service' 7export * from './user-validators.service'
diff --git a/client/src/app/shared/forms/form-validators/instance-validators.service.ts b/client/src/app/shared/forms/form-validators/instance-validators.service.ts
new file mode 100644
index 000000000..5bb852858
--- /dev/null
+++ b/client/src/app/shared/forms/form-validators/instance-validators.service.ts
@@ -0,0 +1,48 @@
1import { I18n } from '@ngx-translate/i18n-polyfill'
2import { Validators } from '@angular/forms'
3import { BuildFormValidator } from '@app/shared'
4import { Injectable } from '@angular/core'
5
6@Injectable()
7export class InstanceValidatorsService {
8 readonly FROM_EMAIL: BuildFormValidator
9 readonly FROM_NAME: BuildFormValidator
10 readonly BODY: BuildFormValidator
11
12 constructor (private i18n: I18n) {
13
14 this.FROM_EMAIL = {
15 VALIDATORS: [ Validators.required, Validators.email ],
16 MESSAGES: {
17 'required': this.i18n('Email is required.'),
18 'email': this.i18n('Email must be valid.')
19 }
20 }
21
22 this.FROM_NAME = {
23 VALIDATORS: [
24 Validators.required,
25 Validators.minLength(1),
26 Validators.maxLength(120)
27 ],
28 MESSAGES: {
29 'required': this.i18n('Your name is required.'),
30 'minlength': this.i18n('Your name must be at least 1 character long.'),
31 'maxlength': this.i18n('Your name cannot be more than 120 characters long.')
32 }
33 }
34
35 this.BODY = {
36 VALIDATORS: [
37 Validators.required,
38 Validators.minLength(3),
39 Validators.maxLength(5000)
40 ],
41 MESSAGES: {
42 'required': this.i18n('A message is required.'),
43 'minlength': this.i18n('The message must be at least 3 characters long.'),
44 'maxlength': this.i18n('The message cannot be more than 5000 characters long.')
45 }
46 }
47 }
48}
diff --git a/client/src/app/shared/forms/form-validators/user-validators.service.ts b/client/src/app/shared/forms/form-validators/user-validators.service.ts
index d14fa4777..6589b2580 100644
--- a/client/src/app/shared/forms/form-validators/user-validators.service.ts
+++ b/client/src/app/shared/forms/form-validators/user-validators.service.ts
@@ -23,15 +23,15 @@ export class UserValidatorsService {
23 this.USER_USERNAME = { 23 this.USER_USERNAME = {
24 VALIDATORS: [ 24 VALIDATORS: [
25 Validators.required, 25 Validators.required,
26 Validators.minLength(3), 26 Validators.minLength(1),
27 Validators.maxLength(20), 27 Validators.maxLength(50),
28 Validators.pattern(/^[a-z0-9._]+$/) 28 Validators.pattern(/^[a-z0-9][a-z0-9._]*$/)
29 ], 29 ],
30 MESSAGES: { 30 MESSAGES: {
31 'required': this.i18n('Username is required.'), 31 'required': this.i18n('Username is required.'),
32 'minlength': this.i18n('Username must be at least 3 characters long.'), 32 'minlength': this.i18n('Username must be at least 1 character long.'),
33 'maxlength': this.i18n('Username cannot be more than 20 characters long.'), 33 'maxlength': this.i18n('Username cannot be more than 50 characters long.'),
34 'pattern': this.i18n('Username should be only lowercase alphanumeric characters.') 34 'pattern': this.i18n('Username should be lowercase alphanumeric; dots and underscores are allowed.')
35 } 35 }
36 } 36 }
37 37
@@ -88,13 +88,13 @@ export class UserValidatorsService {
88 this.USER_DISPLAY_NAME = { 88 this.USER_DISPLAY_NAME = {
89 VALIDATORS: [ 89 VALIDATORS: [
90 Validators.required, 90 Validators.required,
91 Validators.minLength(3), 91 Validators.minLength(1),
92 Validators.maxLength(120) 92 Validators.maxLength(50)
93 ], 93 ],
94 MESSAGES: { 94 MESSAGES: {
95 'required': this.i18n('Display name is required.'), 95 'required': this.i18n('Display name is required.'),
96 'minlength': this.i18n('Display name must be at least 3 characters long.'), 96 'minlength': this.i18n('Display name must be at least 1 character long.'),
97 'maxlength': this.i18n('Display name cannot be more than 120 characters long.') 97 'maxlength': this.i18n('Display name cannot be more than 50 characters long.')
98 } 98 }
99 } 99 }
100 100
diff --git a/client/src/app/shared/forms/form-validators/video-abuse-validators.service.ts b/client/src/app/shared/forms/form-validators/video-abuse-validators.service.ts
index 6e9806611..fcc966b84 100644
--- a/client/src/app/shared/forms/form-validators/video-abuse-validators.service.ts
+++ b/client/src/app/shared/forms/form-validators/video-abuse-validators.service.ts
@@ -10,20 +10,20 @@ export class VideoAbuseValidatorsService {
10 10
11 constructor (private i18n: I18n) { 11 constructor (private i18n: I18n) {
12 this.VIDEO_ABUSE_REASON = { 12 this.VIDEO_ABUSE_REASON = {
13 VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(300) ], 13 VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
14 MESSAGES: { 14 MESSAGES: {
15 'required': this.i18n('Report reason is required.'), 15 'required': this.i18n('Report reason is required.'),
16 'minlength': this.i18n('Report reason must be at least 2 characters long.'), 16 'minlength': this.i18n('Report reason must be at least 2 characters long.'),
17 'maxlength': this.i18n('Report reason cannot be more than 300 characters long.') 17 'maxlength': this.i18n('Report reason cannot be more than 3000 characters long.')
18 } 18 }
19 } 19 }
20 20
21 this.VIDEO_ABUSE_MODERATION_COMMENT = { 21 this.VIDEO_ABUSE_MODERATION_COMMENT = {
22 VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(300) ], 22 VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
23 MESSAGES: { 23 MESSAGES: {
24 'required': this.i18n('Moderation comment is required.'), 24 'required': this.i18n('Moderation comment is required.'),
25 'minlength': this.i18n('Moderation comment must be at least 2 characters long.'), 25 'minlength': this.i18n('Moderation comment must be at least 2 characters long.'),
26 'maxlength': this.i18n('Moderation comment cannot be more than 300 characters long.') 26 'maxlength': this.i18n('Moderation comment cannot be more than 3000 characters long.')
27 } 27 }
28 } 28 }
29 } 29 }
diff --git a/client/src/app/shared/forms/form-validators/video-channel-validators.service.ts b/client/src/app/shared/forms/form-validators/video-channel-validators.service.ts
index f62ff65f7..1c519c10a 100644
--- a/client/src/app/shared/forms/form-validators/video-channel-validators.service.ts
+++ b/client/src/app/shared/forms/form-validators/video-channel-validators.service.ts
@@ -14,28 +14,28 @@ export class VideoChannelValidatorsService {
14 this.VIDEO_CHANNEL_NAME = { 14 this.VIDEO_CHANNEL_NAME = {
15 VALIDATORS: [ 15 VALIDATORS: [
16 Validators.required, 16 Validators.required,
17 Validators.minLength(3), 17 Validators.minLength(1),
18 Validators.maxLength(20), 18 Validators.maxLength(50),
19 Validators.pattern(/^[a-z0-9._]+$/) 19 Validators.pattern(/^[a-z0-9][a-z0-9._]*$/)
20 ], 20 ],
21 MESSAGES: { 21 MESSAGES: {
22 'required': this.i18n('Name is required.'), 22 'required': this.i18n('Name is required.'),
23 'minlength': this.i18n('Name must be at least 3 characters long.'), 23 'minlength': this.i18n('Name must be at least 1 character long.'),
24 'maxlength': this.i18n('Name cannot be more than 20 characters long.'), 24 'maxlength': this.i18n('Name cannot be more than 50 characters long.'),
25 'pattern': this.i18n('Name should be only lowercase alphanumeric characters.') 25 'pattern': this.i18n('Name should be lowercase alphanumeric; dots and underscores are allowed.')
26 } 26 }
27 } 27 }
28 28
29 this.VIDEO_CHANNEL_DISPLAY_NAME = { 29 this.VIDEO_CHANNEL_DISPLAY_NAME = {
30 VALIDATORS: [ 30 VALIDATORS: [
31 Validators.required, 31 Validators.required,
32 Validators.minLength(3), 32 Validators.minLength(1),
33 Validators.maxLength(120) 33 Validators.maxLength(50)
34 ], 34 ],
35 MESSAGES: { 35 MESSAGES: {
36 'required': i18n('Display name is required.'), 36 'required': i18n('Display name is required.'),
37 'minlength': i18n('Display name must be at least 3 characters long.'), 37 'minlength': i18n('Display name must be at least 1 character long.'),
38 'maxlength': i18n('Display name cannot be more than 120 characters long.') 38 'maxlength': i18n('Display name cannot be more than 50 characters long.')
39 } 39 }
40 } 40 }
41 41
diff --git a/client/src/app/shared/forms/markdown-textarea.component.ts b/client/src/app/shared/forms/markdown-textarea.component.ts
index b99169ed2..e87aca0d4 100644
--- a/client/src/app/shared/forms/markdown-textarea.component.ts
+++ b/client/src/app/shared/forms/markdown-textarea.component.ts
@@ -1,10 +1,10 @@
1import { debounceTime, distinctUntilChanged } from 'rxjs/operators' 1import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
2import { Component, forwardRef, Input, OnInit } from '@angular/core' 2import { Component, forwardRef, Input, OnInit } from '@angular/core'
3import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' 3import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
4import { MarkdownService } from '@app/videos/shared'
5import { Subject } from 'rxjs' 4import { Subject } from 'rxjs'
6import truncate from 'lodash-es/truncate' 5import truncate from 'lodash-es/truncate'
7import { ScreenService } from '@app/shared/misc/screen.service' 6import { ScreenService } from '@app/shared/misc/screen.service'
7import { MarkdownService } from '@app/shared/renderer'
8 8
9@Component({ 9@Component({
10 selector: 'my-markdown-textarea', 10 selector: 'my-markdown-textarea',
diff --git a/client/src/app/shared/forms/peertube-checkbox.component.html b/client/src/app/shared/forms/peertube-checkbox.component.html
index fb3006b53..7b8bcf601 100644
--- a/client/src/app/shared/forms/peertube-checkbox.component.html
+++ b/client/src/app/shared/forms/peertube-checkbox.component.html
@@ -1,10 +1,10 @@
1<div class="root"> 1<div class="root">
2 <label class="form-group-checkbox"> 2 <label class="form-group-checkbox">
3 <input type="checkbox" [(ngModel)]="checked" (ngModelChange)="onModelChange()" [id]="inputName" [disabled]="isDisabled" /> 3 <input type="checkbox" [(ngModel)]="checked" (ngModelChange)="onModelChange()" [id]="inputName" [disabled]="disabled" />
4 <span role="checkbox" [attr.aria-checked]="checked"></span> 4 <span role="checkbox" [attr.aria-checked]="checked"></span>
5 <span *ngIf="labelText">{{ labelText }}</span> 5 <span *ngIf="labelText">{{ labelText }}</span>
6 <span *ngIf="labelHtml" [innerHTML]="labelHtml"></span> 6 <span *ngIf="labelHtml" [innerHTML]="labelHtml"></span>
7 </label> 7 </label>
8 8
9 <my-help *ngIf="helpHtml" tooltipPlacement="top" helpType="custom" i18n-customHtml [customHtml]="helpHtml"></my-help> 9 <my-help *ngIf="helpHtml" tooltipPlacement="top" helpType="custom" i18n-customHtml [customHtml]="helpHtml"></my-help>
10</div> \ No newline at end of file 10</div>
diff --git a/client/src/app/shared/forms/peertube-checkbox.component.ts b/client/src/app/shared/forms/peertube-checkbox.component.ts
index bbc9904df..c1a6915e8 100644
--- a/client/src/app/shared/forms/peertube-checkbox.component.ts
+++ b/client/src/app/shared/forms/peertube-checkbox.component.ts
@@ -19,8 +19,7 @@ export class PeertubeCheckboxComponent implements ControlValueAccessor {
19 @Input() labelText: string 19 @Input() labelText: string
20 @Input() labelHtml: string 20 @Input() labelHtml: string
21 @Input() helpHtml: string 21 @Input() helpHtml: string
22 22 @Input() disabled = false
23 isDisabled = false
24 23
25 propagateChange = (_: any) => { /* empty */ } 24 propagateChange = (_: any) => { /* empty */ }
26 25
@@ -41,6 +40,6 @@ export class PeertubeCheckboxComponent implements ControlValueAccessor {
41 } 40 }
42 41
43 setDisabledState (isDisabled: boolean) { 42 setDisabledState (isDisabled: boolean) {
44 this.isDisabled = isDisabled 43 this.disabled = isDisabled
45 } 44 }
46} 45}
diff --git a/client/src/app/shared/forms/reactive-file.component.ts b/client/src/app/shared/forms/reactive-file.component.ts
index 8d22aa56c..f60c38e8d 100644
--- a/client/src/app/shared/forms/reactive-file.component.ts
+++ b/client/src/app/shared/forms/reactive-file.component.ts
@@ -1,6 +1,6 @@
1import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core' 1import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core'
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' 2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { NotificationsService } from 'angular2-notifications' 3import { Notifier } from '@app/core'
4import { I18n } from '@ngx-translate/i18n-polyfill' 4import { I18n } from '@ngx-translate/i18n-polyfill'
5 5
6@Component({ 6@Component({
@@ -30,7 +30,7 @@ export class ReactiveFileComponent implements OnInit, ControlValueAccessor {
30 private file: File 30 private file: File
31 31
32 constructor ( 32 constructor (
33 private notificationsService: NotificationsService, 33 private notifier: Notifier,
34 private i18n: I18n 34 private i18n: I18n
35 ) {} 35 ) {}
36 36
@@ -49,7 +49,18 @@ export class ReactiveFileComponent implements OnInit, ControlValueAccessor {
49 const [ file ] = event.target.files 49 const [ file ] = event.target.files
50 50
51 if (file.size > this.maxFileSize) { 51 if (file.size > this.maxFileSize) {
52 this.notificationsService.error(this.i18n('Error'), this.i18n('This file is too large.')) 52 this.notifier.error(this.i18n('This file is too large.'))
53 return
54 }
55
56 const extension = '.' + file.name.split('.').pop()
57 if (this.extensions.includes(extension) === false) {
58 const message = this.i18n(
59 'PeerTube cannot handle this kind of file. Accepted extensions are {{extensions}}.',
60 { extensions: this.allowedExtensionsMessage }
61 )
62 this.notifier.error(message)
63
53 return 64 return
54 } 65 }
55 66
diff --git a/client/src/app/shared/icons/global-icon.component.html b/client/src/app/shared/icons/global-icon.component.html
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/client/src/app/shared/icons/global-icon.component.html
diff --git a/client/src/app/shared/icons/global-icon.component.scss b/client/src/app/shared/icons/global-icon.component.scss
new file mode 100644
index 000000000..6805fb6f7
--- /dev/null
+++ b/client/src/app/shared/icons/global-icon.component.scss
@@ -0,0 +1,4 @@
1/deep/ svg {
2 width: inherit;
3 height: inherit;
4}
diff --git a/client/src/app/shared/icons/global-icon.component.ts b/client/src/app/shared/icons/global-icon.component.ts
new file mode 100644
index 000000000..e8ada0324
--- /dev/null
+++ b/client/src/app/shared/icons/global-icon.component.ts
@@ -0,0 +1,48 @@
1import { Component, ElementRef, Input, OnInit } from '@angular/core'
2
3const icons = {
4 'add': require('../../../assets/images/global/add.html'),
5 'syndication': require('../../../assets/images/global/syndication.html'),
6 'help': require('../../../assets/images/global/help.html'),
7 'sparkle': require('../../../assets/images/global/sparkle.html'),
8 'alert': require('../../../assets/images/global/alert.html'),
9 'cloud-error': require('../../../assets/images/global/cloud-error.html'),
10 'user-add': require('../../../assets/images/global/user-add.html'),
11 'no': require('../../../assets/images/global/no.html'),
12 'cloud-download': require('../../../assets/images/global/cloud-download.html'),
13 'undo': require('../../../assets/images/global/undo.html'),
14 'circle-tick': require('../../../assets/images/global/circle-tick.html'),
15 'cog': require('../../../assets/images/global/cog.html'),
16 'download': require('../../../assets/images/global/download.html'),
17 'edit': require('../../../assets/images/global/edit.html'),
18 'im-with-her': require('../../../assets/images/global/im-with-her.html'),
19 'delete': require('../../../assets/images/global/delete.html'),
20 'cross': require('../../../assets/images/global/cross.html'),
21 'validate': require('../../../assets/images/global/validate.html'),
22 'tick': require('../../../assets/images/global/tick.html'),
23 'dislike': require('../../../assets/images/video/dislike.html'),
24 'heart': require('../../../assets/images/video/heart.html'),
25 'like': require('../../../assets/images/video/like.html'),
26 'more': require('../../../assets/images/video/more.html'),
27 'share': require('../../../assets/images/video/share.html'),
28 'upload': require('../../../assets/images/video/upload.html')
29}
30
31export type GlobalIconName = keyof typeof icons
32
33@Component({
34 selector: 'my-global-icon',
35 template: '',
36 styleUrls: [ './global-icon.component.scss' ]
37})
38export class GlobalIconComponent implements OnInit {
39 @Input() iconName: GlobalIconName
40
41 constructor (private el: ElementRef) {}
42
43 ngOnInit () {
44 const nativeElement = this.el.nativeElement
45
46 nativeElement.innerHTML = icons[this.iconName]
47 }
48}
diff --git a/client/src/app/shared/instance/instance.service.ts b/client/src/app/shared/instance/instance.service.ts
new file mode 100644
index 000000000..61321ecce
--- /dev/null
+++ b/client/src/app/shared/instance/instance.service.ts
@@ -0,0 +1,36 @@
1import { catchError } from 'rxjs/operators'
2import { HttpClient } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { environment } from '../../../environments/environment'
5import { RestExtractor, RestService } from '../rest'
6import { About } from '../../../../../shared/models/server'
7
8@Injectable()
9export class InstanceService {
10 private static BASE_CONFIG_URL = environment.apiUrl + '/api/v1/config'
11 private static BASE_SERVER_URL = environment.apiUrl + '/api/v1/server'
12
13 constructor (
14 private authHttp: HttpClient,
15 private restService: RestService,
16 private restExtractor: RestExtractor
17 ) {
18 }
19
20 getAbout () {
21 return this.authHttp.get<About>(InstanceService.BASE_CONFIG_URL + '/about')
22 .pipe(catchError(res => this.restExtractor.handleError(res)))
23 }
24
25 contactAdministrator (fromEmail: string, fromName: string, message: string) {
26 const body = {
27 fromEmail,
28 fromName,
29 body: message
30 }
31
32 return this.authHttp.post(InstanceService.BASE_SERVER_URL + '/contact', body)
33 .pipe(catchError(res => this.restExtractor.handleError(res)))
34
35 }
36}
diff --git a/client/src/app/shared/menu/top-menu-dropdown.component.html b/client/src/app/shared/menu/top-menu-dropdown.component.html
new file mode 100644
index 000000000..d3c896019
--- /dev/null
+++ b/client/src/app/shared/menu/top-menu-dropdown.component.html
@@ -0,0 +1,21 @@
1<div class="sub-menu">
2 <ng-container *ngFor="let menuEntry of menuEntries">
3
4 <a *ngIf="menuEntry.routerLink" [routerLink]="menuEntry.routerLink" routerLinkActive="active" class="title-page">{{ menuEntry.label }}</a>
5
6 <div *ngIf="!menuEntry.routerLink" ngbDropdown class="parent-entry" #dropdown="ngbDropdown" (mouseleave)="closeDropdownIfHovered(dropdown)">
7 <span
8 (mouseenter)="openDropdownOnHover(dropdown)" [ngClass]="{ active: !!suffixLabels[menuEntry.label] }" ngbDropdownAnchor
9 (click)="dropdownAnchorClicked(dropdown)" role="button" class="title-page"
10 >
11 <ng-container i18n>{{ menuEntry.label }}</ng-container>
12 <ng-container *ngIf="!!suffixLabels[menuEntry.label]"> - {{ suffixLabels[menuEntry.label] }}</ng-container>
13 </span>
14
15 <div ngbDropdownMenu>
16 <a *ngFor="let menuChild of menuEntry.children" class="dropdown-item" [routerLink]="menuChild.routerLink">{{ menuChild.label }}</a>
17 </div>
18 </div>
19
20 </ng-container>
21</div>
diff --git a/client/src/app/shared/menu/top-menu-dropdown.component.scss b/client/src/app/shared/menu/top-menu-dropdown.component.scss
new file mode 100644
index 000000000..77159532f
--- /dev/null
+++ b/client/src/app/shared/menu/top-menu-dropdown.component.scss
@@ -0,0 +1,18 @@
1.parent-entry {
2 span[role=button] {
3 cursor: pointer;
4 }
5
6 a {
7 display: block;
8 }
9}
10
11/deep/ .dropdown-toggle::after {
12 position: relative;
13 top: 2px;
14}
15
16/deep/ .dropdown-menu {
17 margin-top: 0 !important;
18}
diff --git a/client/src/app/shared/menu/top-menu-dropdown.component.ts b/client/src/app/shared/menu/top-menu-dropdown.component.ts
new file mode 100644
index 000000000..e859c30dd
--- /dev/null
+++ b/client/src/app/shared/menu/top-menu-dropdown.component.ts
@@ -0,0 +1,83 @@
1import { Component, Input, OnDestroy, OnInit } from '@angular/core'
2import { filter, take } from 'rxjs/operators'
3import { NavigationEnd, Router } from '@angular/router'
4import { Subscription } from 'rxjs'
5import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
6
7export type TopMenuDropdownParam = {
8 label: string
9 routerLink?: string
10
11 children?: {
12 label: string
13 routerLink: string
14 }[]
15}
16
17@Component({
18 selector: 'my-top-menu-dropdown',
19 templateUrl: './top-menu-dropdown.component.html',
20 styleUrls: [ './top-menu-dropdown.component.scss' ]
21})
22export class TopMenuDropdownComponent implements OnInit, OnDestroy {
23 @Input() menuEntries: TopMenuDropdownParam[] = []
24
25 suffixLabels: { [ parentLabel: string ]: string }
26
27 private openedOnHover = false
28 private routeSub: Subscription
29
30 constructor (private router: Router) {}
31
32 ngOnInit () {
33 this.updateChildLabels(window.location.pathname)
34
35 this.routeSub = this.router.events
36 .pipe(filter(event => event instanceof NavigationEnd))
37 .subscribe(() => this.updateChildLabels(window.location.pathname))
38 }
39
40 ngOnDestroy () {
41 if (this.routeSub) this.routeSub.unsubscribe()
42 }
43
44 openDropdownOnHover (dropdown: NgbDropdown) {
45 this.openedOnHover = true
46 dropdown.open()
47
48 // Menu was closed
49 dropdown.openChange
50 .pipe(take(1))
51 .subscribe(e => this.openedOnHover = false)
52 }
53
54 dropdownAnchorClicked (dropdown: NgbDropdown) {
55 if (this.openedOnHover) {
56 this.openedOnHover = false
57 return
58 }
59
60 return dropdown.toggle()
61 }
62
63 closeDropdownIfHovered (dropdown: NgbDropdown) {
64 if (this.openedOnHover === false) return
65
66 dropdown.close()
67 this.openedOnHover = false
68 }
69
70 private updateChildLabels (path: string) {
71 this.suffixLabels = {}
72
73 for (const entry of this.menuEntries) {
74 if (!entry.children) continue
75
76 for (const child of entry.children) {
77 if (path.startsWith(child.routerLink)) {
78 this.suffixLabels[entry.label] = child.label
79 }
80 }
81 }
82 }
83}
diff --git a/client/src/app/shared/misc/help.component.html b/client/src/app/shared/misc/help.component.html
index 28ccb1e26..444425c9f 100644
--- a/client/src/app/shared/misc/help.component.html
+++ b/client/src/app/shared/misc/help.component.html
@@ -18,10 +18,13 @@
18 container="body" 18 container="body"
19 title="Get help" 19 title="Get help"
20 i18n-title 20 i18n-title
21 popoverClass="help-popover"
21 [attr.aria-pressed]="isPopoverOpened" 22 [attr.aria-pressed]="isPopoverOpened"
22 [ngbPopover]="tooltipTemplate" 23 [ngbPopover]="tooltipTemplate"
23 [placement]="tooltipPlacement" 24 [placement]="tooltipPlacement"
24 [autoClose]="true" 25 [autoClose]="true"
25 (onHidden)="onPopoverHidden()" 26 (onHidden)="onPopoverHidden()"
26 (onShown)="onPopoverShown()" 27 (onShown)="onPopoverShown()"
27></span> 28>
29 <my-global-icon iconName="help"></my-global-icon>
30</span>
diff --git a/client/src/app/shared/misc/help.component.scss b/client/src/app/shared/misc/help.component.scss
index 5c73a8031..3898f3cda 100644
--- a/client/src/app/shared/misc/help.component.scss
+++ b/client/src/app/shared/misc/help.component.scss
@@ -2,29 +2,40 @@
2@import '_mixins'; 2@import '_mixins';
3 3
4.help-tooltip-button { 4.help-tooltip-button {
5 @include icon(17px); 5 cursor: pointer;
6
7 position: relative;
8 top: -2px;
9 background-image: url('../../../assets/images/global/help.svg');
10 border: none; 6 border: none;
11 margin: 5px; 7
8 my-global-icon {
9 width: 17px;
10 position: relative;
11 top: -2px;
12 margin: 5px;
13
14 @include apply-svg-color(var(--mainForegroundColor))
15 }
12} 16}
13 17
14/deep/ { 18/deep/ {
15 .popover-body { 19 .help-popover {
16 text-align: left;
17 padding: 10px;
18 max-width: 300px; 20 max-width: 300px;
19 21
20 font-size: 13px; 22 .popover-body {
21 font-family: $main-fonts; 23 font-family: $main-fonts;
22 background-color: #fff; 24 text-align: left;
23 color: #000; 25 padding: 10px;
24 box-shadow: 0 0 6px rgba(0, 0, 0, 0.5); 26 font-size: 13px;
27 background-color: var(--mainBackgroundColor);
28 color: var(--mainForegroundColor);
29 box-shadow: 0 0 6px rgba(0, 0, 0, 0.5);
30
31 p {
32 margin-bottom: 0;
33 }
25 34
26 ul { 35 ul {
27 padding-left: 20px; 36 padding-left: 20px;
37 margin-bottom: 0;
38 }
28 } 39 }
29 } 40 }
30} 41}
diff --git a/client/src/app/shared/misc/help.component.ts b/client/src/app/shared/misc/help.component.ts
index ba0452e77..f3426f70f 100644
--- a/client/src/app/shared/misc/help.component.ts
+++ b/client/src/app/shared/misc/help.component.ts
@@ -1,6 +1,6 @@
1import { Component, Input, OnChanges, OnInit } from '@angular/core' 1import { Component, Input, OnChanges, OnInit } from '@angular/core'
2import { MarkdownService } from '@app/videos/shared'
3import { I18n } from '@ngx-translate/i18n-polyfill' 2import { I18n } from '@ngx-translate/i18n-polyfill'
3import { MarkdownService } from '@app/shared/renderer'
4 4
5@Component({ 5@Component({
6 selector: 'my-help', 6 selector: 'my-help',
diff --git a/client/src/app/shared/misc/utils.ts b/client/src/app/shared/misc/utils.ts
index 78e8e9682..7cc6055c2 100644
--- a/client/src/app/shared/misc/utils.ts
+++ b/client/src/app/shared/misc/utils.ts
@@ -102,12 +102,18 @@ function objectToFormData (obj: any, form?: FormData, namespace?: string) {
102 return fd 102 return fd
103} 103}
104 104
105function lineFeedToHtml (obj: any, keyToNormalize: string) { 105function objectLineFeedToHtml (obj: any, keyToNormalize: string) {
106 return immutableAssign(obj, { 106 return immutableAssign(obj, {
107 [keyToNormalize]: obj[keyToNormalize].replace(/\r?\n|\r/g, '<br />') 107 [keyToNormalize]: lineFeedToHtml(obj[keyToNormalize])
108 }) 108 })
109} 109}
110 110
111function lineFeedToHtml (text: string) {
112 if (!text) return text
113
114 return text.replace(/\r?\n|\r/g, '<br />')
115}
116
111function removeElementFromArray <T> (arr: T[], elem: T) { 117function removeElementFromArray <T> (arr: T[], elem: T) {
112 const index = arr.indexOf(elem) 118 const index = arr.indexOf(elem)
113 if (index !== -1) arr.splice(index, 1) 119 if (index !== -1) arr.splice(index, 1)
@@ -131,6 +137,7 @@ function scrollToTop () {
131export { 137export {
132 sortBy, 138 sortBy,
133 durationToString, 139 durationToString,
140 lineFeedToHtml,
134 objectToUrlEncoded, 141 objectToUrlEncoded,
135 getParameterByName, 142 getParameterByName,
136 populateAsyncUserVideoChannels, 143 populateAsyncUserVideoChannels,
@@ -138,7 +145,7 @@ export {
138 dateToHuman, 145 dateToHuman,
139 immutableAssign, 146 immutableAssign,
140 objectToFormData, 147 objectToFormData,
141 lineFeedToHtml, 148 objectLineFeedToHtml,
142 removeElementFromArray, 149 removeElementFromArray,
143 scrollToTop 150 scrollToTop
144} 151}
diff --git a/client/src/app/shared/moderation/user-ban-modal.component.html b/client/src/app/shared/moderation/user-ban-modal.component.html
index fa5cb7404..f38ea543d 100644
--- a/client/src/app/shared/moderation/user-ban-modal.component.html
+++ b/client/src/app/shared/moderation/user-ban-modal.component.html
@@ -1,7 +1,8 @@
1<ng-template #modal> 1<ng-template #modal>
2 <div class="modal-header"> 2 <div class="modal-header">
3 <h4 i18n class="modal-title">Ban</h4> 3 <h4 i18n class="modal-title">Ban</h4>
4 <span class="close" aria-hidden="true" (click)="hideBanUserModal()"></span> 4
5 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
5 </div> 6 </div>
6 7
7 <div class="modal-body"> 8 <div class="modal-body">
@@ -19,7 +20,7 @@
19 </div> 20 </div>
20 21
21 <div class="form-group inputs"> 22 <div class="form-group inputs">
22 <span i18n class="action-button action-button-cancel" (click)="hideBanUserModal()">Cancel</span> 23 <span i18n class="action-button action-button-cancel" (click)="hide()">Cancel</span>
23 24
24 <input 25 <input
25 type="submit" i18n-value value="Ban this user" class="action-button-submit" 26 type="submit" i18n-value value="Ban this user" class="action-button-submit"
@@ -29,4 +30,4 @@
29 </form> 30 </form>
30 </div> 31 </div>
31 32
32</ng-template> \ No newline at end of file 33</ng-template>
diff --git a/client/src/app/shared/moderation/user-ban-modal.component.ts b/client/src/app/shared/moderation/user-ban-modal.component.ts
index 60bd442dd..942765301 100644
--- a/client/src/app/shared/moderation/user-ban-modal.component.ts
+++ b/client/src/app/shared/moderation/user-ban-modal.component.ts
@@ -1,5 +1,5 @@
1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' 1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
2import { NotificationsService } from 'angular2-notifications' 2import { Notifier } from '@app/core'
3import { I18n } from '@ngx-translate/i18n-polyfill' 3import { I18n } from '@ngx-translate/i18n-polyfill'
4import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 4import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
5import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 5import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
@@ -23,7 +23,7 @@ export class UserBanModalComponent extends FormReactive implements OnInit {
23 constructor ( 23 constructor (
24 protected formValidatorService: FormValidatorService, 24 protected formValidatorService: FormValidatorService,
25 private modalService: NgbModal, 25 private modalService: NgbModal,
26 private notificationsService: NotificationsService, 26 private notifier: Notifier,
27 private userService: UserService, 27 private userService: UserService,
28 private userValidatorsService: UserValidatorsService, 28 private userValidatorsService: UserValidatorsService,
29 private i18n: I18n 29 private i18n: I18n
@@ -42,7 +42,7 @@ export class UserBanModalComponent extends FormReactive implements OnInit {
42 this.openedModal = this.modalService.open(this.modal) 42 this.openedModal = this.modalService.open(this.modal)
43 } 43 }
44 44
45 hideBanUserModal () { 45 hide () {
46 this.usersToBan = undefined 46 this.usersToBan = undefined
47 this.openedModal.close() 47 this.openedModal.close()
48 } 48 }
@@ -57,13 +57,13 @@ export class UserBanModalComponent extends FormReactive implements OnInit {
57 ? this.i18n('{{num}} users banned.', { num: this.usersToBan.length }) 57 ? this.i18n('{{num}} users banned.', { num: this.usersToBan.length })
58 : this.i18n('User {{username}} banned.', { username: this.usersToBan.username }) 58 : this.i18n('User {{username}} banned.', { username: this.usersToBan.username })
59 59
60 this.notificationsService.success(this.i18n('Success'), message) 60 this.notifier.success(message)
61 61
62 this.userBanned.emit(this.usersToBan) 62 this.userBanned.emit(this.usersToBan)
63 this.hideBanUserModal() 63 this.hide()
64 }, 64 },
65 65
66 err => this.notificationsService.error(this.i18n('Error'), err.message) 66 err => this.notifier.error(err.message)
67 ) 67 )
68 } 68 }
69 69
diff --git a/client/src/app/shared/moderation/user-moderation-dropdown.component.ts b/client/src/app/shared/moderation/user-moderation-dropdown.component.ts
index d391246e0..9a2461ebf 100644
--- a/client/src/app/shared/moderation/user-moderation-dropdown.component.ts
+++ b/client/src/app/shared/moderation/user-moderation-dropdown.component.ts
@@ -1,10 +1,9 @@
1import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core' 1import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core'
2import { NotificationsService } from 'angular2-notifications'
3import { I18n } from '@ngx-translate/i18n-polyfill' 2import { I18n } from '@ngx-translate/i18n-polyfill'
4import { DropdownAction } from '@app/shared/buttons/action-dropdown.component' 3import { DropdownAction } from '@app/shared/buttons/action-dropdown.component'
5import { UserBanModalComponent } from '@app/shared/moderation/user-ban-modal.component' 4import { UserBanModalComponent } from '@app/shared/moderation/user-ban-modal.component'
6import { UserService } from '@app/shared/users' 5import { UserService } from '@app/shared/users'
7import { AuthService, ConfirmService, ServerService } from '@app/core' 6import { AuthService, ConfirmService, Notifier, ServerService } from '@app/core'
8import { User, UserRight } from '../../../../../shared/models/users' 7import { User, UserRight } from '../../../../../shared/models/users'
9import { Account } from '@app/shared/account/account.model' 8import { Account } from '@app/shared/account/account.model'
10import { BlocklistService } from '@app/shared/blocklist' 9import { BlocklistService } from '@app/shared/blocklist'
@@ -30,7 +29,7 @@ export class UserModerationDropdownComponent implements OnChanges {
30 29
31 constructor ( 30 constructor (
32 private authService: AuthService, 31 private authService: AuthService,
33 private notificationsService: NotificationsService, 32 private notifier: Notifier,
34 private confirmService: ConfirmService, 33 private confirmService: ConfirmService,
35 private serverService: ServerService, 34 private serverService: ServerService,
36 private userService: UserService, 35 private userService: UserService,
@@ -48,7 +47,7 @@ export class UserModerationDropdownComponent implements OnChanges {
48 47
49 openBanUserModal (user: User) { 48 openBanUserModal (user: User) {
50 if (user.username === 'root') { 49 if (user.username === 'root') {
51 this.notificationsService.error(this.i18n('Error'), this.i18n('You cannot ban root.')) 50 this.notifier.error(this.i18n('You cannot ban root.'))
52 return 51 return
53 } 52 }
54 53
@@ -67,21 +66,18 @@ export class UserModerationDropdownComponent implements OnChanges {
67 this.userService.unbanUsers(user) 66 this.userService.unbanUsers(user)
68 .subscribe( 67 .subscribe(
69 () => { 68 () => {
70 this.notificationsService.success( 69 this.notifier.success(this.i18n('User {{username}} unbanned.', { username: user.username }))
71 this.i18n('Success'),
72 this.i18n('User {{username}} unbanned.', { username: user.username })
73 )
74 70
75 this.userChanged.emit() 71 this.userChanged.emit()
76 }, 72 },
77 73
78 err => this.notificationsService.error(this.i18n('Error'), err.message) 74 err => this.notifier.error(err.message)
79 ) 75 )
80 } 76 }
81 77
82 async removeUser (user: User) { 78 async removeUser (user: User) {
83 if (user.username === 'root') { 79 if (user.username === 'root') {
84 this.notificationsService.error(this.i18n('Error'), this.i18n('You cannot delete root.')) 80 this.notifier.error(this.i18n('You cannot delete root.'))
85 return 81 return
86 } 82 }
87 83
@@ -91,29 +87,23 @@ export class UserModerationDropdownComponent implements OnChanges {
91 87
92 this.userService.removeUser(user).subscribe( 88 this.userService.removeUser(user).subscribe(
93 () => { 89 () => {
94 this.notificationsService.success( 90 this.notifier.success(this.i18n('User {{username}} deleted.', { username: user.username }))
95 this.i18n('Success'),
96 this.i18n('User {{username}} deleted.', { username: user.username })
97 )
98 this.userDeleted.emit() 91 this.userDeleted.emit()
99 }, 92 },
100 93
101 err => this.notificationsService.error(this.i18n('Error'), err.message) 94 err => this.notifier.error(err.message)
102 ) 95 )
103 } 96 }
104 97
105 setEmailAsVerified (user: User) { 98 setEmailAsVerified (user: User) {
106 this.userService.updateUser(user.id, { emailVerified: true }).subscribe( 99 this.userService.updateUser(user.id, { emailVerified: true }).subscribe(
107 () => { 100 () => {
108 this.notificationsService.success( 101 this.notifier.success(this.i18n('User {{username}} email set as verified', { username: user.username }))
109 this.i18n('Success'),
110 this.i18n('User {{username}} email set as verified', { username: user.username })
111 )
112 102
113 this.userChanged.emit() 103 this.userChanged.emit()
114 }, 104 },
115 105
116 err => this.notificationsService.error(this.i18n('Error'), err.message) 106 err => this.notifier.error(err.message)
117 ) 107 )
118 } 108 }
119 109
@@ -121,16 +111,13 @@ export class UserModerationDropdownComponent implements OnChanges {
121 this.blocklistService.blockAccountByUser(account) 111 this.blocklistService.blockAccountByUser(account)
122 .subscribe( 112 .subscribe(
123 () => { 113 () => {
124 this.notificationsService.success( 114 this.notifier.success(this.i18n('Account {{nameWithHost}} muted.', { nameWithHost: account.nameWithHost }))
125 this.i18n('Success'),
126 this.i18n('Account {{nameWithHost}} muted.', { nameWithHost: account.nameWithHost })
127 )
128 115
129 this.account.mutedByUser = true 116 this.account.mutedByUser = true
130 this.userChanged.emit() 117 this.userChanged.emit()
131 }, 118 },
132 119
133 err => this.notificationsService.error(this.i18n('Error'), err.message) 120 err => this.notifier.error(err.message)
134 ) 121 )
135 } 122 }
136 123
@@ -138,16 +125,13 @@ export class UserModerationDropdownComponent implements OnChanges {
138 this.blocklistService.unblockAccountByUser(account) 125 this.blocklistService.unblockAccountByUser(account)
139 .subscribe( 126 .subscribe(
140 () => { 127 () => {
141 this.notificationsService.success( 128 this.notifier.success(this.i18n('Account {{nameWithHost}} unmuted.', { nameWithHost: account.nameWithHost }))
142 this.i18n('Success'),
143 this.i18n('Account {{nameWithHost}} unmuted.', { nameWithHost: account.nameWithHost })
144 )
145 129
146 this.account.mutedByUser = false 130 this.account.mutedByUser = false
147 this.userChanged.emit() 131 this.userChanged.emit()
148 }, 132 },
149 133
150 err => this.notificationsService.error(this.i18n('Error'), err.message) 134 err => this.notifier.error(err.message)
151 ) 135 )
152 } 136 }
153 137
@@ -155,16 +139,13 @@ export class UserModerationDropdownComponent implements OnChanges {
155 this.blocklistService.blockServerByUser(host) 139 this.blocklistService.blockServerByUser(host)
156 .subscribe( 140 .subscribe(
157 () => { 141 () => {
158 this.notificationsService.success( 142 this.notifier.success(this.i18n('Instance {{host}} muted.', { host }))
159 this.i18n('Success'),
160 this.i18n('Instance {{host}} muted.', { host })
161 )
162 143
163 this.account.mutedServerByUser = true 144 this.account.mutedServerByUser = true
164 this.userChanged.emit() 145 this.userChanged.emit()
165 }, 146 },
166 147
167 err => this.notificationsService.error(this.i18n('Error'), err.message) 148 err => this.notifier.error(err.message)
168 ) 149 )
169 } 150 }
170 151
@@ -172,16 +153,13 @@ export class UserModerationDropdownComponent implements OnChanges {
172 this.blocklistService.unblockServerByUser(host) 153 this.blocklistService.unblockServerByUser(host)
173 .subscribe( 154 .subscribe(
174 () => { 155 () => {
175 this.notificationsService.success( 156 this.notifier.success(this.i18n('Instance {{host}} unmuted.', { host }))
176 this.i18n('Success'),
177 this.i18n('Instance {{host}} unmuted.', { host })
178 )
179 157
180 this.account.mutedServerByUser = false 158 this.account.mutedServerByUser = false
181 this.userChanged.emit() 159 this.userChanged.emit()
182 }, 160 },
183 161
184 err => this.notificationsService.error(this.i18n('Error'), err.message) 162 err => this.notifier.error(err.message)
185 ) 163 )
186 } 164 }
187 165
@@ -189,16 +167,13 @@ export class UserModerationDropdownComponent implements OnChanges {
189 this.blocklistService.blockAccountByInstance(account) 167 this.blocklistService.blockAccountByInstance(account)
190 .subscribe( 168 .subscribe(
191 () => { 169 () => {
192 this.notificationsService.success( 170 this.notifier.success(this.i18n('Account {{nameWithHost}} muted by the instance.', { nameWithHost: account.nameWithHost }))
193 this.i18n('Success'),
194 this.i18n('Account {{nameWithHost}} muted by the instance.', { nameWithHost: account.nameWithHost })
195 )
196 171
197 this.account.mutedByInstance = true 172 this.account.mutedByInstance = true
198 this.userChanged.emit() 173 this.userChanged.emit()
199 }, 174 },
200 175
201 err => this.notificationsService.error(this.i18n('Error'), err.message) 176 err => this.notifier.error(err.message)
202 ) 177 )
203 } 178 }
204 179
@@ -206,16 +181,13 @@ export class UserModerationDropdownComponent implements OnChanges {
206 this.blocklistService.unblockAccountByInstance(account) 181 this.blocklistService.unblockAccountByInstance(account)
207 .subscribe( 182 .subscribe(
208 () => { 183 () => {
209 this.notificationsService.success( 184 this.notifier.success(this.i18n('Account {{nameWithHost}} unmuted by the instance.', { nameWithHost: account.nameWithHost }))
210 this.i18n('Success'),
211 this.i18n('Account {{nameWithHost}} unmuted by the instance.', { nameWithHost: account.nameWithHost })
212 )
213 185
214 this.account.mutedByInstance = false 186 this.account.mutedByInstance = false
215 this.userChanged.emit() 187 this.userChanged.emit()
216 }, 188 },
217 189
218 err => this.notificationsService.error(this.i18n('Error'), err.message) 190 err => this.notifier.error(err.message)
219 ) 191 )
220 } 192 }
221 193
@@ -223,16 +195,13 @@ export class UserModerationDropdownComponent implements OnChanges {
223 this.blocklistService.blockServerByInstance(host) 195 this.blocklistService.blockServerByInstance(host)
224 .subscribe( 196 .subscribe(
225 () => { 197 () => {
226 this.notificationsService.success( 198 this.notifier.success(this.i18n('Instance {{host}} muted by the instance.', { host }))
227 this.i18n('Success'),
228 this.i18n('Instance {{host}} muted by the instance.', { host })
229 )
230 199
231 this.account.mutedServerByInstance = true 200 this.account.mutedServerByInstance = true
232 this.userChanged.emit() 201 this.userChanged.emit()
233 }, 202 },
234 203
235 err => this.notificationsService.error(this.i18n('Error'), err.message) 204 err => this.notifier.error(err.message)
236 ) 205 )
237 } 206 }
238 207
@@ -240,16 +209,13 @@ export class UserModerationDropdownComponent implements OnChanges {
240 this.blocklistService.unblockServerByInstance(host) 209 this.blocklistService.unblockServerByInstance(host)
241 .subscribe( 210 .subscribe(
242 () => { 211 () => {
243 this.notificationsService.success( 212 this.notifier.success(this.i18n('Instance {{host}} unmuted by the instance.', { host }))
244 this.i18n('Success'),
245 this.i18n('Instance {{host}} unmuted by the instance.', { host })
246 )
247 213
248 this.account.mutedServerByInstance = false 214 this.account.mutedServerByInstance = false
249 this.userChanged.emit() 215 this.userChanged.emit()
250 }, 216 },
251 217
252 err => this.notificationsService.error(this.i18n('Error'), err.message) 218 err => this.notifier.error(err.message)
253 ) 219 )
254 } 220 }
255 221
@@ -277,18 +243,18 @@ export class UserModerationDropdownComponent implements OnChanges {
277 }, 243 },
278 { 244 {
279 label: this.i18n('Ban'), 245 label: this.i18n('Ban'),
280 handler: ({ user }: { user: User }) => this.openBanUserModal(user), 246 handler: ({ user }) => this.openBanUserModal(user),
281 isDisplayed: ({ user }: { user: User }) => !user.blocked 247 isDisplayed: ({ user }) => !user.blocked
282 }, 248 },
283 { 249 {
284 label: this.i18n('Unban'), 250 label: this.i18n('Unban'),
285 handler: ({ user }: { user: User }) => this.unbanUser(user), 251 handler: ({ user }) => this.unbanUser(user),
286 isDisplayed: ({ user }: { user: User }) => user.blocked 252 isDisplayed: ({ user }) => user.blocked
287 }, 253 },
288 { 254 {
289 label: this.i18n('Set Email as Verified'), 255 label: this.i18n('Set Email as Verified'),
290 handler: ({ user }: { user: User }) => this.setEmailAsVerified(user), 256 handler: ({ user }) => this.setEmailAsVerified(user),
291 isDisplayed: ({ user }: { user: User }) => this.requiresEmailVerification && !user.blocked && user.emailVerified === false 257 isDisplayed: ({ user }) => this.requiresEmailVerification && !user.blocked && user.emailVerified === false
292 } 258 }
293 ]) 259 ])
294 } 260 }
@@ -299,23 +265,23 @@ export class UserModerationDropdownComponent implements OnChanges {
299 this.userActions.push([ 265 this.userActions.push([
300 { 266 {
301 label: this.i18n('Mute this account'), 267 label: this.i18n('Mute this account'),
302 isDisplayed: ({ account }: { account: Account }) => account.mutedByUser === false, 268 isDisplayed: ({ account }) => account.mutedByUser === false,
303 handler: ({ account }: { account: Account }) => this.blockAccountByUser(account) 269 handler: ({ account }) => this.blockAccountByUser(account)
304 }, 270 },
305 { 271 {
306 label: this.i18n('Unmute this account'), 272 label: this.i18n('Unmute this account'),
307 isDisplayed: ({ account }: { account: Account }) => account.mutedByUser === true, 273 isDisplayed: ({ account }) => account.mutedByUser === true,
308 handler: ({ account }: { account: Account }) => this.unblockAccountByUser(account) 274 handler: ({ account }) => this.unblockAccountByUser(account)
309 }, 275 },
310 { 276 {
311 label: this.i18n('Mute the instance'), 277 label: this.i18n('Mute the instance'),
312 isDisplayed: ({ account }: { account: Account }) => !account.userId && account.mutedServerByInstance === false, 278 isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === false,
313 handler: ({ account }: { account: Account }) => this.blockServerByUser(account.host) 279 handler: ({ account }) => this.blockServerByUser(account.host)
314 }, 280 },
315 { 281 {
316 label: this.i18n('Unmute the instance'), 282 label: this.i18n('Unmute the instance'),
317 isDisplayed: ({ account }: { account: Account }) => !account.userId && account.mutedServerByInstance === true, 283 isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === true,
318 handler: ({ account }: { account: Account }) => this.unblockServerByUser(account.host) 284 handler: ({ account }) => this.unblockServerByUser(account.host)
319 } 285 }
320 ]) 286 ])
321 287
@@ -326,13 +292,13 @@ export class UserModerationDropdownComponent implements OnChanges {
326 instanceActions = instanceActions.concat([ 292 instanceActions = instanceActions.concat([
327 { 293 {
328 label: this.i18n('Mute this account by your instance'), 294 label: this.i18n('Mute this account by your instance'),
329 isDisplayed: ({ account }: { account: Account }) => account.mutedByInstance === false, 295 isDisplayed: ({ account }) => account.mutedByInstance === false,
330 handler: ({ account }: { account: Account }) => this.blockAccountByInstance(account) 296 handler: ({ account }) => this.blockAccountByInstance(account)
331 }, 297 },
332 { 298 {
333 label: this.i18n('Unmute this account by your instance'), 299 label: this.i18n('Unmute this account by your instance'),
334 isDisplayed: ({ account }: { account: Account }) => account.mutedByInstance === true, 300 isDisplayed: ({ account }) => account.mutedByInstance === true,
335 handler: ({ account }: { account: Account }) => this.unblockAccountByInstance(account) 301 handler: ({ account }) => this.unblockAccountByInstance(account)
336 } 302 }
337 ]) 303 ])
338 } 304 }
@@ -342,13 +308,13 @@ export class UserModerationDropdownComponent implements OnChanges {
342 instanceActions = instanceActions.concat([ 308 instanceActions = instanceActions.concat([
343 { 309 {
344 label: this.i18n('Mute the instance by your instance'), 310 label: this.i18n('Mute the instance by your instance'),
345 isDisplayed: ({ account }: { account: Account }) => !account.userId && account.mutedServerByInstance === false, 311 isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === false,
346 handler: ({ account }: { account: Account }) => this.blockServerByInstance(account.host) 312 handler: ({ account }) => this.blockServerByInstance(account.host)
347 }, 313 },
348 { 314 {
349 label: this.i18n('Unmute the instance by your instance'), 315 label: this.i18n('Unmute the instance by your instance'),
350 isDisplayed: ({ account }: { account: Account }) => !account.userId && account.mutedServerByInstance === true, 316 isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === true,
351 handler: ({ account }: { account: Account }) => this.unblockServerByInstance(account.host) 317 handler: ({ account }) => this.unblockServerByInstance(account.host)
352 } 318 }
353 ]) 319 ])
354 } 320 }
diff --git a/client/src/app/shared/renderer/html-renderer.service.ts b/client/src/app/shared/renderer/html-renderer.service.ts
new file mode 100644
index 000000000..d49df9b6d
--- /dev/null
+++ b/client/src/app/shared/renderer/html-renderer.service.ts
@@ -0,0 +1,35 @@
1import { Injectable } from '@angular/core'
2import { LinkifierService } from '@app/shared/renderer/linkifier.service'
3import * as sanitizeHtml from 'sanitize-html'
4
5@Injectable()
6export class HtmlRendererService {
7
8 constructor (private linkifier: LinkifierService) {
9
10 }
11
12 toSafeHtml (text: string) {
13 // Convert possible markdown to html
14 const html = this.linkifier.linkify(text)
15
16 return sanitizeHtml(html, {
17 allowedTags: [ 'a', 'p', 'span', 'br' ],
18 allowedSchemes: [ 'http', 'https' ],
19 allowedAttributes: {
20 'a': [ 'href', 'class', 'target' ]
21 },
22 transformTags: {
23 a: (tagName, attribs) => {
24 return {
25 tagName,
26 attribs: Object.assign(attribs, {
27 target: '_blank',
28 rel: 'noopener noreferrer'
29 })
30 }
31 }
32 }
33 })
34 }
35}
diff --git a/client/src/app/shared/renderer/index.ts b/client/src/app/shared/renderer/index.ts
new file mode 100644
index 000000000..39202b385
--- /dev/null
+++ b/client/src/app/shared/renderer/index.ts
@@ -0,0 +1,3 @@
1export * from './html-renderer.service'
2export * from './linkifier.service'
3export * from './markdown.service'
diff --git a/client/src/app/shared/renderer/linkifier.service.ts b/client/src/app/shared/renderer/linkifier.service.ts
new file mode 100644
index 000000000..2529c9eaf
--- /dev/null
+++ b/client/src/app/shared/renderer/linkifier.service.ts
@@ -0,0 +1,115 @@
1import { Injectable } from '@angular/core'
2import { getAbsoluteAPIUrl } from '@app/shared/misc/utils'
3// FIXME: use @types/linkify when https://github.com/DefinitelyTyped/DefinitelyTyped/pull/29682/files is merged?
4const linkify = require('linkifyjs')
5const linkifyHtml = require('linkifyjs/html')
6
7@Injectable()
8export class LinkifierService {
9
10 static CLASSNAME = 'linkified'
11
12 private linkifyOptions = {
13 className: {
14 mention: LinkifierService.CLASSNAME + '-mention',
15 url: LinkifierService.CLASSNAME + '-url'
16 }
17 }
18
19 constructor () {
20 // Apply plugin
21 this.mentionWithDomainPlugin(linkify)
22 }
23
24 linkify (text: string) {
25 return linkifyHtml(text, this.linkifyOptions)
26 }
27
28 private mentionWithDomainPlugin (linkify: any) {
29 const TT = linkify.scanner.TOKENS // Text tokens
30 const { TOKENS: MT, State } = linkify.parser // Multi tokens, state
31 const MultiToken = MT.Base
32 const S_START = linkify.parser.start
33
34 const TT_AT = TT.AT
35 const TT_DOMAIN = TT.DOMAIN
36 const TT_LOCALHOST = TT.LOCALHOST
37 const TT_NUM = TT.NUM
38 const TT_COLON = TT.COLON
39 const TT_SLASH = TT.SLASH
40 const TT_TLD = TT.TLD
41 const TT_UNDERSCORE = TT.UNDERSCORE
42 const TT_DOT = TT.DOT
43
44 function MENTION (this: any, value: any) {
45 this.v = value
46 }
47
48 linkify.inherits(MultiToken, MENTION, {
49 type: 'mentionWithDomain',
50 isLink: true,
51 toHref () {
52 return getAbsoluteAPIUrl() + '/services/redirect/accounts/' + this.toString().substr(1)
53 }
54 })
55
56 const S_AT = S_START.jump(TT_AT) // @
57 const S_AT_SYMS = new State()
58 const S_MENTION = new State(MENTION)
59 const S_MENTION_DIVIDER = new State()
60 const S_MENTION_DIVIDER_SYMS = new State()
61
62 // @_,
63 S_AT.on(TT_UNDERSCORE, S_AT_SYMS)
64
65 // @_*
66 S_AT_SYMS
67 .on(TT_UNDERSCORE, S_AT_SYMS)
68 .on(TT_DOT, S_AT_SYMS)
69
70 // Valid mention (not made up entirely of symbols)
71 S_AT
72 .on(TT_DOMAIN, S_MENTION)
73 .on(TT_LOCALHOST, S_MENTION)
74 .on(TT_TLD, S_MENTION)
75 .on(TT_NUM, S_MENTION)
76
77 S_AT_SYMS
78 .on(TT_DOMAIN, S_MENTION)
79 .on(TT_LOCALHOST, S_MENTION)
80 .on(TT_TLD, S_MENTION)
81 .on(TT_NUM, S_MENTION)
82
83 // More valid mentions
84 S_MENTION
85 .on(TT_DOMAIN, S_MENTION)
86 .on(TT_LOCALHOST, S_MENTION)
87 .on(TT_TLD, S_MENTION)
88 .on(TT_COLON, S_MENTION)
89 .on(TT_NUM, S_MENTION)
90 .on(TT_UNDERSCORE, S_MENTION)
91
92 // Mention with a divider
93 S_MENTION
94 .on(TT_AT, S_MENTION_DIVIDER)
95 .on(TT_SLASH, S_MENTION_DIVIDER)
96 .on(TT_DOT, S_MENTION_DIVIDER)
97
98 // Mention _ trailing stash plus syms
99 S_MENTION_DIVIDER.on(TT_UNDERSCORE, S_MENTION_DIVIDER_SYMS)
100 S_MENTION_DIVIDER_SYMS.on(TT_UNDERSCORE, S_MENTION_DIVIDER_SYMS)
101
102 // Once we get a word token, mentions can start up again
103 S_MENTION_DIVIDER
104 .on(TT_DOMAIN, S_MENTION)
105 .on(TT_LOCALHOST, S_MENTION)
106 .on(TT_TLD, S_MENTION)
107 .on(TT_NUM, S_MENTION)
108
109 S_MENTION_DIVIDER_SYMS
110 .on(TT_DOMAIN, S_MENTION)
111 .on(TT_LOCALHOST, S_MENTION)
112 .on(TT_TLD, S_MENTION)
113 .on(TT_NUM, S_MENTION)
114 }
115}
diff --git a/client/src/app/shared/renderer/markdown.service.ts b/client/src/app/shared/renderer/markdown.service.ts
new file mode 100644
index 000000000..07017eca5
--- /dev/null
+++ b/client/src/app/shared/renderer/markdown.service.ts
@@ -0,0 +1,79 @@
1import { Injectable } from '@angular/core'
2
3import * as MarkdownIt from 'markdown-it'
4
5@Injectable()
6export class MarkdownService {
7 static TEXT_RULES = [
8 'linkify',
9 'autolink',
10 'emphasis',
11 'link',
12 'newline',
13 'list'
14 ]
15 static ENHANCED_RULES = MarkdownService.TEXT_RULES.concat([ 'image' ])
16
17 private textMarkdownIt: MarkdownIt.MarkdownIt
18 private enhancedMarkdownIt: MarkdownIt.MarkdownIt
19
20 constructor () {
21 this.textMarkdownIt = this.createMarkdownIt(MarkdownService.TEXT_RULES)
22 this.enhancedMarkdownIt = this.createMarkdownIt(MarkdownService.ENHANCED_RULES)
23 }
24
25 textMarkdownToHTML (markdown: string) {
26 if (!markdown) return ''
27
28 const html = this.textMarkdownIt.render(markdown)
29 return this.avoidTruncatedTags(html)
30 }
31
32 enhancedMarkdownToHTML (markdown: string) {
33 if (!markdown) return ''
34
35 const html = this.enhancedMarkdownIt.render(markdown)
36 return this.avoidTruncatedTags(html)
37 }
38
39 private createMarkdownIt (rules: string[]) {
40 const markdownIt = new MarkdownIt('zero', { linkify: true, breaks: true })
41
42 for (let rule of rules) {
43 markdownIt.enable(rule)
44 }
45
46 this.setTargetToLinks(markdownIt)
47
48 return markdownIt
49 }
50
51 private setTargetToLinks (markdownIt: MarkdownIt.MarkdownIt) {
52 // Snippet from markdown-it documentation: https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer
53 const defaultRender = markdownIt.renderer.rules.link_open || function (tokens, idx, options, env, self) {
54 return self.renderToken(tokens, idx, options)
55 }
56
57 markdownIt.renderer.rules.link_open = function (tokens, index, options, env, self) {
58 const token = tokens[index]
59
60 const targetIndex = token.attrIndex('target')
61 if (targetIndex < 0) token.attrPush([ 'target', '_blank' ])
62 else token.attrs[targetIndex][1] = '_blank'
63
64 const relIndex = token.attrIndex('rel')
65 if (relIndex < 0) token.attrPush([ 'rel', 'noopener noreferrer' ])
66 else token.attrs[relIndex][1] = 'noopener noreferrer'
67
68 // pass token to default renderer.
69 return defaultRender(tokens, index, options, env, self)
70 }
71 }
72
73 private avoidTruncatedTags (html: string) {
74 return html.replace(/\*\*?([^*]+)$/, '$1')
75 .replace(/<a[^>]+>([^<]+)<\/a>\s*...((<\/p>)|(<\/li>)|(<\/strong>))?$/mi, '$1...')
76 .replace(/\[[^\]]+\]?\(?([^\)]+)$/, '$1')
77
78 }
79}
diff --git a/client/src/app/shared/rest/component-pagination.model.ts b/client/src/app/shared/rest/component-pagination.model.ts
index 0b8ecc318..85160d445 100644
--- a/client/src/app/shared/rest/component-pagination.model.ts
+++ b/client/src/app/shared/rest/component-pagination.model.ts
@@ -3,3 +3,14 @@ export interface ComponentPagination {
3 itemsPerPage: number 3 itemsPerPage: number
4 totalItems?: number 4 totalItems?: number
5} 5}
6
7export function hasMoreItems (componentPagination: ComponentPagination) {
8 // No results
9 if (componentPagination.totalItems === 0) return false
10
11 // Not loaded yet
12 if (!componentPagination.totalItems) return true
13
14 const maxPage = componentPagination.totalItems / componentPagination.itemsPerPage
15 return maxPage > componentPagination.currentPage
16}
diff --git a/client/src/app/shared/rest/rest-extractor.service.ts b/client/src/app/shared/rest/rest-extractor.service.ts
index f149569ef..e6518dd1d 100644
--- a/client/src/app/shared/rest/rest-extractor.service.ts
+++ b/client/src/app/shared/rest/rest-extractor.service.ts
@@ -80,6 +80,7 @@ export class RestExtractor {
80 errorMessage = errorMessage ? errorMessage : 'Unknown error.' 80 errorMessage = errorMessage ? errorMessage : 'Unknown error.'
81 console.error(`Backend returned code ${err.status}, errorMessage is: ${errorMessage}`) 81 console.error(`Backend returned code ${err.status}, errorMessage is: ${errorMessage}`)
82 } else { 82 } else {
83 console.error(err)
83 errorMessage = err 84 errorMessage = err
84 } 85 }
85 86
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts
index a2fa27b72..6f8625c7e 100644
--- a/client/src/app/shared/shared.module.ts
+++ b/client/src/app/shared/shared.module.ts
@@ -6,7 +6,6 @@ import { RouterModule } from '@angular/router'
6import { MarkdownTextareaComponent } from '@app/shared/forms/markdown-textarea.component' 6import { MarkdownTextareaComponent } from '@app/shared/forms/markdown-textarea.component'
7import { HelpComponent } from '@app/shared/misc/help.component' 7import { HelpComponent } from '@app/shared/misc/help.component'
8import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive' 8import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive'
9import { MarkdownService } from '@app/videos/shared'
10 9
11import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes' 10import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes'
12import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared' 11import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared'
@@ -34,6 +33,7 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
34import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 33import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
35import { 34import {
36 CustomConfigValidatorsService, 35 CustomConfigValidatorsService,
36 InstanceValidatorsService,
37 LoginValidatorsService, 37 LoginValidatorsService,
38 ReactiveFileComponent, 38 ReactiveFileComponent,
39 ResetPasswordValidatorsService, 39 ResetPasswordValidatorsService,
@@ -61,6 +61,14 @@ import { OverviewService } from '@app/shared/overview'
61import { UserBanModalComponent } from '@app/shared/moderation' 61import { UserBanModalComponent } from '@app/shared/moderation'
62import { UserModerationDropdownComponent } from '@app/shared/moderation/user-moderation-dropdown.component' 62import { UserModerationDropdownComponent } from '@app/shared/moderation/user-moderation-dropdown.component'
63import { BlocklistService } from '@app/shared/blocklist' 63import { BlocklistService } from '@app/shared/blocklist'
64import { TopMenuDropdownComponent } from '@app/shared/menu/top-menu-dropdown.component'
65import { UserHistoryService } from '@app/shared/users/user-history.service'
66import { UserNotificationService } from '@app/shared/users/user-notification.service'
67import { UserNotificationsComponent } from '@app/shared/users/user-notifications.component'
68import { InstanceService } from '@app/shared/instance/instance.service'
69import { HtmlRendererService, LinkifierService, MarkdownService } from '@app/shared/renderer'
70import { ConfirmComponent } from '@app/shared/confirm/confirm.component'
71import { GlobalIconComponent } from '@app/shared/icons/global-icon.component'
64 72
65@NgModule({ 73@NgModule({
66 imports: [ 74 imports: [
@@ -102,7 +110,11 @@ import { BlocklistService } from '@app/shared/blocklist'
102 RemoteSubscribeComponent, 110 RemoteSubscribeComponent,
103 InstanceFeaturesTableComponent, 111 InstanceFeaturesTableComponent,
104 UserBanModalComponent, 112 UserBanModalComponent,
105 UserModerationDropdownComponent 113 UserModerationDropdownComponent,
114 TopMenuDropdownComponent,
115 UserNotificationsComponent,
116 ConfirmComponent,
117 GlobalIconComponent
106 ], 118 ],
107 119
108 exports: [ 120 exports: [
@@ -141,6 +153,10 @@ import { BlocklistService } from '@app/shared/blocklist'
141 InstanceFeaturesTableComponent, 153 InstanceFeaturesTableComponent,
142 UserBanModalComponent, 154 UserBanModalComponent,
143 UserModerationDropdownComponent, 155 UserModerationDropdownComponent,
156 TopMenuDropdownComponent,
157 UserNotificationsComponent,
158 ConfirmComponent,
159 GlobalIconComponent,
144 160
145 NumberFormatterPipe, 161 NumberFormatterPipe,
146 ObjectLengthPipe, 162 ObjectLengthPipe,
@@ -157,7 +173,6 @@ import { BlocklistService } from '@app/shared/blocklist'
157 UserService, 173 UserService,
158 VideoService, 174 VideoService,
159 AccountService, 175 AccountService,
160 MarkdownService,
161 VideoChannelService, 176 VideoChannelService,
162 VideoCaptionService, 177 VideoCaptionService,
163 VideoImportService, 178 VideoImportService,
@@ -177,11 +192,20 @@ import { BlocklistService } from '@app/shared/blocklist'
177 OverviewService, 192 OverviewService,
178 VideoChangeOwnershipValidatorsService, 193 VideoChangeOwnershipValidatorsService,
179 VideoAcceptOwnershipValidatorsService, 194 VideoAcceptOwnershipValidatorsService,
195 InstanceValidatorsService,
180 BlocklistService, 196 BlocklistService,
197 UserHistoryService,
198 InstanceService,
199
200 MarkdownService,
201 LinkifierService,
202 HtmlRendererService,
181 203
182 I18nPrimengCalendarService, 204 I18nPrimengCalendarService,
183 ScreenService, 205 ScreenService,
184 206
207 UserNotificationService,
208
185 I18n 209 I18n
186 ] 210 ]
187}) 211})
diff --git a/client/src/app/shared/user-subscription/remote-subscribe.component.ts b/client/src/app/shared/user-subscription/remote-subscribe.component.ts
index 7a81108cd..ba2a45df1 100644
--- a/client/src/app/shared/user-subscription/remote-subscribe.component.ts
+++ b/client/src/app/shared/user-subscription/remote-subscribe.component.ts
@@ -29,7 +29,7 @@ export class RemoteSubscribeComponent extends FormReactive implements OnInit {
29 } 29 }
30 30
31 onValidKey () { 31 onValidKey () {
32 this.onValueChanged() 32 this.check()
33 if (!this.form.valid) return 33 if (!this.form.valid) return
34 34
35 this.formValidated() 35 this.formValidated()
@@ -37,7 +37,24 @@ export class RemoteSubscribeComponent extends FormReactive implements OnInit {
37 37
38 formValidated () { 38 formValidated () {
39 const address = this.form.value['text'] 39 const address = this.form.value['text']
40 const [ , hostname ] = address.split('@') 40 const [ username, hostname ] = address.split('@')
41 window.open(`https://${hostname}/authorize_interaction?acct=${this.account}`) 41
42 fetch(`https://${hostname}/.well-known/webfinger?resource=acct:${username}@${hostname}`)
43 .then(response => response.json())
44 .then(data => new Promise((resolve, reject) => {
45 if (data && Array.isArray(data.links)) {
46 const link: {
47 template: string
48 } = data.links.find((link: any) =>
49 link && typeof link.template === 'string' && link.rel === 'http://ostatus.org/schema/1.0/subscribe')
50
51 if (link && link.template.includes('{uri}')) {
52 resolve(link.template.replace('{uri}', `acct:${this.account}`))
53 }
54 }
55 reject()
56 }))
57 .then(window.open)
58 .catch(() => window.open(`https://${hostname}/authorize_interaction?acct=${this.account}`))
42 } 59 }
43} 60}
diff --git a/client/src/app/shared/user-subscription/subscribe-button.component.ts b/client/src/app/shared/user-subscription/subscribe-button.component.ts
index 315ea5037..8f1754c7f 100644
--- a/client/src/app/shared/user-subscription/subscribe-button.component.ts
+++ b/client/src/app/shared/user-subscription/subscribe-button.component.ts
@@ -1,9 +1,8 @@
1import { Component, Input, OnInit } from '@angular/core' 1import { Component, Input, OnInit } from '@angular/core'
2import { Router } from '@angular/router' 2import { Router } from '@angular/router'
3import { AuthService } from '@app/core' 3import { AuthService, Notifier } from '@app/core'
4import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service' 4import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service'
5import { VideoChannel } from '@app/shared/video-channel/video-channel.model' 5import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
6import { NotificationsService } from 'angular2-notifications'
7import { I18n } from '@ngx-translate/i18n-polyfill' 6import { I18n } from '@ngx-translate/i18n-polyfill'
8import { VideoService } from '@app/shared/video/video.service' 7import { VideoService } from '@app/shared/video/video.service'
9import { FeedFormat } from '../../../../../shared/models/feeds' 8import { FeedFormat } from '../../../../../shared/models/feeds'
@@ -23,7 +22,7 @@ export class SubscribeButtonComponent implements OnInit {
23 constructor ( 22 constructor (
24 private authService: AuthService, 23 private authService: AuthService,
25 private router: Router, 24 private router: Router,
26 private notificationsService: NotificationsService, 25 private notifier: Notifier,
27 private userSubscriptionService: UserSubscriptionService, 26 private userSubscriptionService: UserSubscriptionService,
28 private i18n: I18n, 27 private i18n: I18n,
29 private videoService: VideoService 28 private videoService: VideoService
@@ -43,18 +42,17 @@ export class SubscribeButtonComponent implements OnInit {
43 .subscribe( 42 .subscribe(
44 res => this.subscribed = res[this.uri], 43 res => this.subscribed = res[this.uri],
45 44
46 err => this.notificationsService.error(this.i18n('Error'), err.message) 45 err => this.notifier.error(err.message)
47 ) 46 )
48 } 47 }
49 } 48 }
50 49
51 subscribe () { 50 subscribe () {
52 if (this.isUserLoggedIn()) { 51 if (this.isUserLoggedIn()) {
53 this.localSubscribe() 52 return this.localSubscribe()
54 } else {
55 this.authService.redirectUrl = this.router.url
56 this.gotoLogin()
57 } 53 }
54
55 return this.gotoLogin()
58 } 56 }
59 57
60 localSubscribe () { 58 localSubscribe () {
@@ -63,13 +61,13 @@ export class SubscribeButtonComponent implements OnInit {
63 () => { 61 () => {
64 this.subscribed = true 62 this.subscribed = true
65 63
66 this.notificationsService.success( 64 this.notifier.success(
67 this.i18n('Subscribed'), 65 this.i18n('Subscribed to {{nameWithHost}}', { nameWithHost: this.videoChannel.displayName }),
68 this.i18n('Subscribed to {{nameWithHost}}', { nameWithHost: this.videoChannel.displayName }) 66 this.i18n('Subscribed')
69 ) 67 )
70 }, 68 },
71 69
72 err => this.notificationsService.error(this.i18n('Error'), err.message) 70 err => this.notifier.error(err.message)
73 ) 71 )
74 } 72 }
75 73
@@ -85,13 +83,13 @@ export class SubscribeButtonComponent implements OnInit {
85 () => { 83 () => {
86 this.subscribed = false 84 this.subscribed = false
87 85
88 this.notificationsService.success( 86 this.notifier.success(
89 this.i18n('Unsubscribed'), 87 this.i18n('Unsubscribed from {{nameWithHost}}', { nameWithHost: this.videoChannel.displayName }),
90 this.i18n('Unsubscribed from {{nameWithHost}}', { nameWithHost: this.videoChannel.displayName }) 88 this.i18n('Unsubscribed')
91 ) 89 )
92 }, 90 },
93 91
94 err => this.notificationsService.error(this.i18n('Error'), err.message) 92 err => this.notifier.error(err.message)
95 ) 93 )
96 } 94 }
97 95
diff --git a/client/src/app/shared/users/index.ts b/client/src/app/shared/users/index.ts
index 7b5a67bc7..ebd715fb1 100644
--- a/client/src/app/shared/users/index.ts
+++ b/client/src/app/shared/users/index.ts
@@ -1,2 +1,3 @@
1export * from './user.model' 1export * from './user.model'
2export * from './user.service' 2export * from './user.service'
3export * from './user-notifications.component'
diff --git a/client/src/app/shared/users/user-history.service.ts b/client/src/app/shared/users/user-history.service.ts
new file mode 100644
index 000000000..9ed25bfc7
--- /dev/null
+++ b/client/src/app/shared/users/user-history.service.ts
@@ -0,0 +1,45 @@
1import { HttpClient, HttpParams } from '@angular/common/http'
2import { Injectable } from '@angular/core'
3import { environment } from '../../../environments/environment'
4import { RestExtractor } from '../rest/rest-extractor.service'
5import { RestService } from '../rest/rest.service'
6import { Video } from '../video/video.model'
7import { catchError, map, switchMap } from 'rxjs/operators'
8import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
9import { VideoService } from '@app/shared/video/video.service'
10import { ResultList } from '../../../../../shared'
11
12@Injectable()
13export class UserHistoryService {
14 static BASE_USER_VIDEOS_HISTORY_URL = environment.apiUrl + '/api/v1/users/me/history/videos'
15
16 constructor (
17 private authHttp: HttpClient,
18 private restExtractor: RestExtractor,
19 private restService: RestService,
20 private videoService: VideoService
21 ) {}
22
23 getUserVideosHistory (historyPagination: ComponentPagination) {
24 const pagination = this.restService.componentPaginationToRestPagination(historyPagination)
25
26 let params = new HttpParams()
27 params = this.restService.addRestGetParams(params, pagination)
28
29 return this.authHttp
30 .get<ResultList<Video>>(UserHistoryService.BASE_USER_VIDEOS_HISTORY_URL, { params })
31 .pipe(
32 switchMap(res => this.videoService.extractVideos(res)),
33 catchError(err => this.restExtractor.handleError(err))
34 )
35 }
36
37 deleteUserVideosHistory () {
38 return this.authHttp
39 .post(UserHistoryService.BASE_USER_VIDEOS_HISTORY_URL + '/remove', {})
40 .pipe(
41 map(() => this.restExtractor.extractDataBool()),
42 catchError(err => this.restExtractor.handleError(err))
43 )
44 }
45}
diff --git a/client/src/app/shared/users/user-notification.model.ts b/client/src/app/shared/users/user-notification.model.ts
new file mode 100644
index 000000000..125d2120c
--- /dev/null
+++ b/client/src/app/shared/users/user-notification.model.ts
@@ -0,0 +1,155 @@
1import { UserNotification as UserNotificationServer, UserNotificationType, VideoInfo, ActorInfo } from '../../../../../shared'
2import { Actor } from '@app/shared/actor/actor.model'
3
4export class UserNotification implements UserNotificationServer {
5 id: number
6 type: UserNotificationType
7 read: boolean
8
9 video?: VideoInfo & {
10 channel: ActorInfo & { avatarUrl?: string }
11 }
12
13 videoImport?: {
14 id: number
15 video?: VideoInfo
16 torrentName?: string
17 magnetUri?: string
18 targetUrl?: string
19 }
20
21 comment?: {
22 id: number
23 threadId: number
24 account: ActorInfo & { avatarUrl?: string }
25 video: VideoInfo
26 }
27
28 videoAbuse?: {
29 id: number
30 video: VideoInfo
31 }
32
33 videoBlacklist?: {
34 id: number
35 video: VideoInfo
36 }
37
38 account?: ActorInfo & { avatarUrl?: string }
39
40 actorFollow?: {
41 id: number
42 follower: ActorInfo & { avatarUrl?: string }
43 following: {
44 type: 'account' | 'channel'
45 name: string
46 displayName: string
47 }
48 }
49
50 createdAt: string
51 updatedAt: string
52
53 // Additional fields
54 videoUrl?: string
55 commentUrl?: any[]
56 videoAbuseUrl?: string
57 accountUrl?: string
58 videoImportIdentifier?: string
59 videoImportUrl?: string
60
61 constructor (hash: UserNotificationServer) {
62 this.id = hash.id
63 this.type = hash.type
64 this.read = hash.read
65
66 this.video = hash.video
67 if (this.video) this.setAvatarUrl(this.video.channel)
68
69 this.videoImport = hash.videoImport
70
71 this.comment = hash.comment
72 if (this.comment) this.setAvatarUrl(this.comment.account)
73
74 this.videoAbuse = hash.videoAbuse
75
76 this.videoBlacklist = hash.videoBlacklist
77
78 this.account = hash.account
79 if (this.account) this.setAvatarUrl(this.account)
80
81 this.actorFollow = hash.actorFollow
82 if (this.actorFollow) this.setAvatarUrl(this.actorFollow.follower)
83
84 this.createdAt = hash.createdAt
85 this.updatedAt = hash.updatedAt
86
87 switch (this.type) {
88 case UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION:
89 this.videoUrl = this.buildVideoUrl(this.video)
90 break
91
92 case UserNotificationType.UNBLACKLIST_ON_MY_VIDEO:
93 this.videoUrl = this.buildVideoUrl(this.video)
94 break
95
96 case UserNotificationType.NEW_COMMENT_ON_MY_VIDEO:
97 case UserNotificationType.COMMENT_MENTION:
98 this.accountUrl = this.buildAccountUrl(this.comment.account)
99 this.commentUrl = [ this.buildVideoUrl(this.comment.video), { threadId: this.comment.threadId } ]
100 break
101
102 case UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS:
103 this.videoAbuseUrl = '/admin/moderation/video-abuses/list'
104 this.videoUrl = this.buildVideoUrl(this.videoAbuse.video)
105 break
106
107 case UserNotificationType.BLACKLIST_ON_MY_VIDEO:
108 this.videoUrl = this.buildVideoUrl(this.videoBlacklist.video)
109 break
110
111 case UserNotificationType.MY_VIDEO_PUBLISHED:
112 this.videoUrl = this.buildVideoUrl(this.video)
113 break
114
115 case UserNotificationType.MY_VIDEO_IMPORT_SUCCESS:
116 this.videoImportUrl = this.buildVideoImportUrl()
117 this.videoImportIdentifier = this.buildVideoImportIdentifier(this.videoImport)
118 this.videoUrl = this.buildVideoUrl(this.videoImport.video)
119 break
120
121 case UserNotificationType.MY_VIDEO_IMPORT_ERROR:
122 this.videoImportUrl = this.buildVideoImportUrl()
123 this.videoImportIdentifier = this.buildVideoImportIdentifier(this.videoImport)
124 break
125
126 case UserNotificationType.NEW_USER_REGISTRATION:
127 this.accountUrl = this.buildAccountUrl(this.account)
128 break
129
130 case UserNotificationType.NEW_FOLLOW:
131 this.accountUrl = this.buildAccountUrl(this.actorFollow.follower)
132 break
133 }
134 }
135
136 private buildVideoUrl (video: { uuid: string }) {
137 return '/videos/watch/' + video.uuid
138 }
139
140 private buildAccountUrl (account: { name: string, host: string }) {
141 return '/accounts/' + Actor.CREATE_BY_STRING(account.name, account.host)
142 }
143
144 private buildVideoImportUrl () {
145 return '/my-account/video-imports'
146 }
147
148 private buildVideoImportIdentifier (videoImport: { targetUrl?: string, magnetUri?: string, torrentName?: string }) {
149 return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName
150 }
151
152 private setAvatarUrl (actor: { avatarUrl?: string, avatar?: { path: string } }) {
153 actor.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(actor)
154 }
155}
diff --git a/client/src/app/shared/users/user-notification.service.ts b/client/src/app/shared/users/user-notification.service.ts
new file mode 100644
index 000000000..f8a30955d
--- /dev/null
+++ b/client/src/app/shared/users/user-notification.service.ts
@@ -0,0 +1,86 @@
1import { Injectable } from '@angular/core'
2import { HttpClient, HttpParams } from '@angular/common/http'
3import { RestExtractor, RestService } from '../rest'
4import { catchError, map, tap } from 'rxjs/operators'
5import { environment } from '../../../environments/environment'
6import { ResultList, UserNotification as UserNotificationServer, UserNotificationSetting } from '../../../../../shared'
7import { UserNotification } from './user-notification.model'
8import { AuthService } from '../../core'
9import { ComponentPagination } from '../rest/component-pagination.model'
10import { User } from '..'
11import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service'
12
13@Injectable()
14export class UserNotificationService {
15 static BASE_NOTIFICATIONS_URL = environment.apiUrl + '/api/v1/users/me/notifications'
16 static BASE_NOTIFICATION_SETTINGS = environment.apiUrl + '/api/v1/users/me/notification-settings'
17
18 constructor (
19 private auth: AuthService,
20 private authHttp: HttpClient,
21 private restExtractor: RestExtractor,
22 private restService: RestService,
23 private userNotificationSocket: UserNotificationSocket
24 ) {}
25
26 listMyNotifications (pagination: ComponentPagination, unread?: boolean, ignoreLoadingBar = false) {
27 let params = new HttpParams()
28 params = this.restService.addRestGetParams(params, this.restService.componentPaginationToRestPagination(pagination))
29
30 if (unread) params = params.append('unread', `${unread}`)
31
32 const headers = ignoreLoadingBar ? { ignoreLoadingBar: '' } : undefined
33
34 return this.authHttp.get<ResultList<UserNotification>>(UserNotificationService.BASE_NOTIFICATIONS_URL, { params, headers })
35 .pipe(
36 map(res => this.restExtractor.convertResultListDateToHuman(res)),
37 map(res => this.restExtractor.applyToResultListData(res, this.formatNotification.bind(this))),
38 catchError(err => this.restExtractor.handleError(err))
39 )
40 }
41
42 countUnreadNotifications () {
43 return this.listMyNotifications({ currentPage: 1, itemsPerPage: 0 }, true)
44 .pipe(map(n => n.total))
45 }
46
47 markAsRead (notification: UserNotification) {
48 const url = UserNotificationService.BASE_NOTIFICATIONS_URL + '/read'
49
50 const body = { ids: [ notification.id ] }
51 const headers = { ignoreLoadingBar: '' }
52
53 return this.authHttp.post(url, body, { headers })
54 .pipe(
55 map(this.restExtractor.extractDataBool),
56 tap(() => this.userNotificationSocket.dispatch('read')),
57 catchError(res => this.restExtractor.handleError(res))
58 )
59 }
60
61 markAllAsRead () {
62 const url = UserNotificationService.BASE_NOTIFICATIONS_URL + '/read-all'
63 const headers = { ignoreLoadingBar: '' }
64
65 return this.authHttp.post(url, {}, { headers })
66 .pipe(
67 map(this.restExtractor.extractDataBool),
68 tap(() => this.userNotificationSocket.dispatch('read-all')),
69 catchError(res => this.restExtractor.handleError(res))
70 )
71 }
72
73 updateNotificationSettings (user: User, settings: UserNotificationSetting) {
74 const url = UserNotificationService.BASE_NOTIFICATION_SETTINGS
75
76 return this.authHttp.put(url, settings)
77 .pipe(
78 map(this.restExtractor.extractDataBool),
79 catchError(res => this.restExtractor.handleError(res))
80 )
81 }
82
83 private formatNotification (notification: UserNotificationServer) {
84 return new UserNotification(notification)
85 }
86}
diff --git a/client/src/app/shared/users/user-notifications.component.html b/client/src/app/shared/users/user-notifications.component.html
new file mode 100644
index 000000000..0d69e0feb
--- /dev/null
+++ b/client/src/app/shared/users/user-notifications.component.html
@@ -0,0 +1,101 @@
1<div *ngIf="componentPagination.totalItems === 0" class="no-notification" i18n>You don't have notifications.</div>
2
3<div class="notifications" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()">
4 <div *ngFor="let notification of notifications" class="notification" [ngClass]="{ unread: !notification.read }" (click)="markAsRead(notification)">
5
6 <ng-container [ngSwitch]="notification.type">
7 <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION">
8 <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.video.channel.avatarUrl" />
9
10 <div class="message">
11 {{ notification.video.channel.displayName }} published a <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">new video</a>
12 </div>
13 </ng-container>
14
15 <ng-container i18n *ngSwitchCase="UserNotificationType.UNBLACKLIST_ON_MY_VIDEO">
16 <my-global-icon iconName="undo"></my-global-icon>
17
18 <div class="message">
19 Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a> has been unblacklisted
20 </div>
21 </ng-container>
22
23 <ng-container i18n *ngSwitchCase="UserNotificationType.BLACKLIST_ON_MY_VIDEO">
24 <my-global-icon iconName="no"></my-global-icon>
25
26 <div class="message">
27 Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.videoBlacklist.video.name }}</a> has been blacklisted
28 </div>
29 </ng-container>
30
31 <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS">
32 <my-global-icon iconName="alert"></my-global-icon>
33
34 <div class="message">
35 <a (click)="markAsRead(notification)" [routerLink]="notification.videoAbuseUrl">A new video abuse</a> has been created on video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.videoAbuse.video.name }}</a>
36 </div>
37 </ng-container>
38
39 <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_COMMENT_ON_MY_VIDEO">
40 <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.comment.account.avatarUrl" />
41
42 <div class="message">
43 <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.comment.account.displayName }}</a> commented your video <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">{{ notification.comment.video.name }}</a>
44 </div>
45 </ng-container>
46
47 <ng-container i18n *ngSwitchCase="UserNotificationType.MY_VIDEO_PUBLISHED">
48 <my-global-icon iconName="sparkle"></my-global-icon>
49
50 <div class="message">
51 Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a> has been published
52 </div>
53 </ng-container>
54
55 <ng-container i18n *ngSwitchCase="UserNotificationType.MY_VIDEO_IMPORT_SUCCESS">
56 <my-global-icon iconName="cloud-download"></my-global-icon>
57
58 <div class="message">
59 <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">Your video import</a> {{ notification.videoImportIdentifier }} succeeded
60 </div>
61 </ng-container>
62
63 <ng-container i18n *ngSwitchCase="UserNotificationType.MY_VIDEO_IMPORT_ERROR">
64 <my-global-icon iconName="cloud-error"></my-global-icon>
65
66 <div class="message">
67 <a (click)="markAsRead(notification)" [routerLink]="notification.videoImportUrl">Your video import</a> {{ notification.videoImportIdentifier }} failed
68 </div>
69 </ng-container>
70
71 <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_USER_REGISTRATION">
72 <my-global-icon iconName="user-add"></my-global-icon>
73
74 <div class="message">
75 User <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.account.name }} registered</a> on your instance
76 </div>
77 </ng-container>
78
79 <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_FOLLOW">
80 <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.actorFollow.follower.avatarUrl" />
81
82 <div class="message">
83 <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.actorFollow.follower.displayName }}</a> is following
84
85 <ng-container *ngIf="notification.actorFollow.following.type === 'channel'">your channel {{ notification.actorFollow.following.displayName }}</ng-container>
86 <ng-container *ngIf="notification.actorFollow.following.type === 'account'">your account</ng-container>
87 </div>
88 </ng-container>
89
90 <ng-container i18n *ngSwitchCase="UserNotificationType.COMMENT_MENTION">
91 <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.comment.account.avatarUrl" />
92
93 <div class="message">
94 <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.comment.account.displayName }}</a> mentioned you on <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">video {{ notification.comment.video.name }}</a>
95 </div>
96 </ng-container>
97 </ng-container>
98
99 <div class="from-date">{{ notification.createdAt | myFromNow }}</div>
100 </div>
101</div>
diff --git a/client/src/app/shared/users/user-notifications.component.scss b/client/src/app/shared/users/user-notifications.component.scss
new file mode 100644
index 000000000..315d504c9
--- /dev/null
+++ b/client/src/app/shared/users/user-notifications.component.scss
@@ -0,0 +1,51 @@
1@import '_variables';
2@import '_mixins';
3
4.no-notification {
5 display: flex;
6 justify-content: center;
7 align-items: center;
8 padding: 20px 0;
9}
10
11.notification {
12 display: flex;
13 align-items: center;
14 font-size: inherit;
15 padding: 15px 5px 15px 10px;
16 border-bottom: 1px solid rgba(0, 0, 0, 0.10);
17
18 &.unread {
19 background-color: rgba(0, 0, 0, 0.05);
20 }
21
22 my-global-icon {
23 width: 24px;
24 margin-right: 11px;
25 margin-left: 3px;
26
27 @include apply-svg-color(#333);
28 }
29
30 .avatar {
31 @include avatar(30px);
32
33 margin-right: 10px;
34 }
35
36 .message {
37 flex-grow: 1;
38
39 a {
40 font-weight: $font-semibold;
41 }
42 }
43
44 .from-date {
45 font-size: 0.85em;
46 color: $grey-foreground-color;
47 padding-left: 5px;
48 min-width: 70px;
49 text-align: right;
50 }
51}
diff --git a/client/src/app/shared/users/user-notifications.component.ts b/client/src/app/shared/users/user-notifications.component.ts
new file mode 100644
index 000000000..b5f9fd399
--- /dev/null
+++ b/client/src/app/shared/users/user-notifications.component.ts
@@ -0,0 +1,87 @@
1import { Component, Input, OnInit } from '@angular/core'
2import { UserNotificationService } from '@app/shared/users/user-notification.service'
3import { UserNotificationType } from '../../../../../shared'
4import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pagination.model'
5import { Notifier } from '@app/core'
6import { UserNotification } from '@app/shared/users/user-notification.model'
7
8@Component({
9 selector: 'my-user-notifications',
10 templateUrl: 'user-notifications.component.html',
11 styleUrls: [ 'user-notifications.component.scss' ]
12})
13export class UserNotificationsComponent implements OnInit {
14 @Input() ignoreLoadingBar = false
15 @Input() infiniteScroll = true
16 @Input() itemsPerPage = 20
17
18 notifications: UserNotification[] = []
19
20 // So we can access it in the template
21 UserNotificationType = UserNotificationType
22
23 componentPagination: ComponentPagination
24
25 constructor (
26 private userNotificationService: UserNotificationService,
27 private notifier: Notifier
28 ) { }
29
30 ngOnInit () {
31 this.componentPagination = {
32 currentPage: 1,
33 itemsPerPage: this.itemsPerPage, // Reset items per page, because of the @Input() variable
34 totalItems: null
35 }
36
37 this.loadMoreNotifications()
38 }
39
40 loadMoreNotifications () {
41 this.userNotificationService.listMyNotifications(this.componentPagination, undefined, this.ignoreLoadingBar)
42 .subscribe(
43 result => {
44 this.notifications = this.notifications.concat(result.data)
45 this.componentPagination.totalItems = result.total
46 },
47
48 err => this.notifier.error(err.message)
49 )
50 }
51
52 onNearOfBottom () {
53 if (this.infiniteScroll === false) return
54
55 this.componentPagination.currentPage++
56
57 if (hasMoreItems(this.componentPagination)) {
58 this.loadMoreNotifications()
59 }
60 }
61
62 markAsRead (notification: UserNotification) {
63 if (notification.read) return
64
65 this.userNotificationService.markAsRead(notification)
66 .subscribe(
67 () => {
68 notification.read = true
69 },
70
71 err => this.notifier.error(err.message)
72 )
73 }
74
75 markAllAsRead () {
76 this.userNotificationService.markAllAsRead()
77 .subscribe(
78 () => {
79 for (const notification of this.notifications) {
80 notification.read = true
81 }
82 },
83
84 err => this.notifier.error(err.message)
85 )
86 }
87}
diff --git a/client/src/app/shared/users/user.model.ts b/client/src/app/shared/users/user.model.ts
index 9819829fd..c15f1de8c 100644
--- a/client/src/app/shared/users/user.model.ts
+++ b/client/src/app/shared/users/user.model.ts
@@ -1,33 +1,8 @@
1import { 1import { hasUserRight, User as UserServerModel, UserNotificationSetting, UserRight, UserRole, VideoChannel } from '../../../../../shared'
2 Account as AccountServerModel,
3 hasUserRight,
4 User as UserServerModel,
5 UserRight,
6 UserRole,
7 VideoChannel
8} from '../../../../../shared'
9import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type' 2import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type'
10import { Account } from '@app/shared/account/account.model' 3import { Account } from '@app/shared/account/account.model'
11import { Avatar } from '../../../../../shared/models/avatars/avatar.model' 4import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
12 5
13export type UserConstructorHash = {
14 id: number,
15 username: string,
16 email: string,
17 role: UserRole,
18 emailVerified?: boolean,
19 videoQuota?: number,
20 videoQuotaDaily?: number,
21 nsfwPolicy?: NSFWPolicyType,
22 webTorrentEnabled?: boolean,
23 autoPlayVideo?: boolean,
24 createdAt?: Date,
25 account?: AccountServerModel,
26 videoChannels?: VideoChannel[]
27
28 blocked?: boolean
29 blockedReason?: string
30}
31export class User implements UserServerModel { 6export class User implements UserServerModel {
32 id: number 7 id: number
33 username: string 8 username: string
@@ -35,8 +10,11 @@ export class User implements UserServerModel {
35 emailVerified: boolean 10 emailVerified: boolean
36 role: UserRole 11 role: UserRole
37 nsfwPolicy: NSFWPolicyType 12 nsfwPolicy: NSFWPolicyType
13
38 webTorrentEnabled: boolean 14 webTorrentEnabled: boolean
39 autoPlayVideo: boolean 15 autoPlayVideo: boolean
16 videosHistoryEnabled: boolean
17
40 videoQuota: number 18 videoQuota: number
41 videoQuotaDaily: number 19 videoQuotaDaily: number
42 account: Account 20 account: Account
@@ -46,7 +24,9 @@ export class User implements UserServerModel {
46 blocked: boolean 24 blocked: boolean
47 blockedReason?: string 25 blockedReason?: string
48 26
49 constructor (hash: UserConstructorHash) { 27 notificationSettings?: UserNotificationSetting
28
29 constructor (hash: Partial<UserServerModel>) {
50 this.id = hash.id 30 this.id = hash.id
51 this.username = hash.username 31 this.username = hash.username
52 this.email = hash.email 32 this.email = hash.email
@@ -57,11 +37,14 @@ export class User implements UserServerModel {
57 this.videoQuotaDaily = hash.videoQuotaDaily 37 this.videoQuotaDaily = hash.videoQuotaDaily
58 this.nsfwPolicy = hash.nsfwPolicy 38 this.nsfwPolicy = hash.nsfwPolicy
59 this.webTorrentEnabled = hash.webTorrentEnabled 39 this.webTorrentEnabled = hash.webTorrentEnabled
40 this.videosHistoryEnabled = hash.videosHistoryEnabled
60 this.autoPlayVideo = hash.autoPlayVideo 41 this.autoPlayVideo = hash.autoPlayVideo
61 this.createdAt = hash.createdAt 42 this.createdAt = hash.createdAt
62 this.blocked = hash.blocked 43 this.blocked = hash.blocked
63 this.blockedReason = hash.blockedReason 44 this.blockedReason = hash.blockedReason
64 45
46 this.notificationSettings = hash.notificationSettings
47
65 if (hash.account !== undefined) { 48 if (hash.account !== undefined) {
66 this.account = new Account(hash.account) 49 this.account = new Account(hash.account)
67 } 50 }
diff --git a/client/src/app/shared/video-abuse/video-abuse.service.ts b/client/src/app/shared/video-abuse/video-abuse.service.ts
index 61b7e1b98..b0b59ea0c 100644
--- a/client/src/app/shared/video-abuse/video-abuse.service.ts
+++ b/client/src/app/shared/video-abuse/video-abuse.service.ts
@@ -32,9 +32,7 @@ export class VideoAbuseService {
32 32
33 reportVideo (id: number, reason: string) { 33 reportVideo (id: number, reason: string) {
34 const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + id + '/abuse' 34 const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + id + '/abuse'
35 const body = { 35 const body = { reason }
36 reason
37 }
38 36
39 return this.authHttp.post(url, body) 37 return this.authHttp.post(url, body)
40 .pipe( 38 .pipe(
diff --git a/client/src/app/shared/video-blacklist/video-blacklist.service.ts b/client/src/app/shared/video-blacklist/video-blacklist.service.ts
index 7d39fd4f2..94e46d7c2 100644
--- a/client/src/app/shared/video-blacklist/video-blacklist.service.ts
+++ b/client/src/app/shared/video-blacklist/video-blacklist.service.ts
@@ -36,8 +36,11 @@ export class VideoBlacklistService {
36 ) 36 )
37 } 37 }
38 38
39 blacklistVideo (videoId: number, reason?: string) { 39 blacklistVideo (videoId: number, reason: string, unfederate: boolean) {
40 const body = reason ? { reason } : {} 40 const body = {
41 unfederate,
42 reason
43 }
41 44
42 return this.authHttp.post(VideoBlacklistService.BASE_VIDEOS_URL + videoId + '/blacklist', body) 45 return this.authHttp.post(VideoBlacklistService.BASE_VIDEOS_URL + videoId + '/blacklist', body)
43 .pipe( 46 .pipe(
diff --git a/client/src/app/shared/video/abstract-video-list.html b/client/src/app/shared/video/abstract-video-list.html
index 29492351b..1f97bc389 100644
--- a/client/src/app/shared/video/abstract-video-list.html
+++ b/client/src/app/shared/video/abstract-video-list.html
@@ -1,8 +1,11 @@
1<div [ngClass]="{ 'margin-content': marginContent }"> 1<div [ngClass]="{ 'margin-content': marginContent }">
2 <div class="videos-header"> 2 <div class="videos-header">
3 <div *ngIf="titlePage" class="title-page title-page-single"> 3 <div *ngIf="titlePage" class="title-page title-page-single">
4 {{ titlePage }} 4 <div placement="bottom" [ngbTooltip]="titleTooltip" container="body">
5 {{ titlePage }}
6 </div>
5 </div> 7 </div>
8
6 <my-feed [syndicationItems]="syndicationItems"></my-feed> 9 <my-feed [syndicationItems]="syndicationItems"></my-feed>
7 10
8 <div class="moderation-block" *ngIf="displayModerationBlock"> 11 <div class="moderation-block" *ngIf="displayModerationBlock">
diff --git a/client/src/app/shared/video/abstract-video-list.scss b/client/src/app/shared/video/abstract-video-list.scss
index 9fb3fd4d6..292ede698 100644
--- a/client/src/app/shared/video/abstract-video-list.scss
+++ b/client/src/app/shared/video/abstract-video-list.scss
@@ -19,8 +19,8 @@
19 19
20 my-feed { 20 my-feed {
21 display: inline-block; 21 display: inline-block;
22 position: relative;
23 top: 1px; 22 top: 1px;
23 min-width: 60px;
24 } 24 }
25 25
26 .moderation-block { 26 .moderation-block {
diff --git a/client/src/app/shared/video/abstract-video-list.ts b/client/src/app/shared/video/abstract-video-list.ts
index 2d32dd6ad..b0633be4a 100644
--- a/client/src/app/shared/video/abstract-video-list.ts
+++ b/client/src/app/shared/video/abstract-video-list.ts
@@ -3,7 +3,6 @@ import { ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core'
3import { ActivatedRoute, Router } from '@angular/router' 3import { ActivatedRoute, Router } from '@angular/router'
4import { Location } from '@angular/common' 4import { Location } from '@angular/common'
5import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive' 5import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive'
6import { NotificationsService } from 'angular2-notifications'
7import { fromEvent, Observable, Subscription } from 'rxjs' 6import { fromEvent, Observable, Subscription } from 'rxjs'
8import { AuthService } from '../../core/auth' 7import { AuthService } from '../../core/auth'
9import { ComponentPagination } from '../rest/component-pagination.model' 8import { ComponentPagination } from '../rest/component-pagination.model'
@@ -13,6 +12,7 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
13import { ScreenService } from '@app/shared/misc/screen.service' 12import { ScreenService } from '@app/shared/misc/screen.service'
14import { OwnerDisplayType } from '@app/shared/video/video-miniature.component' 13import { OwnerDisplayType } from '@app/shared/video/video-miniature.component'
15import { Syndication } from '@app/shared/video/syndication.model' 14import { Syndication } from '@app/shared/video/syndication.model'
15import { Notifier } from '@app/core'
16 16
17export abstract class AbstractVideoList implements OnInit, OnDestroy { 17export abstract class AbstractVideoList implements OnInit, OnDestroy {
18 private static LINES_PER_PAGE = 4 18 private static LINES_PER_PAGE = 4
@@ -39,11 +39,12 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy {
39 ownerDisplayType: OwnerDisplayType = 'account' 39 ownerDisplayType: OwnerDisplayType = 'account'
40 firstLoadedPage: number 40 firstLoadedPage: number
41 displayModerationBlock = false 41 displayModerationBlock = false
42 titleTooltip: string
42 43
43 protected baseVideoWidth = 215 44 protected baseVideoWidth = 215
44 protected baseVideoHeight = 205 45 protected baseVideoHeight = 205
45 46
46 protected abstract notificationsService: NotificationsService 47 protected abstract notifier: Notifier
47 protected abstract authService: AuthService 48 protected abstract authService: AuthService
48 protected abstract router: Router 49 protected abstract router: Router
49 protected abstract route: ActivatedRoute 50 protected abstract route: ActivatedRoute
@@ -157,7 +158,7 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy {
157 }, 158 },
158 error => { 159 error => {
159 this.loadingPage[page] = false 160 this.loadingPage[page] = false
160 this.notificationsService.error(this.i18n('Error'), error.message) 161 this.notifier.error(error.message)
161 } 162 }
162 ) 163 )
163 } 164 }
diff --git a/client/src/app/shared/video/feed.component.html b/client/src/app/shared/video/feed.component.html
index 16116ba88..f7624ec01 100644
--- a/client/src/app/shared/video/feed.component.html
+++ b/client/src/app/shared/video/feed.component.html
@@ -1,10 +1,11 @@
1<div class="video-feed"> 1<div class="video-feed">
2 <span 2 <my-global-icon
3 *ngIf="syndicationItems.length !== 0" [ngbPopover]="feedsList" [autoClose]="true" placement="bottom" 3 *ngIf="syndicationItems.length !== 0" [ngbPopover]="feedsList" [autoClose]="true" placement="bottom"
4 class="icon icon-syndication" role="button" 4 class="icon-syndication" role="button" iconName="syndication"
5 ></span> 5 >
6 </my-global-icon>
6 7
7 <ng-template #feedsList> 8 <ng-template #feedsList>
8 <a *ngFor="let item of syndicationItems" [href]="item.url" target="_blank" rel="noopener noreferrer">{{ item.label }}</a> 9 <a *ngFor="let item of syndicationItems" [href]="item.url" target="_blank" rel="noopener noreferrer">{{ item.label }}</a>
9 </ng-template> 10 </ng-template>
10</div> \ No newline at end of file 11</div>
diff --git a/client/src/app/shared/video/feed.component.scss b/client/src/app/shared/video/feed.component.scss
index 385764be0..ed1dc17d3 100644
--- a/client/src/app/shared/video/feed.component.scss
+++ b/client/src/app/shared/video/feed.component.scss
@@ -1,3 +1,4 @@
1@import '_variables';
1@import '_mixins'; 2@import '_mixins';
2 3
3.video-feed { 4.video-feed {
@@ -6,14 +7,12 @@
6 display: block; 7 display: block;
7 } 8 }
8 9
9 .icon { 10 my-global-icon {
10 @include icon(12px); 11 cursor: pointer;
12 width: 12px;
13 position: relative;
14 top: -2px;
11 15
12 &.icon-syndication { 16 @include apply-svg-color(var(--mainForegroundColor))
13 position: relative;
14 top: -2px;
15 background-color: var(--mainForegroundColor);
16 mask-image: url('../../../assets/images/global/syndication.svg');
17 }
18 } 17 }
19} \ No newline at end of file 18}
diff --git a/client/src/app/shared/video/video-miniature.component.scss b/client/src/app/shared/video/video-miniature.component.scss
index 895879adc..f44bdf9a9 100644
--- a/client/src/app/shared/video/video-miniature.component.scss
+++ b/client/src/app/shared/video/video-miniature.component.scss
@@ -50,10 +50,10 @@
50 text-overflow: ellipsis; 50 text-overflow: ellipsis;
51 white-space: nowrap; 51 white-space: nowrap;
52 font-size: 13px; 52 font-size: 13px;
53 color: #585858; 53 color: $grey-foreground-color;
54 54
55 &:hover { 55 &:hover {
56 color: #303030; 56 color: $grey-foreground-hover-color;
57 } 57 }
58 } 58 }
59 } 59 }
diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts
index b92c96450..6ea83d13b 100644
--- a/client/src/app/shared/video/video.model.ts
+++ b/client/src/app/shared/video/video.model.ts
@@ -53,7 +53,7 @@ export class Video implements VideoServerModel {
53 displayName: string 53 displayName: string
54 url: string 54 url: string
55 host: string 55 host: string
56 avatar: Avatar 56 avatar?: Avatar
57 } 57 }
58 58
59 channel: { 59 channel: {
@@ -63,7 +63,7 @@ export class Video implements VideoServerModel {
63 displayName: string 63 displayName: string
64 url: string 64 url: string
65 host: string 65 host: string
66 avatar: Avatar 66 avatar?: Avatar
67 } 67 }
68 68
69 userHistory?: { 69 userHistory?: {