diff options
Diffstat (limited to 'client/src/app/shared')
90 files changed, 2131 insertions, 342 deletions
diff --git a/client/src/app/shared/account/account.model.ts b/client/src/app/shared/account/account.model.ts index 42f2cfeaf..c5cd2051c 100644 --- a/client/src/app/shared/account/account.model.ts +++ b/client/src/app/shared/account/account.model.ts | |||
@@ -5,6 +5,10 @@ export class Account extends Actor implements ServerAccount { | |||
5 | displayName: string | 5 | displayName: string |
6 | description: string | 6 | description: string |
7 | nameWithHost: string | 7 | nameWithHost: string |
8 | mutedByUser: boolean | ||
9 | mutedByInstance: boolean | ||
10 | mutedServerByUser: boolean | ||
11 | mutedServerByInstance: boolean | ||
8 | 12 | ||
9 | userId?: number | 13 | userId?: number |
10 | 14 | ||
@@ -15,5 +19,10 @@ export class Account extends Actor implements ServerAccount { | |||
15 | this.description = hash.description | 19 | this.description = hash.description |
16 | this.userId = hash.userId | 20 | this.userId = hash.userId |
17 | this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host) | 21 | this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host) |
22 | |||
23 | this.mutedByUser = false | ||
24 | this.mutedByInstance = false | ||
25 | this.mutedServerByUser = false | ||
26 | this.mutedServerByInstance = false | ||
18 | } | 27 | } |
19 | } | 28 | } |
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/blocklist/account-block.model.ts b/client/src/app/shared/blocklist/account-block.model.ts new file mode 100644 index 000000000..e7b433d88 --- /dev/null +++ b/client/src/app/shared/blocklist/account-block.model.ts | |||
@@ -0,0 +1,14 @@ | |||
1 | import { AccountBlock as AccountBlockServer } from '../../../../../shared' | ||
2 | import { Account } from '../account/account.model' | ||
3 | |||
4 | export class AccountBlock implements AccountBlockServer { | ||
5 | byAccount: Account | ||
6 | blockedAccount: Account | ||
7 | createdAt: Date | string | ||
8 | |||
9 | constructor (block: AccountBlockServer) { | ||
10 | this.byAccount = new Account(block.byAccount) | ||
11 | this.blockedAccount = new Account(block.blockedAccount) | ||
12 | this.createdAt = block.createdAt | ||
13 | } | ||
14 | } | ||
diff --git a/client/src/app/shared/blocklist/blocklist.service.ts b/client/src/app/shared/blocklist/blocklist.service.ts new file mode 100644 index 000000000..c1f7312f0 --- /dev/null +++ b/client/src/app/shared/blocklist/blocklist.service.ts | |||
@@ -0,0 +1,135 @@ | |||
1 | import { Injectable } from '@angular/core' | ||
2 | import { environment } from '../../../environments/environment' | ||
3 | import { HttpClient, HttpParams } from '@angular/common/http' | ||
4 | import { RestExtractor, RestPagination, RestService } from '../rest' | ||
5 | import { SortMeta } from 'primeng/api' | ||
6 | import { catchError, map } from 'rxjs/operators' | ||
7 | import { AccountBlock as AccountBlockServer, ResultList, ServerBlock } from '../../../../../shared' | ||
8 | import { Account } from '@app/shared/account/account.model' | ||
9 | import { AccountBlock } from '@app/shared/blocklist/account-block.model' | ||
10 | |||
11 | @Injectable() | ||
12 | export class BlocklistService { | ||
13 | static BASE_USER_BLOCKLIST_URL = environment.apiUrl + '/api/v1/users/me/blocklist' | ||
14 | static BASE_SERVER_BLOCKLIST_URL = environment.apiUrl + '/api/v1/server/blocklist' | ||
15 | |||
16 | constructor ( | ||
17 | private authHttp: HttpClient, | ||
18 | private restExtractor: RestExtractor, | ||
19 | private restService: RestService | ||
20 | ) { } | ||
21 | |||
22 | /*********************** User -> Account blocklist ***********************/ | ||
23 | |||
24 | getUserAccountBlocklist (pagination: RestPagination, sort: SortMeta) { | ||
25 | let params = new HttpParams() | ||
26 | params = this.restService.addRestGetParams(params, pagination, sort) | ||
27 | |||
28 | return this.authHttp.get<ResultList<AccountBlock>>(BlocklistService.BASE_USER_BLOCKLIST_URL + '/accounts', { params }) | ||
29 | .pipe( | ||
30 | map(res => this.restExtractor.convertResultListDateToHuman(res)), | ||
31 | map(res => this.restExtractor.applyToResultListData(res, this.formatAccountBlock.bind(this))), | ||
32 | catchError(err => this.restExtractor.handleError(err)) | ||
33 | ) | ||
34 | } | ||
35 | |||
36 | blockAccountByUser (account: Account) { | ||
37 | const body = { accountName: account.nameWithHost } | ||
38 | |||
39 | return this.authHttp.post(BlocklistService.BASE_USER_BLOCKLIST_URL + '/accounts', body) | ||
40 | .pipe(catchError(err => this.restExtractor.handleError(err))) | ||
41 | } | ||
42 | |||
43 | unblockAccountByUser (account: Account) { | ||
44 | const path = BlocklistService.BASE_USER_BLOCKLIST_URL + '/accounts/' + account.nameWithHost | ||
45 | |||
46 | return this.authHttp.delete(path) | ||
47 | .pipe(catchError(err => this.restExtractor.handleError(err))) | ||
48 | } | ||
49 | |||
50 | /*********************** User -> Server blocklist ***********************/ | ||
51 | |||
52 | getUserServerBlocklist (pagination: RestPagination, sort: SortMeta) { | ||
53 | let params = new HttpParams() | ||
54 | params = this.restService.addRestGetParams(params, pagination, sort) | ||
55 | |||
56 | return this.authHttp.get<ResultList<ServerBlock>>(BlocklistService.BASE_USER_BLOCKLIST_URL + '/servers', { params }) | ||
57 | .pipe( | ||
58 | map(res => this.restExtractor.convertResultListDateToHuman(res)), | ||
59 | catchError(err => this.restExtractor.handleError(err)) | ||
60 | ) | ||
61 | } | ||
62 | |||
63 | blockServerByUser (host: string) { | ||
64 | const body = { host } | ||
65 | |||
66 | return this.authHttp.post(BlocklistService.BASE_USER_BLOCKLIST_URL + '/servers', body) | ||
67 | .pipe(catchError(err => this.restExtractor.handleError(err))) | ||
68 | } | ||
69 | |||
70 | unblockServerByUser (host: string) { | ||
71 | const path = BlocklistService.BASE_USER_BLOCKLIST_URL + '/servers/' + host | ||
72 | |||
73 | return this.authHttp.delete(path) | ||
74 | .pipe(catchError(err => this.restExtractor.handleError(err))) | ||
75 | } | ||
76 | |||
77 | /*********************** Instance -> Account blocklist ***********************/ | ||
78 | |||
79 | getInstanceAccountBlocklist (pagination: RestPagination, sort: SortMeta) { | ||
80 | let params = new HttpParams() | ||
81 | params = this.restService.addRestGetParams(params, pagination, sort) | ||
82 | |||
83 | return this.authHttp.get<ResultList<AccountBlock>>(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/accounts', { params }) | ||
84 | .pipe( | ||
85 | map(res => this.restExtractor.convertResultListDateToHuman(res)), | ||
86 | map(res => this.restExtractor.applyToResultListData(res, this.formatAccountBlock.bind(this))), | ||
87 | catchError(err => this.restExtractor.handleError(err)) | ||
88 | ) | ||
89 | } | ||
90 | |||
91 | blockAccountByInstance (account: Account) { | ||
92 | const body = { accountName: account.nameWithHost } | ||
93 | |||
94 | return this.authHttp.post(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/accounts', body) | ||
95 | .pipe(catchError(err => this.restExtractor.handleError(err))) | ||
96 | } | ||
97 | |||
98 | unblockAccountByInstance (account: Account) { | ||
99 | const path = BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/accounts/' + account.nameWithHost | ||
100 | |||
101 | return this.authHttp.delete(path) | ||
102 | .pipe(catchError(err => this.restExtractor.handleError(err))) | ||
103 | } | ||
104 | |||
105 | /*********************** Instance -> Server blocklist ***********************/ | ||
106 | |||
107 | getInstanceServerBlocklist (pagination: RestPagination, sort: SortMeta) { | ||
108 | let params = new HttpParams() | ||
109 | params = this.restService.addRestGetParams(params, pagination, sort) | ||
110 | |||
111 | return this.authHttp.get<ResultList<ServerBlock>>(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/servers', { params }) | ||
112 | .pipe( | ||
113 | map(res => this.restExtractor.convertResultListDateToHuman(res)), | ||
114 | catchError(err => this.restExtractor.handleError(err)) | ||
115 | ) | ||
116 | } | ||
117 | |||
118 | blockServerByInstance (host: string) { | ||
119 | const body = { host } | ||
120 | |||
121 | return this.authHttp.post(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/servers', body) | ||
122 | .pipe(catchError(err => this.restExtractor.handleError(err))) | ||
123 | } | ||
124 | |||
125 | unblockServerByInstance (host: string) { | ||
126 | const path = BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/servers/' + host | ||
127 | |||
128 | return this.authHttp.delete(path) | ||
129 | .pipe(catchError(err => this.restExtractor.handleError(err))) | ||
130 | } | ||
131 | |||
132 | private formatAccountBlock (accountBlock: AccountBlockServer) { | ||
133 | return new AccountBlock(accountBlock) | ||
134 | } | ||
135 | } | ||
diff --git a/client/src/app/shared/blocklist/index.ts b/client/src/app/shared/blocklist/index.ts new file mode 100644 index 000000000..5886ca07e --- /dev/null +++ b/client/src/app/shared/blocklist/index.ts | |||
@@ -0,0 +1,2 @@ | |||
1 | export * from './blocklist.service' | ||
2 | export * from './account-block.model' | ||
diff --git a/client/src/app/shared/buttons/action-dropdown.component.html b/client/src/app/shared/buttons/action-dropdown.component.html index 8110e2515..114b1d71f 100644 --- a/client/src/app/shared/buttons/action-dropdown.component.html +++ b/client/src/app/shared/buttons/action-dropdown.component.html | |||
@@ -1,17 +1,27 @@ | |||
1 | <div class="dropdown-root" ngbDropdown [placement]="placement"> | 1 | <div class="dropdown-root" ngbDropdown [placement]="placement"> |
2 | <div class="action-button" [ngClass]="{ small: buttonSize === 'small' }" ngbDropdownToggle role="button"> | 2 | <div |
3 | <span class="icon icon-action"></span> | 3 | class="action-button" [ngClass]="{ small: buttonSize === 'small', grey: theme === 'grey', orange: theme === 'orange' }" |
4 | ngbDropdownToggle role="button" | ||
5 | > | ||
6 | <my-global-icon *ngIf="!label" class="more-icon" iconName="more"></my-global-icon> | ||
7 | <span *ngIf="label" class="dropdown-toggle">{{ label }}</span> | ||
4 | </div> | 8 | </div> |
5 | 9 | ||
6 | <div ngbDropdownMenu class="dropdown-menu"> | 10 | <div ngbDropdownMenu class="dropdown-menu"> |
7 | <ng-container *ngFor="let action of actions"> | 11 | <ng-container *ngFor="let actions of getActions()"> |
8 | <div class="dropdown-item" *ngIf="action.isDisplayed === undefined || action.isDisplayed(entry) === true"> | 12 | |
9 | <a *ngIf="action.linkBuilder" class="dropdown-item" [routerLink]="action.linkBuilder(entry)">{{ action.label }}</a> | 13 | <ng-container *ngFor="let action of actions"> |
14 | <ng-container *ngIf="action.isDisplayed === undefined || action.isDisplayed(entry) === true"> | ||
15 | <a *ngIf="action.linkBuilder" class="dropdown-item" [routerLink]="action.linkBuilder(entry)">{{ action.label }}</a> | ||
16 | |||
17 | <span *ngIf="!action.linkBuilder" class="custom-action dropdown-item" (click)="action.handler(entry)" role="button"> | ||
18 | {{ action.label }} | ||
19 | </span> | ||
20 | </ng-container> | ||
21 | </ng-container> | ||
22 | |||
23 | <div class="dropdown-divider"></div> | ||
10 | 24 | ||
11 | <span *ngIf="!action.linkBuilder" class="custom-action" class="dropdown-item" (click)="action.handler(entry)" role="button"> | ||
12 | {{ action.label }} | ||
13 | </span> | ||
14 | </div> | ||
15 | </ng-container> | 25 | </ng-container> |
16 | </div> | 26 | </div> |
17 | </div> \ No newline at end of file | 27 | </div> |
diff --git a/client/src/app/shared/buttons/action-dropdown.component.scss b/client/src/app/shared/buttons/action-dropdown.component.scss index 00f120fb8..985b2ca88 100644 --- a/client/src/app/shared/buttons/action-dropdown.component.scss +++ b/client/src/app/shared/buttons/action-dropdown.component.scss | |||
@@ -1,9 +1,20 @@ | |||
1 | @import '_variables'; | 1 | @import '_variables'; |
2 | @import '_mixins'; | 2 | @import '_mixins'; |
3 | 3 | ||
4 | .dropdown-divider:last-child { | ||
5 | display: none; | ||
6 | } | ||
7 | |||
4 | .action-button { | 8 | .action-button { |
5 | @include peertube-button; | 9 | @include peertube-button; |
6 | @include grey-button; | 10 | |
11 | &.grey { | ||
12 | @include grey-button; | ||
13 | } | ||
14 | |||
15 | &.orange { | ||
16 | @include orange-button; | ||
17 | } | ||
7 | 18 | ||
8 | display: inline-block; | 19 | display: inline-block; |
9 | padding: 0 10px; | 20 | padding: 0 10px; |
@@ -13,14 +24,11 @@ | |||
13 | } | 24 | } |
14 | 25 | ||
15 | &:hover, &:active, &:focus { | 26 | &:hover, &:active, &:focus { |
16 | background-color: $grey-color; | 27 | background-color: $grey-background-color; |
17 | } | 28 | } |
18 | 29 | ||
19 | .icon-action { | 30 | .more-icon { |
20 | @include icon(21px); | 31 | width: 21px; |
21 | |||
22 | background-image: url('../../../assets/images/video/more.svg'); | ||
23 | top: -1px; | ||
24 | } | 32 | } |
25 | 33 | ||
26 | &.small { | 34 | &.small { |
@@ -30,9 +38,19 @@ | |||
30 | } | 38 | } |
31 | } | 39 | } |
32 | 40 | ||
41 | .dropdown-toggle::after { | ||
42 | position: relative; | ||
43 | top: 1px; | ||
44 | } | ||
45 | |||
33 | .dropdown-menu { | 46 | .dropdown-menu { |
34 | .dropdown-item { | 47 | .dropdown-item { |
35 | cursor: pointer; | 48 | cursor: pointer; |
36 | color: #000 !important; | 49 | color: #000 !important; |
50 | |||
51 | a, span { | ||
52 | display: block; | ||
53 | width: 100%; | ||
54 | } | ||
37 | } | 55 | } |
38 | } \ No newline at end of file | 56 | } |
diff --git a/client/src/app/shared/buttons/action-dropdown.component.ts b/client/src/app/shared/buttons/action-dropdown.component.ts index 1838ff697..275e2b51e 100644 --- a/client/src/app/shared/buttons/action-dropdown.component.ts +++ b/client/src/app/shared/buttons/action-dropdown.component.ts | |||
@@ -2,9 +2,9 @@ import { Component, Input } from '@angular/core' | |||
2 | 2 | ||
3 | export type DropdownAction<T> = { | 3 | export type DropdownAction<T> = { |
4 | label?: string | 4 | label?: string |
5 | handler?: (T) => any | 5 | handler?: (a: T) => any |
6 | linkBuilder?: (T) => (string | number)[] | 6 | linkBuilder?: (a: T) => (string | number)[] |
7 | isDisplayed?: (T) => boolean | 7 | isDisplayed?: (a: T) => boolean |
8 | } | 8 | } |
9 | 9 | ||
10 | @Component({ | 10 | @Component({ |
@@ -14,8 +14,16 @@ export type DropdownAction<T> = { | |||
14 | }) | 14 | }) |
15 | 15 | ||
16 | export class ActionDropdownComponent<T> { | 16 | export class ActionDropdownComponent<T> { |
17 | @Input() actions: DropdownAction<T>[] = [] | 17 | @Input() actions: DropdownAction<T>[] | DropdownAction<T>[][] = [] |
18 | @Input() entry: T | 18 | @Input() entry: T |
19 | @Input() placement = 'left' | 19 | @Input() placement = 'bottom-left' |
20 | @Input() buttonSize: 'normal' | 'small' = 'normal' | 20 | @Input() buttonSize: 'normal' | 'small' = 'normal' |
21 | @Input() label: string | ||
22 | @Input() theme: 'orange' | 'grey' = 'grey' | ||
23 | |||
24 | getActions () { | ||
25 | if (this.actions.length !== 0 && Array.isArray(this.actions[0])) return this.actions | ||
26 | |||
27 | return [ this.actions ] | ||
28 | } | ||
21 | } | 29 | } |
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 967cb1409..a91e9c7eb 100644 --- a/client/src/app/shared/buttons/button.component.ts +++ b/client/src/app/shared/buttons/button.component.ts | |||
@@ -1,4 +1,5 @@ | |||
1 | import { Component, Input } from '@angular/core' | 1 | import { Component, Input } from '@angular/core' |
2 | import { GlobalIconName } from '@app/shared/icons/global-icon.component' | ||
2 | 3 | ||
3 | @Component({ | 4 | @Component({ |
4 | selector: 'my-button', | 5 | selector: 'my-button', |
@@ -8,9 +9,9 @@ import { Component, Input } from '@angular/core' | |||
8 | 9 | ||
9 | export class ButtonComponent { | 10 | export class ButtonComponent { |
10 | @Input() label = '' | 11 | @Input() label = '' |
11 | @Input() className = undefined | 12 | @Input() className: string = undefined |
12 | @Input() icon = undefined | 13 | @Input() icon: GlobalIconName = undefined |
13 | @Input() title = undefined | 14 | @Input() title: string = undefined |
14 | 15 | ||
15 | getTitle () { | 16 | getTitle () { |
16 | return this.title || this.label | 17 | return this.title || this.label |
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/buttons/edit-button.component.ts b/client/src/app/shared/buttons/edit-button.component.ts index 7abaacc26..1fe4f7b30 100644 --- a/client/src/app/shared/buttons/edit-button.component.ts +++ b/client/src/app/shared/buttons/edit-button.component.ts | |||
@@ -8,5 +8,5 @@ import { Component, Input } from '@angular/core' | |||
8 | 8 | ||
9 | export class EditButtonComponent { | 9 | export class EditButtonComponent { |
10 | @Input() label: string | 10 | @Input() label: string |
11 | @Input() routerLink = [] | 11 | @Input() routerLink: string[] = [] |
12 | } | 12 | } |
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 | |||
8 | input[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 @@ | |||
1 | import { Component, ElementRef, HostListener, OnInit, ViewChild } from '@angular/core' | ||
2 | import { ConfirmService } from '@app/core/confirm/confirm.service' | ||
3 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
4 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||
5 | import { 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 | }) | ||
12 | export 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 @@ | |||
1 | import { FormGroup } from '@angular/forms' | 1 | import { FormGroup } from '@angular/forms' |
2 | import { BuildFormArgument, BuildFormDefaultValues, FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | 2 | import { BuildFormArgument, BuildFormDefaultValues, FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' |
3 | 3 | ||
4 | export type FormReactiveErrors = { [ id: string ]: string } | 4 | export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors } |
5 | export type FormReactiveValidationMessages = { | 5 | export type FormReactiveValidationMessages = { |
6 | [ id: string ]: { | 6 | [ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages |
7 | [ name: string ]: string | ||
8 | } | ||
9 | } | 7 | } |
10 | 8 | ||
11 | export abstract class FormReactive { | 9 | export 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 | } |
9 | export type BuildFormArgument = { | 9 | export type BuildFormArgument = { |
10 | [ id: string ]: BuildFormValidator | 10 | [ id: string ]: BuildFormValidator | BuildFormArgument |
11 | } | 11 | } |
12 | export type BuildFormDefaultValues = { | 12 | export 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 @@ | |||
1 | export * from './custom-config-validators.service' | 1 | export * from './custom-config-validators.service' |
2 | export * from './form-validator.service' | 2 | export * from './form-validator.service' |
3 | export * from './host' | 3 | export * from './host' |
4 | export * from './instance-validators.service' | ||
4 | export * from './login-validators.service' | 5 | export * from './login-validators.service' |
5 | export * from './reset-password-validators.service' | 6 | export * from './reset-password-validators.service' |
6 | export * from './user-validators.service' | 7 | export * 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 @@ | |||
1 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
2 | import { Validators } from '@angular/forms' | ||
3 | import { BuildFormValidator } from '@app/shared' | ||
4 | import { Injectable } from '@angular/core' | ||
5 | |||
6 | @Injectable() | ||
7 | export 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 1fd1cdf68..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,24 +88,24 @@ 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 | ||
101 | this.USER_DESCRIPTION = { | 101 | this.USER_DESCRIPTION = { |
102 | VALIDATORS: [ | 102 | VALIDATORS: [ |
103 | Validators.minLength(3), | 103 | Validators.minLength(3), |
104 | Validators.maxLength(250) | 104 | Validators.maxLength(1000) |
105 | ], | 105 | ], |
106 | MESSAGES: { | 106 | MESSAGES: { |
107 | 'minlength': this.i18n('Description must be at least 3 characters long.'), | 107 | 'minlength': this.i18n('Description must be at least 3 characters long.'), |
108 | 'maxlength': this.i18n('Description cannot be more than 250 characters long.') | 108 | 'maxlength': this.i18n('Description cannot be more than 1000 characters long.') |
109 | } | 109 | } |
110 | } | 110 | } |
111 | 111 | ||
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-change-ownership-validators.service.ts b/client/src/app/shared/forms/form-validators/video-change-ownership-validators.service.ts index 087b80b44..c6fbb7538 100644 --- a/client/src/app/shared/forms/form-validators/video-change-ownership-validators.service.ts +++ b/client/src/app/shared/forms/form-validators/video-change-ownership-validators.service.ts | |||
@@ -1,5 +1,5 @@ | |||
1 | import { I18n } from '@ngx-translate/i18n-polyfill' | 1 | import { I18n } from '@ngx-translate/i18n-polyfill' |
2 | import { Validators } from '@angular/forms' | 2 | import { AbstractControl, ValidationErrors, Validators } from '@angular/forms' |
3 | import { Injectable } from '@angular/core' | 3 | import { Injectable } from '@angular/core' |
4 | import { BuildFormValidator } from '@app/shared' | 4 | import { BuildFormValidator } from '@app/shared' |
5 | 5 | ||
@@ -9,10 +9,19 @@ export class VideoChangeOwnershipValidatorsService { | |||
9 | 9 | ||
10 | constructor (private i18n: I18n) { | 10 | constructor (private i18n: I18n) { |
11 | this.USERNAME = { | 11 | this.USERNAME = { |
12 | VALIDATORS: [ Validators.required ], | 12 | VALIDATORS: [ Validators.required, this.localAccountValidator ], |
13 | MESSAGES: { | 13 | MESSAGES: { |
14 | 'required': this.i18n('The username is required.') | 14 | 'required': this.i18n('The username is required.'), |
15 | 'localAccountOnly': this.i18n('You can only transfer ownership to a local account') | ||
15 | } | 16 | } |
16 | } | 17 | } |
17 | } | 18 | } |
19 | |||
20 | localAccountValidator (control: AbstractControl): ValidationErrors { | ||
21 | if (control.value.includes('@')) { | ||
22 | return { 'localAccountOnly': true } | ||
23 | } | ||
24 | |||
25 | return null | ||
26 | } | ||
18 | } | 27 | } |
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 1ce3a0dca..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,50 +14,50 @@ 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 | ||
42 | this.VIDEO_CHANNEL_DESCRIPTION = { | 42 | this.VIDEO_CHANNEL_DESCRIPTION = { |
43 | VALIDATORS: [ | 43 | VALIDATORS: [ |
44 | Validators.minLength(3), | 44 | Validators.minLength(3), |
45 | Validators.maxLength(500) | 45 | Validators.maxLength(1000) |
46 | ], | 46 | ], |
47 | MESSAGES: { | 47 | MESSAGES: { |
48 | 'minlength': i18n('Description must be at least 3 characters long.'), | 48 | 'minlength': i18n('Description must be at least 3 characters long.'), |
49 | 'maxlength': i18n('Description cannot be more than 500 characters long.') | 49 | 'maxlength': i18n('Description cannot be more than 1000 characters long.') |
50 | } | 50 | } |
51 | } | 51 | } |
52 | 52 | ||
53 | this.VIDEO_CHANNEL_SUPPORT = { | 53 | this.VIDEO_CHANNEL_SUPPORT = { |
54 | VALIDATORS: [ | 54 | VALIDATORS: [ |
55 | Validators.minLength(3), | 55 | Validators.minLength(3), |
56 | Validators.maxLength(500) | 56 | Validators.maxLength(1000) |
57 | ], | 57 | ], |
58 | MESSAGES: { | 58 | MESSAGES: { |
59 | 'minlength': i18n('Support text must be at least 3 characters long.'), | 59 | 'minlength': i18n('Support text must be at least 3 characters long.'), |
60 | 'maxlength': i18n('Support text cannot be more than 500 characters long.') | 60 | 'maxlength': i18n('Support text cannot be more than 1000 characters long.') |
61 | } | 61 | } |
62 | } | 62 | } |
63 | } | 63 | } |
diff --git a/client/src/app/shared/forms/form-validators/video-validators.service.ts b/client/src/app/shared/forms/form-validators/video-validators.service.ts index 396be6f3b..81ed0666f 100644 --- a/client/src/app/shared/forms/form-validators/video-validators.service.ts +++ b/client/src/app/shared/forms/form-validators/video-validators.service.ts | |||
@@ -79,10 +79,10 @@ export class VideoValidatorsService { | |||
79 | } | 79 | } |
80 | 80 | ||
81 | this.VIDEO_SUPPORT = { | 81 | this.VIDEO_SUPPORT = { |
82 | VALIDATORS: [ Validators.minLength(3), Validators.maxLength(500) ], | 82 | VALIDATORS: [ Validators.minLength(3), Validators.maxLength(1000) ], |
83 | MESSAGES: { | 83 | MESSAGES: { |
84 | 'minlength': this.i18n('Video support must be at least 3 characters long.'), | 84 | 'minlength': this.i18n('Video support must be at least 3 characters long.'), |
85 | 'maxlength': this.i18n('Video support cannot be more than 500 characters long.') | 85 | 'maxlength': this.i18n('Video support cannot be more than 1000 characters long.') |
86 | } | 86 | } |
87 | } | 87 | } |
88 | 88 | ||
diff --git a/client/src/app/shared/forms/index.ts b/client/src/app/shared/forms/index.ts index 41c321c4c..8febbfee9 100644 --- a/client/src/app/shared/forms/index.ts +++ b/client/src/app/shared/forms/index.ts | |||
@@ -1,3 +1,4 @@ | |||
1 | export * from './form-validators' | 1 | export * from './form-validators' |
2 | export * from './form-reactive' | 2 | export * from './form-reactive' |
3 | export * from './reactive-file.component' | 3 | export * from './reactive-file.component' |
4 | export * from './textarea-autoresize.directive' | ||
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 @@ | |||
1 | import { debounceTime, distinctUntilChanged } from 'rxjs/operators' | 1 | import { debounceTime, distinctUntilChanged } from 'rxjs/operators' |
2 | import { Component, forwardRef, Input, OnInit } from '@angular/core' | 2 | import { Component, forwardRef, Input, OnInit } from '@angular/core' |
3 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | 3 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' |
4 | import { MarkdownService } from '@app/videos/shared' | ||
5 | import { Subject } from 'rxjs' | 4 | import { Subject } from 'rxjs' |
6 | import truncate from 'lodash-es/truncate' | 5 | import truncate from 'lodash-es/truncate' |
7 | import { ScreenService } from '@app/shared/misc/screen.service' | 6 | import { ScreenService } from '@app/shared/misc/screen.service' |
7 | import { 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 38691f050..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="form-group"> | 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.scss b/client/src/app/shared/forms/peertube-checkbox.component.scss index ee133f190..6e4e20775 100644 --- a/client/src/app/shared/forms/peertube-checkbox.component.scss +++ b/client/src/app/shared/forms/peertube-checkbox.component.scss | |||
@@ -1,7 +1,7 @@ | |||
1 | @import '_variables'; | 1 | @import '_variables'; |
2 | @import '_mixins'; | 2 | @import '_mixins'; |
3 | 3 | ||
4 | .form-group { | 4 | .root { |
5 | display: flex; | 5 | display: flex; |
6 | 6 | ||
7 | .form-group-checkbox { | 7 | .form-group-checkbox { |
@@ -20,6 +20,10 @@ | |||
20 | } | 20 | } |
21 | } | 21 | } |
22 | 22 | ||
23 | label { | ||
24 | margin-bottom: 0; | ||
25 | } | ||
26 | |||
23 | my-help { | 27 | my-help { |
24 | position: relative; | 28 | position: relative; |
25 | top: -2px; | 29 | top: -2px; |
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 @@ | |||
1 | import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core' | 1 | import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core' |
2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | 2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' |
3 | import { NotificationsService } from 'angular2-notifications' | 3 | import { Notifier } from '@app/core' |
4 | import { I18n } from '@ngx-translate/i18n-polyfill' | 4 | import { 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/forms/textarea-autoresize.directive.ts b/client/src/app/shared/forms/textarea-autoresize.directive.ts new file mode 100644 index 000000000..f8c855c16 --- /dev/null +++ b/client/src/app/shared/forms/textarea-autoresize.directive.ts | |||
@@ -0,0 +1,25 @@ | |||
1 | // Thanks: https://github.com/evseevdev/ngx-textarea-autosize | ||
2 | import { AfterViewInit, Directive, ElementRef, HostBinding, HostListener } from '@angular/core' | ||
3 | |||
4 | @Directive({ | ||
5 | selector: 'textarea[myAutoResize]' | ||
6 | }) | ||
7 | export class TextareaAutoResizeDirective implements AfterViewInit { | ||
8 | @HostBinding('attr.rows') rows = '1' | ||
9 | @HostBinding('style.overflow') overflow = 'hidden' | ||
10 | |||
11 | constructor (private elem: ElementRef) { } | ||
12 | |||
13 | public ngAfterViewInit () { | ||
14 | this.resize() | ||
15 | } | ||
16 | |||
17 | @HostListener('input') | ||
18 | resize () { | ||
19 | const textarea = this.elem.nativeElement as HTMLTextAreaElement | ||
20 | // Reset textarea height to auto that correctly calculate the new height | ||
21 | textarea.style.height = 'auto' | ||
22 | // Set new height | ||
23 | textarea.style.height = `${textarea.scrollHeight}px` | ||
24 | } | ||
25 | } | ||
diff --git a/client/src/app/shared/guards/can-deactivate-guard.service.ts b/client/src/app/shared/guards/can-deactivate-guard.service.ts index e2a79e8c4..3a35fcfb3 100644 --- a/client/src/app/shared/guards/can-deactivate-guard.service.ts +++ b/client/src/app/shared/guards/can-deactivate-guard.service.ts | |||
@@ -4,8 +4,10 @@ import { Observable } from 'rxjs' | |||
4 | import { ConfirmService } from '../../core/index' | 4 | import { ConfirmService } from '../../core/index' |
5 | import { I18n } from '@ngx-translate/i18n-polyfill' | 5 | import { I18n } from '@ngx-translate/i18n-polyfill' |
6 | 6 | ||
7 | export type CanComponentDeactivateResult = { text?: string, canDeactivate: Observable<boolean> | boolean } | ||
8 | |||
7 | export interface CanComponentDeactivate { | 9 | export interface CanComponentDeactivate { |
8 | canDeactivate: () => { text?: string, canDeactivate: Observable<boolean> | boolean } | 10 | canDeactivate: () => CanComponentDeactivateResult |
9 | } | 11 | } |
10 | 12 | ||
11 | @Injectable() | 13 | @Injectable() |
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 @@ | |||
1 | import { Component, ElementRef, Input, OnInit } from '@angular/core' | ||
2 | |||
3 | const 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 | |||
31 | export type GlobalIconName = keyof typeof icons | ||
32 | |||
33 | @Component({ | ||
34 | selector: 'my-global-icon', | ||
35 | template: '', | ||
36 | styleUrls: [ './global-icon.component.scss' ] | ||
37 | }) | ||
38 | export 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 @@ | |||
1 | import { catchError } from 'rxjs/operators' | ||
2 | import { HttpClient } from '@angular/common/http' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { environment } from '../../../environments/environment' | ||
5 | import { RestExtractor, RestService } from '../rest' | ||
6 | import { About } from '../../../../../shared/models/server' | ||
7 | |||
8 | @Injectable() | ||
9 | export 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 @@ | |||
1 | import { Component, Input, OnDestroy, OnInit } from '@angular/core' | ||
2 | import { filter, take } from 'rxjs/operators' | ||
3 | import { NavigationEnd, Router } from '@angular/router' | ||
4 | import { Subscription } from 'rxjs' | ||
5 | import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' | ||
6 | |||
7 | export 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 | }) | ||
22 | export 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/from-now.pipe.ts b/client/src/app/shared/misc/from-now.pipe.ts index 33e6d25fe..00b5be6c9 100644 --- a/client/src/app/shared/misc/from-now.pipe.ts +++ b/client/src/app/shared/misc/from-now.pipe.ts | |||
@@ -7,8 +7,9 @@ export class FromNowPipe implements PipeTransform { | |||
7 | 7 | ||
8 | constructor (private i18n: I18n) { } | 8 | constructor (private i18n: I18n) { } |
9 | 9 | ||
10 | transform (value: number) { | 10 | transform (arg: number | Date | string) { |
11 | const seconds = Math.floor((Date.now() - value) / 1000) | 11 | const argDate = new Date(arg) |
12 | const seconds = Math.floor((Date.now() - argDate.getTime()) / 1000) | ||
12 | 13 | ||
13 | let interval = Math.floor(seconds / 31536000) | 14 | let interval = Math.floor(seconds / 31536000) |
14 | if (interval > 1) { | 15 | if (interval > 1) { |
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 @@ | |||
1 | import { Component, Input, OnChanges, OnInit } from '@angular/core' | 1 | import { Component, Input, OnChanges, OnInit } from '@angular/core' |
2 | import { MarkdownService } from '@app/videos/shared' | ||
3 | import { I18n } from '@ngx-translate/i18n-polyfill' | 2 | import { I18n } from '@ngx-translate/i18n-polyfill' |
3 | import { 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/peertube-local-storage.ts b/client/src/app/shared/misc/peertube-local-storage.ts index 260f994b6..fb5c45acf 100644 --- a/client/src/app/shared/misc/peertube-local-storage.ts +++ b/client/src/app/shared/misc/peertube-local-storage.ts | |||
@@ -6,7 +6,7 @@ class MemoryStorage { | |||
6 | [key: string]: any | 6 | [key: string]: any |
7 | [index: number]: string | 7 | [index: number]: string |
8 | 8 | ||
9 | getItem (key) { | 9 | getItem (key: any) { |
10 | const stringKey = String(key) | 10 | const stringKey = String(key) |
11 | if (valuesMap.has(key)) { | 11 | if (valuesMap.has(key)) { |
12 | return String(valuesMap.get(stringKey)) | 12 | return String(valuesMap.get(stringKey)) |
@@ -15,11 +15,11 @@ class MemoryStorage { | |||
15 | return null | 15 | return null |
16 | } | 16 | } |
17 | 17 | ||
18 | setItem (key, val) { | 18 | setItem (key: any, val: any) { |
19 | valuesMap.set(String(key), String(val)) | 19 | valuesMap.set(String(key), String(val)) |
20 | } | 20 | } |
21 | 21 | ||
22 | removeItem (key) { | 22 | removeItem (key: any) { |
23 | valuesMap.delete(key) | 23 | valuesMap.delete(key) |
24 | } | 24 | } |
25 | 25 | ||
diff --git a/client/src/app/shared/misc/utils.ts b/client/src/app/shared/misc/utils.ts index c8b7ebc67..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 | ||
105 | function lineFeedToHtml (obj: object, keyToNormalize: string) { | 105 | function 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 | ||
111 | function lineFeedToHtml (text: string) { | ||
112 | if (!text) return text | ||
113 | |||
114 | return text.replace(/\r?\n|\r/g, '<br />') | ||
115 | } | ||
116 | |||
111 | function removeElementFromArray <T> (arr: T[], elem: T) { | 117 | function 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) |
@@ -124,9 +130,14 @@ function sortBy (obj: any[], key1: string, key2?: string) { | |||
124 | }) | 130 | }) |
125 | } | 131 | } |
126 | 132 | ||
133 | function scrollToTop () { | ||
134 | window.scroll(0, 0) | ||
135 | } | ||
136 | |||
127 | export { | 137 | export { |
128 | sortBy, | 138 | sortBy, |
129 | durationToString, | 139 | durationToString, |
140 | lineFeedToHtml, | ||
130 | objectToUrlEncoded, | 141 | objectToUrlEncoded, |
131 | getParameterByName, | 142 | getParameterByName, |
132 | populateAsyncUserVideoChannels, | 143 | populateAsyncUserVideoChannels, |
@@ -134,6 +145,7 @@ export { | |||
134 | dateToHuman, | 145 | dateToHuman, |
135 | immutableAssign, | 146 | immutableAssign, |
136 | objectToFormData, | 147 | objectToFormData, |
137 | lineFeedToHtml, | 148 | objectLineFeedToHtml, |
138 | removeElementFromArray | 149 | removeElementFromArray, |
150 | scrollToTop | ||
139 | } | 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 b2958caa4..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 {{ userToBan.username }}</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 67ae38e48..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 @@ | |||
1 | import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' | 1 | import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' |
2 | import { NotificationsService } from 'angular2-notifications' | 2 | import { Notifier } from '@app/core' |
3 | import { I18n } from '@ngx-translate/i18n-polyfill' | 3 | import { I18n } from '@ngx-translate/i18n-polyfill' |
4 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | 4 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' |
5 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' | 5 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' |
@@ -15,15 +15,15 @@ import { User } from '../../../../../shared' | |||
15 | }) | 15 | }) |
16 | export class UserBanModalComponent extends FormReactive implements OnInit { | 16 | export class UserBanModalComponent extends FormReactive implements OnInit { |
17 | @ViewChild('modal') modal: NgbModal | 17 | @ViewChild('modal') modal: NgbModal |
18 | @Output() userBanned = new EventEmitter<User>() | 18 | @Output() userBanned = new EventEmitter<User | User[]>() |
19 | 19 | ||
20 | private userToBan: User | 20 | private usersToBan: User | User[] |
21 | private openedModal: NgbModalRef | 21 | private openedModal: NgbModalRef |
22 | 22 | ||
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 |
@@ -37,32 +37,33 @@ export class UserBanModalComponent extends FormReactive implements OnInit { | |||
37 | }) | 37 | }) |
38 | } | 38 | } |
39 | 39 | ||
40 | openModal (user: User) { | 40 | openModal (user: User | User[]) { |
41 | this.userToBan = user | 41 | this.usersToBan = user |
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.userToBan = undefined | 46 | this.usersToBan = undefined |
47 | this.openedModal.close() | 47 | this.openedModal.close() |
48 | } | 48 | } |
49 | 49 | ||
50 | async banUser () { | 50 | async banUser () { |
51 | const reason = this.form.value['reason'] || undefined | 51 | const reason = this.form.value['reason'] || undefined |
52 | 52 | ||
53 | this.userService.banUser(this.userToBan, reason) | 53 | this.userService.banUsers(this.usersToBan, reason) |
54 | .subscribe( | 54 | .subscribe( |
55 | () => { | 55 | () => { |
56 | this.notificationsService.success( | 56 | const message = Array.isArray(this.usersToBan) |
57 | this.i18n('Success'), | 57 | ? this.i18n('{{num}} users banned.', { num: this.usersToBan.length }) |
58 | this.i18n('User {{username}} banned.', { username: this.userToBan.username }) | 58 | : this.i18n('User {{username}} banned.', { username: this.usersToBan.username }) |
59 | ) | ||
60 | 59 | ||
61 | this.userBanned.emit(this.userToBan) | 60 | this.notifier.success(message) |
62 | this.hideBanUserModal() | 61 | |
62 | this.userBanned.emit(this.usersToBan) | ||
63 | this.hide() | ||
63 | }, | 64 | }, |
64 | 65 | ||
65 | err => this.notificationsService.error(this.i18n('Error'), err.message) | 66 | err => this.notifier.error(err.message) |
66 | ) | 67 | ) |
67 | } | 68 | } |
68 | 69 | ||
diff --git a/client/src/app/shared/moderation/user-moderation-dropdown.component.html b/client/src/app/shared/moderation/user-moderation-dropdown.component.html index ed1a4c863..7367a7e59 100644 --- a/client/src/app/shared/moderation/user-moderation-dropdown.component.html +++ b/client/src/app/shared/moderation/user-moderation-dropdown.component.html | |||
@@ -1,5 +1,8 @@ | |||
1 | <ng-container *ngIf="user && userActions.length !== 0"> | 1 | <ng-container *ngIf="userActions.length !== 0"> |
2 | <my-user-ban-modal #userBanModal (userBanned)="onUserBanned()"></my-user-ban-modal> | 2 | <my-user-ban-modal #userBanModal (userBanned)="onUserBanned()"></my-user-ban-modal> |
3 | 3 | ||
4 | <my-action-dropdown i18n-label label="Actions" [actions]="userActions" [entry]="user" [buttonSize]="buttonSize"></my-action-dropdown> | 4 | <my-action-dropdown |
5 | [actions]="userActions" [entry]="{ user: user, account: account }" | ||
6 | [buttonSize]="buttonSize" [placement]="placement" | ||
7 | ></my-action-dropdown> | ||
5 | </ng-container> \ No newline at end of file | 8 | </ng-container> \ No newline at end of file |
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 4f88456de..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,50 +1,53 @@ | |||
1 | import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' | 1 | import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core' |
2 | import { NotificationsService } from 'angular2-notifications' | ||
3 | import { I18n } from '@ngx-translate/i18n-polyfill' | 2 | import { I18n } from '@ngx-translate/i18n-polyfill' |
4 | import { DropdownAction } from '@app/shared/buttons/action-dropdown.component' | 3 | import { DropdownAction } from '@app/shared/buttons/action-dropdown.component' |
5 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' | ||
6 | import { UserBanModalComponent } from '@app/shared/moderation/user-ban-modal.component' | 4 | import { UserBanModalComponent } from '@app/shared/moderation/user-ban-modal.component' |
7 | import { UserService } from '@app/shared/users' | 5 | import { UserService } from '@app/shared/users' |
8 | import { AuthService, ConfirmService } from '@app/core' | 6 | import { AuthService, ConfirmService, Notifier, ServerService } from '@app/core' |
9 | import { User, UserRight } from '../../../../../shared/models/users' | 7 | import { User, UserRight } from '../../../../../shared/models/users' |
8 | import { Account } from '@app/shared/account/account.model' | ||
9 | import { BlocklistService } from '@app/shared/blocklist' | ||
10 | 10 | ||
11 | @Component({ | 11 | @Component({ |
12 | selector: 'my-user-moderation-dropdown', | 12 | selector: 'my-user-moderation-dropdown', |
13 | templateUrl: './user-moderation-dropdown.component.html', | 13 | templateUrl: './user-moderation-dropdown.component.html', |
14 | styleUrls: [ './user-moderation-dropdown.component.scss' ] | 14 | styleUrls: [ './user-moderation-dropdown.component.scss' ] |
15 | }) | 15 | }) |
16 | export class UserModerationDropdownComponent implements OnInit { | 16 | export class UserModerationDropdownComponent implements OnChanges { |
17 | @ViewChild('userBanModal') userBanModal: UserBanModalComponent | 17 | @ViewChild('userBanModal') userBanModal: UserBanModalComponent |
18 | 18 | ||
19 | @Input() user: User | 19 | @Input() user: User |
20 | @Input() account: Account | ||
21 | |||
20 | @Input() buttonSize: 'normal' | 'small' = 'normal' | 22 | @Input() buttonSize: 'normal' | 'small' = 'normal' |
23 | @Input() placement = 'left' | ||
21 | 24 | ||
22 | @Output() userChanged = new EventEmitter() | 25 | @Output() userChanged = new EventEmitter() |
23 | @Output() userDeleted = new EventEmitter() | 26 | @Output() userDeleted = new EventEmitter() |
24 | 27 | ||
25 | userActions: DropdownAction<User>[] = [] | 28 | userActions: DropdownAction<{ user: User, account: Account }>[][] = [] |
26 | |||
27 | private openedModal: NgbModalRef | ||
28 | 29 | ||
29 | constructor ( | 30 | constructor ( |
30 | private authService: AuthService, | 31 | private authService: AuthService, |
31 | private notificationsService: NotificationsService, | 32 | private notifier: Notifier, |
32 | private confirmService: ConfirmService, | 33 | private confirmService: ConfirmService, |
34 | private serverService: ServerService, | ||
33 | private userService: UserService, | 35 | private userService: UserService, |
36 | private blocklistService: BlocklistService, | ||
34 | private i18n: I18n | 37 | private i18n: I18n |
35 | ) { } | 38 | ) { } |
36 | 39 | ||
37 | ngOnInit () { | 40 | get requiresEmailVerification () { |
38 | this.buildActions() | 41 | return this.serverService.getConfig().signup.requiresEmailVerification |
39 | } | 42 | } |
40 | 43 | ||
41 | hideBanUserModal () { | 44 | ngOnChanges () { |
42 | this.openedModal.close() | 45 | this.buildActions() |
43 | } | 46 | } |
44 | 47 | ||
45 | openBanUserModal (user: User) { | 48 | openBanUserModal (user: User) { |
46 | if (user.username === 'root') { | 49 | if (user.username === 'root') { |
47 | this.notificationsService.error(this.i18n('Error'), this.i18n('You cannot ban root.')) | 50 | this.notifier.error(this.i18n('You cannot ban root.')) |
48 | return | 51 | return |
49 | } | 52 | } |
50 | 53 | ||
@@ -60,24 +63,21 @@ export class UserModerationDropdownComponent implements OnInit { | |||
60 | const res = await this.confirmService.confirm(message, this.i18n('Unban')) | 63 | const res = await this.confirmService.confirm(message, this.i18n('Unban')) |
61 | if (res === false) return | 64 | if (res === false) return |
62 | 65 | ||
63 | this.userService.unbanUser(user) | 66 | this.userService.unbanUsers(user) |
64 | .subscribe( | 67 | .subscribe( |
65 | () => { | 68 | () => { |
66 | this.notificationsService.success( | 69 | this.notifier.success(this.i18n('User {{username}} unbanned.', { username: user.username })) |
67 | this.i18n('Success'), | ||
68 | this.i18n('User {{username}} unbanned.', { username: user.username }) | ||
69 | ) | ||
70 | 70 | ||
71 | this.userChanged.emit() | 71 | this.userChanged.emit() |
72 | }, | 72 | }, |
73 | 73 | ||
74 | err => this.notificationsService.error(this.i18n('Error'), err.message) | 74 | err => this.notifier.error(err.message) |
75 | ) | 75 | ) |
76 | } | 76 | } |
77 | 77 | ||
78 | async removeUser (user: User) { | 78 | async removeUser (user: User) { |
79 | if (user.username === 'root') { | 79 | if (user.username === 'root') { |
80 | this.notificationsService.error(this.i18n('Error'), this.i18n('You cannot delete root.')) | 80 | this.notifier.error(this.i18n('You cannot delete root.')) |
81 | return | 81 | return |
82 | } | 82 | } |
83 | 83 | ||
@@ -87,17 +87,138 @@ export class UserModerationDropdownComponent implements OnInit { | |||
87 | 87 | ||
88 | this.userService.removeUser(user).subscribe( | 88 | this.userService.removeUser(user).subscribe( |
89 | () => { | 89 | () => { |
90 | this.notificationsService.success( | 90 | this.notifier.success(this.i18n('User {{username}} deleted.', { username: user.username })) |
91 | this.i18n('Success'), | ||
92 | this.i18n('User {{username}} deleted.', { username: user.username }) | ||
93 | ) | ||
94 | this.userDeleted.emit() | 91 | this.userDeleted.emit() |
95 | }, | 92 | }, |
96 | 93 | ||
97 | err => this.notificationsService.error(this.i18n('Error'), err.message) | 94 | err => this.notifier.error(err.message) |
98 | ) | 95 | ) |
99 | } | 96 | } |
100 | 97 | ||
98 | setEmailAsVerified (user: User) { | ||
99 | this.userService.updateUser(user.id, { emailVerified: true }).subscribe( | ||
100 | () => { | ||
101 | this.notifier.success(this.i18n('User {{username}} email set as verified', { username: user.username })) | ||
102 | |||
103 | this.userChanged.emit() | ||
104 | }, | ||
105 | |||
106 | err => this.notifier.error(err.message) | ||
107 | ) | ||
108 | } | ||
109 | |||
110 | blockAccountByUser (account: Account) { | ||
111 | this.blocklistService.blockAccountByUser(account) | ||
112 | .subscribe( | ||
113 | () => { | ||
114 | this.notifier.success(this.i18n('Account {{nameWithHost}} muted.', { nameWithHost: account.nameWithHost })) | ||
115 | |||
116 | this.account.mutedByUser = true | ||
117 | this.userChanged.emit() | ||
118 | }, | ||
119 | |||
120 | err => this.notifier.error(err.message) | ||
121 | ) | ||
122 | } | ||
123 | |||
124 | unblockAccountByUser (account: Account) { | ||
125 | this.blocklistService.unblockAccountByUser(account) | ||
126 | .subscribe( | ||
127 | () => { | ||
128 | this.notifier.success(this.i18n('Account {{nameWithHost}} unmuted.', { nameWithHost: account.nameWithHost })) | ||
129 | |||
130 | this.account.mutedByUser = false | ||
131 | this.userChanged.emit() | ||
132 | }, | ||
133 | |||
134 | err => this.notifier.error(err.message) | ||
135 | ) | ||
136 | } | ||
137 | |||
138 | blockServerByUser (host: string) { | ||
139 | this.blocklistService.blockServerByUser(host) | ||
140 | .subscribe( | ||
141 | () => { | ||
142 | this.notifier.success(this.i18n('Instance {{host}} muted.', { host })) | ||
143 | |||
144 | this.account.mutedServerByUser = true | ||
145 | this.userChanged.emit() | ||
146 | }, | ||
147 | |||
148 | err => this.notifier.error(err.message) | ||
149 | ) | ||
150 | } | ||
151 | |||
152 | unblockServerByUser (host: string) { | ||
153 | this.blocklistService.unblockServerByUser(host) | ||
154 | .subscribe( | ||
155 | () => { | ||
156 | this.notifier.success(this.i18n('Instance {{host}} unmuted.', { host })) | ||
157 | |||
158 | this.account.mutedServerByUser = false | ||
159 | this.userChanged.emit() | ||
160 | }, | ||
161 | |||
162 | err => this.notifier.error(err.message) | ||
163 | ) | ||
164 | } | ||
165 | |||
166 | blockAccountByInstance (account: Account) { | ||
167 | this.blocklistService.blockAccountByInstance(account) | ||
168 | .subscribe( | ||
169 | () => { | ||
170 | this.notifier.success(this.i18n('Account {{nameWithHost}} muted by the instance.', { nameWithHost: account.nameWithHost })) | ||
171 | |||
172 | this.account.mutedByInstance = true | ||
173 | this.userChanged.emit() | ||
174 | }, | ||
175 | |||
176 | err => this.notifier.error(err.message) | ||
177 | ) | ||
178 | } | ||
179 | |||
180 | unblockAccountByInstance (account: Account) { | ||
181 | this.blocklistService.unblockAccountByInstance(account) | ||
182 | .subscribe( | ||
183 | () => { | ||
184 | this.notifier.success(this.i18n('Account {{nameWithHost}} unmuted by the instance.', { nameWithHost: account.nameWithHost })) | ||
185 | |||
186 | this.account.mutedByInstance = false | ||
187 | this.userChanged.emit() | ||
188 | }, | ||
189 | |||
190 | err => this.notifier.error(err.message) | ||
191 | ) | ||
192 | } | ||
193 | |||
194 | blockServerByInstance (host: string) { | ||
195 | this.blocklistService.blockServerByInstance(host) | ||
196 | .subscribe( | ||
197 | () => { | ||
198 | this.notifier.success(this.i18n('Instance {{host}} muted by the instance.', { host })) | ||
199 | |||
200 | this.account.mutedServerByInstance = true | ||
201 | this.userChanged.emit() | ||
202 | }, | ||
203 | |||
204 | err => this.notifier.error(err.message) | ||
205 | ) | ||
206 | } | ||
207 | |||
208 | unblockServerByInstance (host: string) { | ||
209 | this.blocklistService.unblockServerByInstance(host) | ||
210 | .subscribe( | ||
211 | () => { | ||
212 | this.notifier.success(this.i18n('Instance {{host}} unmuted by the instance.', { host })) | ||
213 | |||
214 | this.account.mutedServerByInstance = false | ||
215 | this.userChanged.emit() | ||
216 | }, | ||
217 | |||
218 | err => this.notifier.error(err.message) | ||
219 | ) | ||
220 | } | ||
221 | |||
101 | getRouterUserEditLink (user: User) { | 222 | getRouterUserEditLink (user: User) { |
102 | return [ '/admin', 'users', 'update', user.id ] | 223 | return [ '/admin', 'users', 'update', user.id ] |
103 | } | 224 | } |
@@ -108,28 +229,100 @@ export class UserModerationDropdownComponent implements OnInit { | |||
108 | if (this.authService.isLoggedIn()) { | 229 | if (this.authService.isLoggedIn()) { |
109 | const authUser = this.authService.getUser() | 230 | const authUser = this.authService.getUser() |
110 | 231 | ||
111 | if (authUser.hasRight(UserRight.MANAGE_USERS)) { | 232 | if (this.user && authUser.id === this.user.id) return |
112 | this.userActions = this.userActions.concat([ | 233 | |
234 | if (this.user && authUser.hasRight(UserRight.MANAGE_USERS)) { | ||
235 | this.userActions.push([ | ||
113 | { | 236 | { |
114 | label: this.i18n('Edit'), | 237 | label: this.i18n('Edit'), |
115 | linkBuilder: this.getRouterUserEditLink | 238 | linkBuilder: ({ user }) => this.getRouterUserEditLink(user) |
116 | }, | 239 | }, |
117 | { | 240 | { |
118 | label: this.i18n('Delete'), | 241 | label: this.i18n('Delete'), |
119 | handler: user => this.removeUser(user) | 242 | handler: ({ user }) => this.removeUser(user) |
120 | }, | 243 | }, |
121 | { | 244 | { |
122 | label: this.i18n('Ban'), | 245 | label: this.i18n('Ban'), |
123 | handler: user => this.openBanUserModal(user), | 246 | handler: ({ user }) => this.openBanUserModal(user), |
124 | isDisplayed: user => !user.blocked | 247 | isDisplayed: ({ user }) => !user.blocked |
125 | }, | 248 | }, |
126 | { | 249 | { |
127 | label: this.i18n('Unban'), | 250 | label: this.i18n('Unban'), |
128 | handler: user => this.unbanUser(user), | 251 | handler: ({ user }) => this.unbanUser(user), |
129 | isDisplayed: user => user.blocked | 252 | isDisplayed: ({ user }) => user.blocked |
253 | }, | ||
254 | { | ||
255 | label: this.i18n('Set Email as Verified'), | ||
256 | handler: ({ user }) => this.setEmailAsVerified(user), | ||
257 | isDisplayed: ({ user }) => this.requiresEmailVerification && !user.blocked && user.emailVerified === false | ||
130 | } | 258 | } |
131 | ]) | 259 | ]) |
132 | } | 260 | } |
261 | |||
262 | // Actions on accounts/servers | ||
263 | if (this.account) { | ||
264 | // User actions | ||
265 | this.userActions.push([ | ||
266 | { | ||
267 | label: this.i18n('Mute this account'), | ||
268 | isDisplayed: ({ account }) => account.mutedByUser === false, | ||
269 | handler: ({ account }) => this.blockAccountByUser(account) | ||
270 | }, | ||
271 | { | ||
272 | label: this.i18n('Unmute this account'), | ||
273 | isDisplayed: ({ account }) => account.mutedByUser === true, | ||
274 | handler: ({ account }) => this.unblockAccountByUser(account) | ||
275 | }, | ||
276 | { | ||
277 | label: this.i18n('Mute the instance'), | ||
278 | isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === false, | ||
279 | handler: ({ account }) => this.blockServerByUser(account.host) | ||
280 | }, | ||
281 | { | ||
282 | label: this.i18n('Unmute the instance'), | ||
283 | isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === true, | ||
284 | handler: ({ account }) => this.unblockServerByUser(account.host) | ||
285 | } | ||
286 | ]) | ||
287 | |||
288 | let instanceActions: DropdownAction<{ user: User, account: Account }>[] = [] | ||
289 | |||
290 | // Instance actions | ||
291 | if (authUser.hasRight(UserRight.MANAGE_ACCOUNTS_BLOCKLIST)) { | ||
292 | instanceActions = instanceActions.concat([ | ||
293 | { | ||
294 | label: this.i18n('Mute this account by your instance'), | ||
295 | isDisplayed: ({ account }) => account.mutedByInstance === false, | ||
296 | handler: ({ account }) => this.blockAccountByInstance(account) | ||
297 | }, | ||
298 | { | ||
299 | label: this.i18n('Unmute this account by your instance'), | ||
300 | isDisplayed: ({ account }) => account.mutedByInstance === true, | ||
301 | handler: ({ account }) => this.unblockAccountByInstance(account) | ||
302 | } | ||
303 | ]) | ||
304 | } | ||
305 | |||
306 | // Instance actions | ||
307 | if (authUser.hasRight(UserRight.MANAGE_SERVERS_BLOCKLIST)) { | ||
308 | instanceActions = instanceActions.concat([ | ||
309 | { | ||
310 | label: this.i18n('Mute the instance by your instance'), | ||
311 | isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === false, | ||
312 | handler: ({ account }) => this.blockServerByInstance(account.host) | ||
313 | }, | ||
314 | { | ||
315 | label: this.i18n('Unmute the instance by your instance'), | ||
316 | isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === true, | ||
317 | handler: ({ account }) => this.unblockServerByInstance(account.host) | ||
318 | } | ||
319 | ]) | ||
320 | } | ||
321 | |||
322 | if (instanceActions.length !== 0) { | ||
323 | this.userActions.push(instanceActions) | ||
324 | } | ||
325 | } | ||
133 | } | 326 | } |
134 | } | 327 | } |
135 | } | 328 | } |
diff --git a/client/src/app/shared/overview/videos-overview.model.ts b/client/src/app/shared/overview/videos-overview.model.ts index cf02bdb3d..c8eafc8e8 100644 --- a/client/src/app/shared/overview/videos-overview.model.ts +++ b/client/src/app/shared/overview/videos-overview.model.ts | |||
@@ -16,4 +16,5 @@ export class VideosOverview implements VideosOverviewServer { | |||
16 | tag: string | 16 | tag: string |
17 | videos: Video[] | 17 | videos: Video[] |
18 | }[] | 18 | }[] |
19 | [key: string]: any | ||
19 | } | 20 | } |
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 @@ | |||
1 | import { Injectable } from '@angular/core' | ||
2 | import { LinkifierService } from '@app/shared/renderer/linkifier.service' | ||
3 | import * as sanitizeHtml from 'sanitize-html' | ||
4 | |||
5 | @Injectable() | ||
6 | export 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 @@ | |||
1 | export * from './html-renderer.service' | ||
2 | export * from './linkifier.service' | ||
3 | export * 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 @@ | |||
1 | import { Injectable } from '@angular/core' | ||
2 | import { getAbsoluteAPIUrl } from '@app/shared/misc/utils' | ||
3 | // FIXME: use @types/linkify when https://github.com/DefinitelyTyped/DefinitelyTyped/pull/29682/files is merged? | ||
4 | const linkify = require('linkifyjs') | ||
5 | const linkifyHtml = require('linkifyjs/html') | ||
6 | |||
7 | @Injectable() | ||
8 | export 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 @@ | |||
1 | import { Injectable } from '@angular/core' | ||
2 | |||
3 | import * as MarkdownIt from 'markdown-it' | ||
4 | |||
5 | @Injectable() | ||
6 | export 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 | |||
7 | export 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 6492aa66d..e6518dd1d 100644 --- a/client/src/app/shared/rest/rest-extractor.service.ts +++ b/client/src/app/shared/rest/rest-extractor.service.ts | |||
@@ -33,7 +33,7 @@ export class RestExtractor { | |||
33 | return this.applyToResultListData(result, this.convertDateToHuman, [ fieldsToConvert ]) | 33 | return this.applyToResultListData(result, this.convertDateToHuman, [ fieldsToConvert ]) |
34 | } | 34 | } |
35 | 35 | ||
36 | convertDateToHuman (target: object, fieldsToConvert: string[]) { | 36 | convertDateToHuman (target: { [ id: string ]: string }, fieldsToConvert: string[]) { |
37 | fieldsToConvert.forEach(field => target[field] = dateToHuman(target[field])) | 37 | fieldsToConvert.forEach(field => target[field] = dateToHuman(target[field])) |
38 | 38 | ||
39 | return target | 39 | return target |
@@ -80,10 +80,11 @@ 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 | ||
86 | const errorObj = { | 87 | const errorObj: { message: string, status: string, body: string } = { |
87 | message: errorMessage, | 88 | message: errorMessage, |
88 | status: undefined, | 89 | status: undefined, |
89 | body: undefined | 90 | body: undefined |
diff --git a/client/src/app/shared/rest/rest-table.ts b/client/src/app/shared/rest/rest-table.ts index fe1a91d2d..884588207 100644 --- a/client/src/app/shared/rest/rest-table.ts +++ b/client/src/app/shared/rest/rest-table.ts | |||
@@ -1,8 +1,9 @@ | |||
1 | import { peertubeLocalStorage } from '@app/shared/misc/peertube-local-storage' | 1 | import { peertubeLocalStorage } from '@app/shared/misc/peertube-local-storage' |
2 | import { LazyLoadEvent } from 'primeng/components/common/lazyloadevent' | 2 | import { LazyLoadEvent } from 'primeng/components/common/lazyloadevent' |
3 | import { SortMeta } from 'primeng/components/common/sortmeta' | 3 | import { SortMeta } from 'primeng/components/common/sortmeta' |
4 | |||
5 | import { RestPagination } from './rest-pagination' | 4 | import { RestPagination } from './rest-pagination' |
5 | import { Subject } from 'rxjs' | ||
6 | import { debounceTime, distinctUntilChanged } from 'rxjs/operators' | ||
6 | 7 | ||
7 | export abstract class RestTable { | 8 | export abstract class RestTable { |
8 | 9 | ||
@@ -11,9 +12,14 @@ export abstract class RestTable { | |||
11 | abstract sort: SortMeta | 12 | abstract sort: SortMeta |
12 | abstract pagination: RestPagination | 13 | abstract pagination: RestPagination |
13 | 14 | ||
15 | protected search: string | ||
16 | private searchStream: Subject<string> | ||
14 | private sortLocalStorageKey = 'rest-table-sort-' + this.constructor.name | 17 | private sortLocalStorageKey = 'rest-table-sort-' + this.constructor.name |
15 | 18 | ||
16 | protected abstract loadData (): void | 19 | initialize () { |
20 | this.loadSort() | ||
21 | this.initSearch() | ||
22 | } | ||
17 | 23 | ||
18 | loadSort () { | 24 | loadSort () { |
19 | const result = peertubeLocalStorage.getItem(this.sortLocalStorageKey) | 25 | const result = peertubeLocalStorage.getItem(this.sortLocalStorageKey) |
@@ -46,4 +52,23 @@ export abstract class RestTable { | |||
46 | peertubeLocalStorage.setItem(this.sortLocalStorageKey, JSON.stringify(this.sort)) | 52 | peertubeLocalStorage.setItem(this.sortLocalStorageKey, JSON.stringify(this.sort)) |
47 | } | 53 | } |
48 | 54 | ||
55 | initSearch () { | ||
56 | this.searchStream = new Subject() | ||
57 | |||
58 | this.searchStream | ||
59 | .pipe( | ||
60 | debounceTime(400), | ||
61 | distinctUntilChanged() | ||
62 | ) | ||
63 | .subscribe(search => { | ||
64 | this.search = search | ||
65 | this.loadData() | ||
66 | }) | ||
67 | } | ||
68 | |||
69 | onSearch (search: string) { | ||
70 | this.searchStream.next(search) | ||
71 | } | ||
72 | |||
73 | protected abstract loadData (): void | ||
49 | } | 74 | } |
diff --git a/client/src/app/shared/rest/rest.service.ts b/client/src/app/shared/rest/rest.service.ts index 4560c2024..e6d4e6e5e 100644 --- a/client/src/app/shared/rest/rest.service.ts +++ b/client/src/app/shared/rest/rest.service.ts | |||
@@ -32,7 +32,7 @@ export class RestService { | |||
32 | return newParams | 32 | return newParams |
33 | } | 33 | } |
34 | 34 | ||
35 | addObjectParams (params: HttpParams, object: object) { | 35 | addObjectParams (params: HttpParams, object: { [ name: string ]: any }) { |
36 | for (const name of Object.keys(object)) { | 36 | for (const name of Object.keys(object)) { |
37 | const value = object[name] | 37 | const value = object[name] |
38 | if (!value) continue | 38 | if (!value) continue |
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 9647a7966..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' | |||
6 | import { MarkdownTextareaComponent } from '@app/shared/forms/markdown-textarea.component' | 6 | import { MarkdownTextareaComponent } from '@app/shared/forms/markdown-textarea.component' |
7 | import { HelpComponent } from '@app/shared/misc/help.component' | 7 | import { HelpComponent } from '@app/shared/misc/help.component' |
8 | import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive' | 8 | import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive' |
9 | import { MarkdownService } from '@app/videos/shared' | ||
10 | 9 | ||
11 | import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes' | 10 | import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes' |
12 | import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared' | 11 | import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared' |
@@ -25,7 +24,7 @@ import { VideoAbuseService } from './video-abuse' | |||
25 | import { VideoBlacklistService } from './video-blacklist' | 24 | import { VideoBlacklistService } from './video-blacklist' |
26 | import { VideoOwnershipService } from './video-ownership' | 25 | import { VideoOwnershipService } from './video-ownership' |
27 | import { VideoMiniatureComponent } from './video/video-miniature.component' | 26 | import { VideoMiniatureComponent } from './video/video-miniature.component' |
28 | import { VideoFeedComponent } from './video/video-feed.component' | 27 | import { FeedComponent } from './video/feed.component' |
29 | import { VideoThumbnailComponent } from './video/video-thumbnail.component' | 28 | import { VideoThumbnailComponent } from './video/video-thumbnail.component' |
30 | import { VideoService } from './video/video.service' | 29 | import { VideoService } from './video/video.service' |
31 | import { AccountService } from '@app/shared/account/account.service' | 30 | import { AccountService } from '@app/shared/account/account.service' |
@@ -34,16 +33,19 @@ import { I18n } from '@ngx-translate/i18n-polyfill' | |||
34 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | 33 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' |
35 | import { | 34 | import { |
36 | CustomConfigValidatorsService, | 35 | CustomConfigValidatorsService, |
36 | InstanceValidatorsService, | ||
37 | LoginValidatorsService, | 37 | LoginValidatorsService, |
38 | ReactiveFileComponent, | 38 | ReactiveFileComponent, |
39 | ResetPasswordValidatorsService, | 39 | ResetPasswordValidatorsService, |
40 | TextareaAutoResizeDirective, | ||
40 | UserValidatorsService, | 41 | UserValidatorsService, |
41 | VideoAbuseValidatorsService, | 42 | VideoAbuseValidatorsService, |
43 | VideoAcceptOwnershipValidatorsService, | ||
42 | VideoBlacklistValidatorsService, | 44 | VideoBlacklistValidatorsService, |
45 | VideoChangeOwnershipValidatorsService, | ||
43 | VideoChannelValidatorsService, | 46 | VideoChannelValidatorsService, |
44 | VideoCommentValidatorsService, | 47 | VideoCommentValidatorsService, |
45 | VideoValidatorsService, | 48 | VideoValidatorsService |
46 | VideoChangeOwnershipValidatorsService, VideoAcceptOwnershipValidatorsService | ||
47 | } from '@app/shared/forms' | 49 | } from '@app/shared/forms' |
48 | import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' | 50 | import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' |
49 | import { ScreenService } from '@app/shared/misc/screen.service' | 51 | import { ScreenService } from '@app/shared/misc/screen.service' |
@@ -53,11 +55,20 @@ import { PeertubeCheckboxComponent } from '@app/shared/forms/peertube-checkbox.c | |||
53 | import { VideoImportService } from '@app/shared/video-import/video-import.service' | 55 | import { VideoImportService } from '@app/shared/video-import/video-import.service' |
54 | import { ActionDropdownComponent } from '@app/shared/buttons/action-dropdown.component' | 56 | import { ActionDropdownComponent } from '@app/shared/buttons/action-dropdown.component' |
55 | import { NgbDropdownModule, NgbModalModule, NgbPopoverModule, NgbTabsetModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' | 57 | import { NgbDropdownModule, NgbModalModule, NgbPopoverModule, NgbTabsetModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' |
56 | import { SubscribeButtonComponent, RemoteSubscribeComponent, UserSubscriptionService } from '@app/shared/user-subscription' | 58 | import { RemoteSubscribeComponent, SubscribeButtonComponent, UserSubscriptionService } from '@app/shared/user-subscription' |
57 | import { InstanceFeaturesTableComponent } from '@app/shared/instance/instance-features-table.component' | 59 | import { InstanceFeaturesTableComponent } from '@app/shared/instance/instance-features-table.component' |
58 | import { OverviewService } from '@app/shared/overview' | 60 | import { OverviewService } from '@app/shared/overview' |
59 | import { UserBanModalComponent } from '@app/shared/moderation' | 61 | import { UserBanModalComponent } from '@app/shared/moderation' |
60 | import { UserModerationDropdownComponent } from '@app/shared/moderation/user-moderation-dropdown.component' | 62 | import { UserModerationDropdownComponent } from '@app/shared/moderation/user-moderation-dropdown.component' |
63 | import { BlocklistService } from '@app/shared/blocklist' | ||
64 | import { TopMenuDropdownComponent } from '@app/shared/menu/top-menu-dropdown.component' | ||
65 | import { UserHistoryService } from '@app/shared/users/user-history.service' | ||
66 | import { UserNotificationService } from '@app/shared/users/user-notification.service' | ||
67 | import { UserNotificationsComponent } from '@app/shared/users/user-notifications.component' | ||
68 | import { InstanceService } from '@app/shared/instance/instance.service' | ||
69 | import { HtmlRendererService, LinkifierService, MarkdownService } from '@app/shared/renderer' | ||
70 | import { ConfirmComponent } from '@app/shared/confirm/confirm.component' | ||
71 | import { GlobalIconComponent } from '@app/shared/icons/global-icon.component' | ||
61 | 72 | ||
62 | @NgModule({ | 73 | @NgModule({ |
63 | imports: [ | 74 | imports: [ |
@@ -81,7 +92,7 @@ import { UserModerationDropdownComponent } from '@app/shared/moderation/user-mod | |||
81 | LoaderComponent, | 92 | LoaderComponent, |
82 | VideoThumbnailComponent, | 93 | VideoThumbnailComponent, |
83 | VideoMiniatureComponent, | 94 | VideoMiniatureComponent, |
84 | VideoFeedComponent, | 95 | FeedComponent, |
85 | ButtonComponent, | 96 | ButtonComponent, |
86 | DeleteButtonComponent, | 97 | DeleteButtonComponent, |
87 | EditButtonComponent, | 98 | EditButtonComponent, |
@@ -91,6 +102,7 @@ import { UserModerationDropdownComponent } from '@app/shared/moderation/user-mod | |||
91 | FromNowPipe, | 102 | FromNowPipe, |
92 | MarkdownTextareaComponent, | 103 | MarkdownTextareaComponent, |
93 | InfiniteScrollerDirective, | 104 | InfiniteScrollerDirective, |
105 | TextareaAutoResizeDirective, | ||
94 | HelpComponent, | 106 | HelpComponent, |
95 | ReactiveFileComponent, | 107 | ReactiveFileComponent, |
96 | PeertubeCheckboxComponent, | 108 | PeertubeCheckboxComponent, |
@@ -98,7 +110,11 @@ import { UserModerationDropdownComponent } from '@app/shared/moderation/user-mod | |||
98 | RemoteSubscribeComponent, | 110 | RemoteSubscribeComponent, |
99 | InstanceFeaturesTableComponent, | 111 | InstanceFeaturesTableComponent, |
100 | UserBanModalComponent, | 112 | UserBanModalComponent, |
101 | UserModerationDropdownComponent | 113 | UserModerationDropdownComponent, |
114 | TopMenuDropdownComponent, | ||
115 | UserNotificationsComponent, | ||
116 | ConfirmComponent, | ||
117 | GlobalIconComponent | ||
102 | ], | 118 | ], |
103 | 119 | ||
104 | exports: [ | 120 | exports: [ |
@@ -121,13 +137,14 @@ import { UserModerationDropdownComponent } from '@app/shared/moderation/user-mod | |||
121 | LoaderComponent, | 137 | LoaderComponent, |
122 | VideoThumbnailComponent, | 138 | VideoThumbnailComponent, |
123 | VideoMiniatureComponent, | 139 | VideoMiniatureComponent, |
124 | VideoFeedComponent, | 140 | FeedComponent, |
125 | ButtonComponent, | 141 | ButtonComponent, |
126 | DeleteButtonComponent, | 142 | DeleteButtonComponent, |
127 | EditButtonComponent, | 143 | EditButtonComponent, |
128 | ActionDropdownComponent, | 144 | ActionDropdownComponent, |
129 | MarkdownTextareaComponent, | 145 | MarkdownTextareaComponent, |
130 | InfiniteScrollerDirective, | 146 | InfiniteScrollerDirective, |
147 | TextareaAutoResizeDirective, | ||
131 | HelpComponent, | 148 | HelpComponent, |
132 | ReactiveFileComponent, | 149 | ReactiveFileComponent, |
133 | PeertubeCheckboxComponent, | 150 | PeertubeCheckboxComponent, |
@@ -136,6 +153,10 @@ import { UserModerationDropdownComponent } from '@app/shared/moderation/user-mod | |||
136 | InstanceFeaturesTableComponent, | 153 | InstanceFeaturesTableComponent, |
137 | UserBanModalComponent, | 154 | UserBanModalComponent, |
138 | UserModerationDropdownComponent, | 155 | UserModerationDropdownComponent, |
156 | TopMenuDropdownComponent, | ||
157 | UserNotificationsComponent, | ||
158 | ConfirmComponent, | ||
159 | GlobalIconComponent, | ||
139 | 160 | ||
140 | NumberFormatterPipe, | 161 | NumberFormatterPipe, |
141 | ObjectLengthPipe, | 162 | ObjectLengthPipe, |
@@ -152,7 +173,6 @@ import { UserModerationDropdownComponent } from '@app/shared/moderation/user-mod | |||
152 | UserService, | 173 | UserService, |
153 | VideoService, | 174 | VideoService, |
154 | AccountService, | 175 | AccountService, |
155 | MarkdownService, | ||
156 | VideoChannelService, | 176 | VideoChannelService, |
157 | VideoCaptionService, | 177 | VideoCaptionService, |
158 | VideoImportService, | 178 | VideoImportService, |
@@ -172,10 +192,20 @@ import { UserModerationDropdownComponent } from '@app/shared/moderation/user-mod | |||
172 | OverviewService, | 192 | OverviewService, |
173 | VideoChangeOwnershipValidatorsService, | 193 | VideoChangeOwnershipValidatorsService, |
174 | VideoAcceptOwnershipValidatorsService, | 194 | VideoAcceptOwnershipValidatorsService, |
195 | InstanceValidatorsService, | ||
196 | BlocklistService, | ||
197 | UserHistoryService, | ||
198 | InstanceService, | ||
199 | |||
200 | MarkdownService, | ||
201 | LinkifierService, | ||
202 | HtmlRendererService, | ||
175 | 203 | ||
176 | I18nPrimengCalendarService, | 204 | I18nPrimengCalendarService, |
177 | ScreenService, | 205 | ScreenService, |
178 | 206 | ||
207 | UserNotificationService, | ||
208 | |||
179 | I18n | 209 | I18n |
180 | ] | 210 | ] |
181 | }) | 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 @@ | |||
1 | import { Component, Input, OnInit } from '@angular/core' | 1 | import { Component, Input, OnInit } from '@angular/core' |
2 | import { Router } from '@angular/router' | 2 | import { Router } from '@angular/router' |
3 | import { AuthService } from '@app/core' | 3 | import { AuthService, Notifier } from '@app/core' |
4 | import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service' | 4 | import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service' |
5 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' | 5 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' |
6 | import { NotificationsService } from 'angular2-notifications' | ||
7 | import { I18n } from '@ngx-translate/i18n-polyfill' | 6 | import { I18n } from '@ngx-translate/i18n-polyfill' |
8 | import { VideoService } from '@app/shared/video/video.service' | 7 | import { VideoService } from '@app/shared/video/video.service' |
9 | import { FeedFormat } from '../../../../../shared/models/feeds' | 8 | import { 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 @@ | |||
1 | export * from './user.model' | 1 | export * from './user.model' |
2 | export * from './user.service' | 2 | export * from './user.service' |
3 | export * 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 @@ | |||
1 | import { HttpClient, HttpParams } from '@angular/common/http' | ||
2 | import { Injectable } from '@angular/core' | ||
3 | import { environment } from '../../../environments/environment' | ||
4 | import { RestExtractor } from '../rest/rest-extractor.service' | ||
5 | import { RestService } from '../rest/rest.service' | ||
6 | import { Video } from '../video/video.model' | ||
7 | import { catchError, map, switchMap } from 'rxjs/operators' | ||
8 | import { ComponentPagination } from '@app/shared/rest/component-pagination.model' | ||
9 | import { VideoService } from '@app/shared/video/video.service' | ||
10 | import { ResultList } from '../../../../../shared' | ||
11 | |||
12 | @Injectable() | ||
13 | export 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 @@ | |||
1 | import { UserNotification as UserNotificationServer, UserNotificationType, VideoInfo, ActorInfo } from '../../../../../shared' | ||
2 | import { Actor } from '@app/shared/actor/actor.model' | ||
3 | |||
4 | export 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 @@ | |||
1 | import { Injectable } from '@angular/core' | ||
2 | import { HttpClient, HttpParams } from '@angular/common/http' | ||
3 | import { RestExtractor, RestService } from '../rest' | ||
4 | import { catchError, map, tap } from 'rxjs/operators' | ||
5 | import { environment } from '../../../environments/environment' | ||
6 | import { ResultList, UserNotification as UserNotificationServer, UserNotificationSetting } from '../../../../../shared' | ||
7 | import { UserNotification } from './user-notification.model' | ||
8 | import { AuthService } from '../../core' | ||
9 | import { ComponentPagination } from '../rest/component-pagination.model' | ||
10 | import { User } from '..' | ||
11 | import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service' | ||
12 | |||
13 | @Injectable() | ||
14 | export 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 @@ | |||
1 | import { Component, Input, OnInit } from '@angular/core' | ||
2 | import { UserNotificationService } from '@app/shared/users/user-notification.service' | ||
3 | import { UserNotificationType } from '../../../../../shared' | ||
4 | import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pagination.model' | ||
5 | import { Notifier } from '@app/core' | ||
6 | import { 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 | }) | ||
13 | export 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 877f1bf3a..c15f1de8c 100644 --- a/client/src/app/shared/users/user.model.ts +++ b/client/src/app/shared/users/user.model.ts | |||
@@ -1,38 +1,20 @@ | |||
1 | import { | 1 | import { 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' | ||
9 | import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type' | 2 | import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type' |
10 | import { Account } from '@app/shared/account/account.model' | 3 | import { Account } from '@app/shared/account/account.model' |
11 | import { Avatar } from '../../../../../shared/models/avatars/avatar.model' | 4 | import { Avatar } from '../../../../../shared/models/avatars/avatar.model' |
12 | 5 | ||
13 | export type UserConstructorHash = { | ||
14 | id: number, | ||
15 | username: string, | ||
16 | email: string, | ||
17 | role: UserRole, | ||
18 | videoQuota?: number, | ||
19 | videoQuotaDaily?: number, | ||
20 | nsfwPolicy?: NSFWPolicyType, | ||
21 | autoPlayVideo?: boolean, | ||
22 | createdAt?: Date, | ||
23 | account?: AccountServerModel, | ||
24 | videoChannels?: VideoChannel[] | ||
25 | |||
26 | blocked?: boolean | ||
27 | blockedReason?: string | ||
28 | } | ||
29 | export class User implements UserServerModel { | 6 | export class User implements UserServerModel { |
30 | id: number | 7 | id: number |
31 | username: string | 8 | username: string |
32 | email: string | 9 | email: string |
10 | emailVerified: boolean | ||
33 | role: UserRole | 11 | role: UserRole |
34 | nsfwPolicy: NSFWPolicyType | 12 | nsfwPolicy: NSFWPolicyType |
13 | |||
14 | webTorrentEnabled: boolean | ||
35 | autoPlayVideo: boolean | 15 | autoPlayVideo: boolean |
16 | videosHistoryEnabled: boolean | ||
17 | |||
36 | videoQuota: number | 18 | videoQuota: number |
37 | videoQuotaDaily: number | 19 | videoQuotaDaily: number |
38 | account: Account | 20 | account: Account |
@@ -42,7 +24,9 @@ export class User implements UserServerModel { | |||
42 | blocked: boolean | 24 | blocked: boolean |
43 | blockedReason?: string | 25 | blockedReason?: string |
44 | 26 | ||
45 | constructor (hash: UserConstructorHash) { | 27 | notificationSettings?: UserNotificationSetting |
28 | |||
29 | constructor (hash: Partial<UserServerModel>) { | ||
46 | this.id = hash.id | 30 | this.id = hash.id |
47 | this.username = hash.username | 31 | this.username = hash.username |
48 | this.email = hash.email | 32 | this.email = hash.email |
@@ -52,11 +36,15 @@ export class User implements UserServerModel { | |||
52 | this.videoQuota = hash.videoQuota | 36 | this.videoQuota = hash.videoQuota |
53 | this.videoQuotaDaily = hash.videoQuotaDaily | 37 | this.videoQuotaDaily = hash.videoQuotaDaily |
54 | this.nsfwPolicy = hash.nsfwPolicy | 38 | this.nsfwPolicy = hash.nsfwPolicy |
39 | this.webTorrentEnabled = hash.webTorrentEnabled | ||
40 | this.videosHistoryEnabled = hash.videosHistoryEnabled | ||
55 | this.autoPlayVideo = hash.autoPlayVideo | 41 | this.autoPlayVideo = hash.autoPlayVideo |
56 | this.createdAt = hash.createdAt | 42 | this.createdAt = hash.createdAt |
57 | this.blocked = hash.blocked | 43 | this.blocked = hash.blocked |
58 | this.blockedReason = hash.blockedReason | 44 | this.blockedReason = hash.blockedReason |
59 | 45 | ||
46 | this.notificationSettings = hash.notificationSettings | ||
47 | |||
60 | if (hash.account !== undefined) { | 48 | if (hash.account !== undefined) { |
61 | this.account = new Account(hash.account) | 49 | this.account = new Account(hash.account) |
62 | } | 50 | } |
diff --git a/client/src/app/shared/users/user.service.ts b/client/src/app/shared/users/user.service.ts index d9b81c181..cc5c051f1 100644 --- a/client/src/app/shared/users/user.service.ts +++ b/client/src/app/shared/users/user.service.ts | |||
@@ -1,5 +1,5 @@ | |||
1 | import { Observable } from 'rxjs' | 1 | import { from, Observable } from 'rxjs' |
2 | import { catchError, map } from 'rxjs/operators' | 2 | import { catchError, concatMap, map, toArray } from 'rxjs/operators' |
3 | import { HttpClient, HttpParams } from '@angular/common/http' | 3 | import { HttpClient, HttpParams } from '@angular/common/http' |
4 | import { Injectable } from '@angular/core' | 4 | import { Injectable } from '@angular/core' |
5 | import { ResultList, User, UserCreate, UserRole, UserUpdate, UserUpdateMe, UserVideoQuota } from '../../../../../shared' | 5 | import { ResultList, User, UserCreate, UserRole, UserUpdate, UserUpdateMe, UserVideoQuota } from '../../../../../shared' |
@@ -153,15 +153,26 @@ export class UserService { | |||
153 | ) | 153 | ) |
154 | } | 154 | } |
155 | 155 | ||
156 | updateUsers (users: User[], userUpdate: UserUpdate) { | ||
157 | return from(users) | ||
158 | .pipe( | ||
159 | concatMap(u => this.authHttp.put(UserService.BASE_USERS_URL + u.id, userUpdate)), | ||
160 | toArray(), | ||
161 | catchError(err => this.restExtractor.handleError(err)) | ||
162 | ) | ||
163 | } | ||
164 | |||
156 | getUser (userId: number) { | 165 | getUser (userId: number) { |
157 | return this.authHttp.get<User>(UserService.BASE_USERS_URL + userId) | 166 | return this.authHttp.get<User>(UserService.BASE_USERS_URL + userId) |
158 | .pipe(catchError(err => this.restExtractor.handleError(err))) | 167 | .pipe(catchError(err => this.restExtractor.handleError(err))) |
159 | } | 168 | } |
160 | 169 | ||
161 | getUsers (pagination: RestPagination, sort: SortMeta): Observable<ResultList<User>> { | 170 | getUsers (pagination: RestPagination, sort: SortMeta, search?: string): Observable<ResultList<User>> { |
162 | let params = new HttpParams() | 171 | let params = new HttpParams() |
163 | params = this.restService.addRestGetParams(params, pagination, sort) | 172 | params = this.restService.addRestGetParams(params, pagination, sort) |
164 | 173 | ||
174 | if (search) params = params.append('search', search) | ||
175 | |||
165 | return this.authHttp.get<ResultList<User>>(UserService.BASE_USERS_URL, { params }) | 176 | return this.authHttp.get<ResultList<User>>(UserService.BASE_USERS_URL, { params }) |
166 | .pipe( | 177 | .pipe( |
167 | map(res => this.restExtractor.convertResultListDateToHuman(res)), | 178 | map(res => this.restExtractor.convertResultListDateToHuman(res)), |
@@ -170,21 +181,38 @@ export class UserService { | |||
170 | ) | 181 | ) |
171 | } | 182 | } |
172 | 183 | ||
173 | removeUser (user: { id: number }) { | 184 | removeUser (usersArg: User | User[]) { |
174 | return this.authHttp.delete(UserService.BASE_USERS_URL + user.id) | 185 | const users = Array.isArray(usersArg) ? usersArg : [ usersArg ] |
175 | .pipe(catchError(err => this.restExtractor.handleError(err))) | 186 | |
187 | return from(users) | ||
188 | .pipe( | ||
189 | concatMap(u => this.authHttp.delete(UserService.BASE_USERS_URL + u.id)), | ||
190 | toArray(), | ||
191 | catchError(err => this.restExtractor.handleError(err)) | ||
192 | ) | ||
176 | } | 193 | } |
177 | 194 | ||
178 | banUser (user: { id: number }, reason?: string) { | 195 | banUsers (usersArg: User | User[], reason?: string) { |
179 | const body = reason ? { reason } : {} | 196 | const body = reason ? { reason } : {} |
197 | const users = Array.isArray(usersArg) ? usersArg : [ usersArg ] | ||
180 | 198 | ||
181 | return this.authHttp.post(UserService.BASE_USERS_URL + user.id + '/block', body) | 199 | return from(users) |
182 | .pipe(catchError(err => this.restExtractor.handleError(err))) | 200 | .pipe( |
201 | concatMap(u => this.authHttp.post(UserService.BASE_USERS_URL + u.id + '/block', body)), | ||
202 | toArray(), | ||
203 | catchError(err => this.restExtractor.handleError(err)) | ||
204 | ) | ||
183 | } | 205 | } |
184 | 206 | ||
185 | unbanUser (user: { id: number }) { | 207 | unbanUsers (usersArg: User | User[]) { |
186 | return this.authHttp.post(UserService.BASE_USERS_URL + user.id + '/unblock', {}) | 208 | const users = Array.isArray(usersArg) ? usersArg : [ usersArg ] |
187 | .pipe(catchError(err => this.restExtractor.handleError(err))) | 209 | |
210 | return from(users) | ||
211 | .pipe( | ||
212 | concatMap(u => this.authHttp.post(UserService.BASE_USERS_URL + u.id + '/unblock', {})), | ||
213 | toArray(), | ||
214 | catchError(err => this.restExtractor.handleError(err)) | ||
215 | ) | ||
188 | } | 216 | } |
189 | 217 | ||
190 | private formatUser (user: User) { | 218 | private formatUser (user: User) { |
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 d543ab7c1..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,21 @@ | |||
1 | <div [ngClass]="{ 'margin-content': marginContent }"> | 1 | <div [ngClass]="{ 'margin-content': marginContent }"> |
2 | <div *ngIf="titlePage" class="title-page title-page-single"> | 2 | <div class="videos-header"> |
3 | {{ titlePage }} | 3 | <div *ngIf="titlePage" class="title-page title-page-single"> |
4 | <div placement="bottom" [ngbTooltip]="titleTooltip" container="body"> | ||
5 | {{ titlePage }} | ||
6 | </div> | ||
7 | </div> | ||
8 | |||
9 | <my-feed [syndicationItems]="syndicationItems"></my-feed> | ||
10 | |||
11 | <div class="moderation-block" *ngIf="displayModerationBlock"> | ||
12 | <my-peertube-checkbox | ||
13 | (change)="toggleModerationDisplay()" | ||
14 | inputName="display-unlisted-private" i18n-labelText labelText="Display unlisted and private videos" | ||
15 | > | ||
16 | </my-peertube-checkbox> | ||
17 | </div> | ||
4 | </div> | 18 | </div> |
5 | <my-video-feed [syndicationItems]="syndicationItems"></my-video-feed> | ||
6 | 19 | ||
7 | <div class="no-results" i18n *ngIf="pagination.totalItems === 0">No results.</div> | 20 | <div class="no-results" i18n *ngIf="pagination.totalItems === 0">No results.</div> |
8 | <div | 21 | <div |
diff --git a/client/src/app/shared/video/abstract-video-list.scss b/client/src/app/shared/video/abstract-video-list.scss index 3f9c73a29..292ede698 100644 --- a/client/src/app/shared/video/abstract-video-list.scss +++ b/client/src/app/shared/video/abstract-video-list.scss | |||
@@ -8,12 +8,27 @@ | |||
8 | } | 8 | } |
9 | } | 9 | } |
10 | 10 | ||
11 | .title-page.title-page-single { | 11 | .videos-header { |
12 | margin-right: 5px; | 12 | display: flex; |
13 | } | 13 | height: 80px; |
14 | align-items: center; | ||
15 | |||
16 | .title-page.title-page-single { | ||
17 | margin: 0 5px 0 0; | ||
18 | } | ||
14 | 19 | ||
15 | my-video-feed { | 20 | my-feed { |
16 | display: inline-block; | 21 | display: inline-block; |
22 | top: 1px; | ||
23 | min-width: 60px; | ||
24 | } | ||
25 | |||
26 | .moderation-block { | ||
27 | display: flex; | ||
28 | flex-grow: 1; | ||
29 | justify-content: flex-end; | ||
30 | align-items: center; | ||
31 | } | ||
17 | } | 32 | } |
18 | 33 | ||
19 | @media screen and (max-width: 500px) { | 34 | @media screen and (max-width: 500px) { |
diff --git a/client/src/app/shared/video/abstract-video-list.ts b/client/src/app/shared/video/abstract-video-list.ts index 763791165..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' | |||
3 | import { ActivatedRoute, Router } from '@angular/router' | 3 | import { ActivatedRoute, Router } from '@angular/router' |
4 | import { Location } from '@angular/common' | 4 | import { Location } from '@angular/common' |
5 | import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive' | 5 | import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive' |
6 | import { NotificationsService } from 'angular2-notifications' | ||
7 | import { fromEvent, Observable, Subscription } from 'rxjs' | 6 | import { fromEvent, Observable, Subscription } from 'rxjs' |
8 | import { AuthService } from '../../core/auth' | 7 | import { AuthService } from '../../core/auth' |
9 | import { ComponentPagination } from '../rest/component-pagination.model' | 8 | import { ComponentPagination } from '../rest/component-pagination.model' |
@@ -12,6 +11,8 @@ import { Video } from './video.model' | |||
12 | import { I18n } from '@ngx-translate/i18n-polyfill' | 11 | import { I18n } from '@ngx-translate/i18n-polyfill' |
13 | import { ScreenService } from '@app/shared/misc/screen.service' | 12 | import { ScreenService } from '@app/shared/misc/screen.service' |
14 | import { OwnerDisplayType } from '@app/shared/video/video-miniature.component' | 13 | import { OwnerDisplayType } from '@app/shared/video/video-miniature.component' |
14 | import { Syndication } from '@app/shared/video/syndication.model' | ||
15 | import { Notifier } from '@app/core' | ||
15 | 16 | ||
16 | export abstract class AbstractVideoList implements OnInit, OnDestroy { | 17 | export abstract class AbstractVideoList implements OnInit, OnDestroy { |
17 | private static LINES_PER_PAGE = 4 | 18 | private static LINES_PER_PAGE = 4 |
@@ -27,7 +28,7 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy { | |||
27 | sort: VideoSortField = '-publishedAt' | 28 | sort: VideoSortField = '-publishedAt' |
28 | categoryOneOf?: number | 29 | categoryOneOf?: number |
29 | defaultSort: VideoSortField = '-publishedAt' | 30 | defaultSort: VideoSortField = '-publishedAt' |
30 | syndicationItems = [] | 31 | syndicationItems: Syndication[] = [] |
31 | 32 | ||
32 | loadOnInit = true | 33 | loadOnInit = true |
33 | marginContent = true | 34 | marginContent = true |
@@ -37,11 +38,13 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy { | |||
37 | videoPages: Video[][] = [] | 38 | videoPages: Video[][] = [] |
38 | ownerDisplayType: OwnerDisplayType = 'account' | 39 | ownerDisplayType: OwnerDisplayType = 'account' |
39 | firstLoadedPage: number | 40 | firstLoadedPage: number |
41 | displayModerationBlock = false | ||
42 | titleTooltip: string | ||
40 | 43 | ||
41 | protected baseVideoWidth = 215 | 44 | protected baseVideoWidth = 215 |
42 | protected baseVideoHeight = 205 | 45 | protected baseVideoHeight = 205 |
43 | 46 | ||
44 | protected abstract notificationsService: NotificationsService | 47 | protected abstract notifier: Notifier |
45 | protected abstract authService: AuthService | 48 | protected abstract authService: AuthService |
46 | protected abstract router: Router | 49 | protected abstract router: Router |
47 | protected abstract route: ActivatedRoute | 50 | protected abstract route: ActivatedRoute |
@@ -58,7 +61,7 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy { | |||
58 | private resizeSubscription: Subscription | 61 | private resizeSubscription: Subscription |
59 | 62 | ||
60 | abstract getVideosObservable (page: number): Observable<{ videos: Video[], totalVideos: number}> | 63 | abstract getVideosObservable (page: number): Observable<{ videos: Video[], totalVideos: number}> |
61 | abstract generateSyndicationList () | 64 | abstract generateSyndicationList (): void |
62 | 65 | ||
63 | get user () { | 66 | get user () { |
64 | return this.authService.getUser() | 67 | return this.authService.getUser() |
@@ -155,11 +158,15 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy { | |||
155 | }, | 158 | }, |
156 | error => { | 159 | error => { |
157 | this.loadingPage[page] = false | 160 | this.loadingPage[page] = false |
158 | this.notificationsService.error(this.i18n('Error'), error.message) | 161 | this.notifier.error(error.message) |
159 | } | 162 | } |
160 | ) | 163 | ) |
161 | } | 164 | } |
162 | 165 | ||
166 | toggleModerationDisplay () { | ||
167 | throw new Error('toggleModerationDisplay is not implemented') | ||
168 | } | ||
169 | |||
163 | protected hasMoreVideos () { | 170 | protected hasMoreVideos () { |
164 | // No results | 171 | // No results |
165 | if (this.pagination.totalItems === 0) return false | 172 | if (this.pagination.totalItems === 0) return false |
@@ -206,7 +213,9 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy { | |||
206 | protected setNewRouteParams () { | 213 | protected setNewRouteParams () { |
207 | const paramsObject = this.buildRouteParams() | 214 | const paramsObject = this.buildRouteParams() |
208 | 215 | ||
209 | const queryParams = Object.keys(paramsObject).map(p => p + '=' + paramsObject[p]).join('&') | 216 | const queryParams = Object.keys(paramsObject) |
217 | .map(p => p + '=' + paramsObject[p]) | ||
218 | .join('&') | ||
210 | this.location.replaceState(this.currentRoute, queryParams) | 219 | this.location.replaceState(this.currentRoute, queryParams) |
211 | } | 220 | } |
212 | 221 | ||
diff --git a/client/src/app/shared/video/video-feed.component.html b/client/src/app/shared/video/feed.component.html index 16116ba88..f7624ec01 100644 --- a/client/src/app/shared/video/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 new file mode 100644 index 000000000..ed1dc17d3 --- /dev/null +++ b/client/src/app/shared/video/feed.component.scss | |||
@@ -0,0 +1,18 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | .video-feed { | ||
5 | a { | ||
6 | color: black; | ||
7 | display: block; | ||
8 | } | ||
9 | |||
10 | my-global-icon { | ||
11 | cursor: pointer; | ||
12 | width: 12px; | ||
13 | position: relative; | ||
14 | top: -2px; | ||
15 | |||
16 | @include apply-svg-color(var(--mainForegroundColor)) | ||
17 | } | ||
18 | } | ||
diff --git a/client/src/app/shared/video/feed.component.ts b/client/src/app/shared/video/feed.component.ts new file mode 100644 index 000000000..12507458f --- /dev/null +++ b/client/src/app/shared/video/feed.component.ts | |||
@@ -0,0 +1,11 @@ | |||
1 | import { Component, Input } from '@angular/core' | ||
2 | import { Syndication } from '@app/shared/video/syndication.model' | ||
3 | |||
4 | @Component({ | ||
5 | selector: 'my-feed', | ||
6 | styleUrls: [ './feed.component.scss' ], | ||
7 | templateUrl: './feed.component.html' | ||
8 | }) | ||
9 | export class FeedComponent { | ||
10 | @Input() syndicationItems: Syndication[] | ||
11 | } | ||
diff --git a/client/src/app/shared/video/syndication.model.ts b/client/src/app/shared/video/syndication.model.ts new file mode 100644 index 000000000..c59ab01e8 --- /dev/null +++ b/client/src/app/shared/video/syndication.model.ts | |||
@@ -0,0 +1,7 @@ | |||
1 | import { FeedFormat } from '../../../../../shared/models/feeds/feed-format.enum' | ||
2 | |||
3 | export interface Syndication { | ||
4 | format: FeedFormat, | ||
5 | label: string, | ||
6 | url: string | ||
7 | } | ||
diff --git a/client/src/app/shared/video/video-details.model.ts b/client/src/app/shared/video/video-details.model.ts index 5ff3926c4..388357343 100644 --- a/client/src/app/shared/video/video-details.model.ts +++ b/client/src/app/shared/video/video-details.model.ts | |||
@@ -3,6 +3,8 @@ import { AuthUser } from '../../core' | |||
3 | import { Video } from '../../shared/video/video.model' | 3 | import { Video } from '../../shared/video/video.model' |
4 | import { Account } from '@app/shared/account/account.model' | 4 | import { Account } from '@app/shared/account/account.model' |
5 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' | 5 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' |
6 | import { VideoStreamingPlaylist } from '../../../../../shared/models/videos/video-streaming-playlist.model' | ||
7 | import { VideoStreamingPlaylistType } from '../../../../../shared/models/videos/video-streaming-playlist.type' | ||
6 | 8 | ||
7 | export class VideoDetails extends Video implements VideoDetailsServerModel { | 9 | export class VideoDetails extends Video implements VideoDetailsServerModel { |
8 | descriptionPath: string | 10 | descriptionPath: string |
@@ -20,6 +22,10 @@ export class VideoDetails extends Video implements VideoDetailsServerModel { | |||
20 | likesPercent: number | 22 | likesPercent: number |
21 | dislikesPercent: number | 23 | dislikesPercent: number |
22 | 24 | ||
25 | trackerUrls: string[] | ||
26 | |||
27 | streamingPlaylists: VideoStreamingPlaylist[] | ||
28 | |||
23 | constructor (hash: VideoDetailsServerModel, translations = {}) { | 29 | constructor (hash: VideoDetailsServerModel, translations = {}) { |
24 | super(hash, translations) | 30 | super(hash, translations) |
25 | 31 | ||
@@ -32,6 +38,9 @@ export class VideoDetails extends Video implements VideoDetailsServerModel { | |||
32 | this.commentsEnabled = hash.commentsEnabled | 38 | this.commentsEnabled = hash.commentsEnabled |
33 | this.downloadEnabled = hash.downloadEnabled | 39 | this.downloadEnabled = hash.downloadEnabled |
34 | 40 | ||
41 | this.trackerUrls = hash.trackerUrls | ||
42 | this.streamingPlaylists = hash.streamingPlaylists | ||
43 | |||
35 | this.buildLikeAndDislikePercents() | 44 | this.buildLikeAndDislikePercents() |
36 | } | 45 | } |
37 | 46 | ||
@@ -55,4 +64,8 @@ export class VideoDetails extends Video implements VideoDetailsServerModel { | |||
55 | this.likesPercent = (this.likes / (this.likes + this.dislikes)) * 100 | 64 | this.likesPercent = (this.likes / (this.likes + this.dislikes)) * 100 |
56 | this.dislikesPercent = (this.dislikes / (this.likes + this.dislikes)) * 100 | 65 | this.dislikesPercent = (this.dislikes / (this.likes + this.dislikes)) * 100 |
57 | } | 66 | } |
67 | |||
68 | getHlsPlaylist () { | ||
69 | return this.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS) | ||
70 | } | ||
58 | } | 71 | } |
diff --git a/client/src/app/shared/video/video-edit.model.ts b/client/src/app/shared/video/video-edit.model.ts index 5bb0510f9..18c62a1f9 100644 --- a/client/src/app/shared/video/video-edit.model.ts +++ b/client/src/app/shared/video/video-edit.model.ts | |||
@@ -59,14 +59,14 @@ export class VideoEdit implements VideoUpdate { | |||
59 | } | 59 | } |
60 | } | 60 | } |
61 | 61 | ||
62 | patch (values: Object) { | 62 | patch (values: { [ id: string ]: string }) { |
63 | Object.keys(values).forEach((key) => { | 63 | Object.keys(values).forEach((key) => { |
64 | this[ key ] = values[ key ] | 64 | this[ key ] = values[ key ] |
65 | }) | 65 | }) |
66 | 66 | ||
67 | // If schedule publication, the video is private and will be changed to public privacy | 67 | // If schedule publication, the video is private and will be changed to public privacy |
68 | if (parseInt(values['privacy'], 10) === VideoEdit.SPECIAL_SCHEDULED_PRIVACY) { | 68 | if (parseInt(values['privacy'], 10) === VideoEdit.SPECIAL_SCHEDULED_PRIVACY) { |
69 | const updateAt = (values['schedulePublicationAt'] as Date) | 69 | const updateAt = new Date(values['schedulePublicationAt']) |
70 | updateAt.setSeconds(0) | 70 | updateAt.setSeconds(0) |
71 | 71 | ||
72 | this.privacy = VideoPrivacy.PRIVATE | 72 | this.privacy = VideoPrivacy.PRIVATE |
diff --git a/client/src/app/shared/video/video-feed.component.scss b/client/src/app/shared/video/video-feed.component.scss deleted file mode 100644 index 385764be0..000000000 --- a/client/src/app/shared/video/video-feed.component.scss +++ /dev/null | |||
@@ -1,19 +0,0 @@ | |||
1 | @import '_mixins'; | ||
2 | |||
3 | .video-feed { | ||
4 | a { | ||
5 | color: black; | ||
6 | display: block; | ||
7 | } | ||
8 | |||
9 | .icon { | ||
10 | @include icon(12px); | ||
11 | |||
12 | &.icon-syndication { | ||
13 | position: relative; | ||
14 | top: -2px; | ||
15 | background-color: var(--mainForegroundColor); | ||
16 | mask-image: url('../../../assets/images/global/syndication.svg'); | ||
17 | } | ||
18 | } | ||
19 | } \ No newline at end of file | ||
diff --git a/client/src/app/shared/video/video-feed.component.ts b/client/src/app/shared/video/video-feed.component.ts deleted file mode 100644 index 6922153c0..000000000 --- a/client/src/app/shared/video/video-feed.component.ts +++ /dev/null | |||
@@ -1,10 +0,0 @@ | |||
1 | import { Component, Input } from '@angular/core' | ||
2 | |||
3 | @Component({ | ||
4 | selector: 'my-video-feed', | ||
5 | styleUrls: [ './video-feed.component.scss' ], | ||
6 | templateUrl: './video-feed.component.html' | ||
7 | }) | ||
8 | export class VideoFeedComponent { | ||
9 | @Input() syndicationItems | ||
10 | } | ||
diff --git a/client/src/app/shared/video/video-miniature.component.html b/client/src/app/shared/video/video-miniature.component.html index cfc483018..2c635fa2f 100644 --- a/client/src/app/shared/video/video-miniature.component.html +++ b/client/src/app/shared/video/video-miniature.component.html | |||
@@ -7,6 +7,9 @@ | |||
7 | class="video-miniature-name" | 7 | class="video-miniature-name" |
8 | [routerLink]="[ '/videos/watch', video.uuid ]" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }" | 8 | [routerLink]="[ '/videos/watch', video.uuid ]" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }" |
9 | > | 9 | > |
10 | <span *ngIf="isUnlistedVideo()" class="badge badge-warning" i18n>Unlisted</span> | ||
11 | <span *ngIf="isPrivateVideo()" class="badge badge-danger" i18n>Private</span> | ||
12 | |||
10 | {{ video.name }} | 13 | {{ video.name }} |
11 | </a> | 14 | </a> |
12 | 15 | ||
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-miniature.component.ts b/client/src/app/shared/video/video-miniature.component.ts index 7e8692b0b..2f951a1f1 100644 --- a/client/src/app/shared/video/video-miniature.component.ts +++ b/client/src/app/shared/video/video-miniature.component.ts | |||
@@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core | |||
2 | import { User } from '../users' | 2 | import { User } from '../users' |
3 | import { Video } from './video.model' | 3 | import { Video } from './video.model' |
4 | import { ServerService } from '@app/core' | 4 | import { ServerService } from '@app/core' |
5 | import { VideoPrivacy } from '../../../../../shared' | ||
5 | 6 | ||
6 | export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto' | 7 | export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto' |
7 | 8 | ||
@@ -49,4 +50,12 @@ export class VideoMiniatureComponent implements OnInit { | |||
49 | displayOwnerVideoChannel () { | 50 | displayOwnerVideoChannel () { |
50 | return this.ownerDisplayTypeChosen === 'videoChannel' | 51 | return this.ownerDisplayTypeChosen === 'videoChannel' |
51 | } | 52 | } |
53 | |||
54 | isUnlistedVideo () { | ||
55 | return this.video.privacy.id === VideoPrivacy.UNLISTED | ||
56 | } | ||
57 | |||
58 | isPrivateVideo () { | ||
59 | return this.video.privacy.id === VideoPrivacy.PRIVATE | ||
60 | } | ||
52 | } | 61 | } |
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?: { |
diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts index c9d6da7a4..565aad93b 100644 --- a/client/src/app/shared/video/video.service.ts +++ b/client/src/app/shared/video/video.service.ts | |||
@@ -6,11 +6,11 @@ import { Video as VideoServerModel, VideoDetails as VideoDetailsServerModel } fr | |||
6 | import { ResultList } from '../../../../../shared/models/result-list.model' | 6 | import { ResultList } from '../../../../../shared/models/result-list.model' |
7 | import { | 7 | import { |
8 | UserVideoRate, | 8 | UserVideoRate, |
9 | UserVideoRateType, | ||
9 | UserVideoRateUpdate, | 10 | UserVideoRateUpdate, |
10 | VideoConstant, | 11 | VideoConstant, |
11 | VideoFilter, | 12 | VideoFilter, |
12 | VideoPrivacy, | 13 | VideoPrivacy, |
13 | VideoRateType, | ||
14 | VideoUpdate | 14 | VideoUpdate |
15 | } from '../../../../../shared/models/videos' | 15 | } from '../../../../../shared/models/videos' |
16 | import { FeedFormat } from '../../../../../shared/models/feeds/feed-format.enum' | 16 | import { FeedFormat } from '../../../../../shared/models/feeds/feed-format.enum' |
@@ -275,9 +275,9 @@ export class VideoService implements VideosProvider { | |||
275 | 275 | ||
276 | loadCompleteDescription (descriptionPath: string) { | 276 | loadCompleteDescription (descriptionPath: string) { |
277 | return this.authHttp | 277 | return this.authHttp |
278 | .get(environment.apiUrl + descriptionPath) | 278 | .get<{ description: string }>(environment.apiUrl + descriptionPath) |
279 | .pipe( | 279 | .pipe( |
280 | map(res => res[ 'description' ]), | 280 | map(res => res.description), |
281 | catchError(err => this.restExtractor.handleError(err)) | 281 | catchError(err => this.restExtractor.handleError(err)) |
282 | ) | 282 | ) |
283 | } | 283 | } |
@@ -333,7 +333,7 @@ export class VideoService implements VideosProvider { | |||
333 | return privacies | 333 | return privacies |
334 | } | 334 | } |
335 | 335 | ||
336 | private setVideoRate (id: number, rateType: VideoRateType) { | 336 | private setVideoRate (id: number, rateType: UserVideoRateType) { |
337 | const url = VideoService.BASE_VIDEO_URL + id + '/rate' | 337 | const url = VideoService.BASE_VIDEO_URL + id + '/rate' |
338 | const body: UserVideoRateUpdate = { | 338 | const body: UserVideoRateUpdate = { |
339 | rating: rateType | 339 | rating: rateType |