Merge branch 'develop' into pr/1285

This commit is contained in:
Chocobozzz 2019-02-11 14:09:23 +01:00
commit b718fd2237
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
387 changed files with 18290 additions and 10001 deletions

View File

@ -1,10 +1,99 @@
# Changelog # Changelog
## v1.2.0
### BREAKING CHANGES
* **Docker:** `PEERTUBE_TRUST_PROXY` env variable is now an array ([LecygneNoir](https://github.com/LecygneNoir))
* **Docker:** Check you have all the storage fields in your `/config/production.yaml` file: https://github.com/Chocobozzz/PeerTube/blob/develop/support/docker/production/config/production.yaml#L34
* **nginx:** Add redundancy endpoint in static file. **You should add it in your nginx configuration: https://github.com/Chocobozzz/PeerTube/blob/develop/support/doc/production.md#nginx**
* **nginx:** Add socket io endpoint. **You should add it in your nginx configuration: https://github.com/Chocobozzz/PeerTube/blob/develop/support/doc/production.md#nginx**
* Moderators can manage users now (add/delete/update/block)
* Add `tmp` and `redundancy` directories in configuration file. **You should configure them in your production.yaml**
### Maintenance
* Check free storage before upgrading in upgrade script ([@Nutomic](https://github.com/nutomic))
* Explain that PeerTube must be stopped in prune storage script
* Add some security directives in the systemd unit configuration file ([@rigelk](https://github.com/rigelk) & [@mkoppmann](https://github.com/mkoppmann))
* Update FreeBSD startup script ([@gegeweb](https://github.com/gegeweb))
### Docker
* Patch docker entrypoint to speed up the chown at startup ([LecygneNoir](https://github.com/LecygneNoir))
### Features
* Add Russian, Polish and Italian languages
* Add user notifications:
* Notification types:
* Comment on my video
* New video from my subscriptions
* New video abuses (for moderators)
* Blacklist/Unblacklist on my video
* Video import finished (error or success)
* Pending video published (after transcoding or a scheduled update)
* My account or one of my channel has a new follower
* Someone (except muted accounts) mentioned me in comments
* A user registered on the instance (for moderators)
* Notification actions:
* Add a web notification
* Send an english email
* Add contact form in about page (**enabled by default**)
* Add ability to unfederate a local video in blacklist modal (**checkbox checked by default**)
* Support additional video extensions if transcoding is enabled (**enabled by default**)
* Redirect to the last url on login
* Add ability to automatically set the video caption in URL. Example: https://peertube2.cpy.re/videos/watch/9c9de5e8-0a1e-484a-b099-e80766180a6d?subtitle=ru
* Automatically enable the last selected caption when watching a video
* Add ability to disable, clear and list user videos history
* Add a button to help to translate peertube
* Add text in the report modal to explain to whom the report will be sent
* Open my account menu entries on hover
* Explain what features are enabled on the instance in the about page
* Add an error message in the forgot password modal if the instance email system is not configured
* Add sitemap
* Add well known url to change password ([@rigelk](https://github.com/rigelk))
* Remove 8GB video upload limit on client side. There may still be such limit depending on the reverse proxy configuration ([@scanlime](https://github.com/scanlime))
* Add CSP ([@rigelk](https://github.com/rigelk) & [@Nutomic](https://github.com/nutomic))
* Update title and description HTML tags when rendering video HTML page
* Add webfinger support for remote follows ([@acid-chicken](https://github.com/acid-chicken))
* Add tooltip to explain how the trending algorithm works ([@auberanger](https://github.com/auberanger))
* Warn users when they want to delete a channel because they will not be able to create another channel with the same name
* Warn users when they leave the video upload/update (on page refresh/tab close)
* Set max user name, user display name, channel name and channel display name lengths to 50 characters ([@McFlat](https://github.com/mcflat))
* Increase video abuse length to 3000 characters
* Add totalLocalVideoFilesSize in the stats endpoint
## Bug fixes
* Fix the addition of captions to a video
* Fix federation of some videos
* Fix NSFW blur on search
* Add error message when trying to upload .ass subtitles
* Fix default homepage in the progressive web application
* Don't crash on queue error
* Fix EXDEV errors if you have multiple mount points
* Fix broken audio in transcoding with some videos
* Fix crash on getVideoFileStream issue
* Fix followers search
* Remove trailing `/` in CLI import script ([@HesioZ](https://github.com/HesioZ/))
* Use origin video url in canonical tag
* Fix captions in HTTP fallback
* Automatically refresh remote actors to fix deleted remote actors that are still displayed on some instances
* Add missing translations in video embed page
* Fix some styling issues in dark mode
* Fix transcoding issues with some videos
* Fix Mac OS mkv/avi upload
* Fix menu overflow on mobile
* Fix ownership button icons ([@joshmorel](https://github.com/joshmorel))
## v1.1.0 ## v1.1.0
***Since v1.0.1*** ***Since v1.0.1***
### BREAKING CHANGES ### BREAKING CHANGES
* **Docker:** `PEERTUBE_TRUST_PROXY` env variable is now an array ([LecygneNoir](https://github.com/LecygneNoir)) * **Docker:** `PEERTUBE_TRUST_PROXY` env variable is now an array ([LecygneNoir](https://github.com/LecygneNoir))
### Maintenance ### Maintenance

View File

@ -133,7 +133,7 @@ You can also join the cheerful bunch that makes our community:
* Chat<a name="contact"></a>: * Chat<a name="contact"></a>:
* IRC : **[#peertube on chat.freenode.net:6697](https://kiwiirc.com/client/irc.freenode.net/#peertube)** * IRC : **[#peertube on chat.freenode.net:6697](https://kiwiirc.com/client/irc.freenode.net/#peertube)**
* Matrix (bridged on the IRC channel) : **[#peertube:matrix.org](https://matrix.to/#/#peertube:matrix.org)** * Matrix (bridged on IRC and [Discord](https://discord.gg/wj8DDUT)) : **[#peertube:matrix.org](https://matrix.to/#/#peertube:matrix.org)**
* Forum: * Forum:
* Framacolibri: [https://framacolibri.org/c/peertube](https://framacolibri.org/c/peertube) * Framacolibri: [https://framacolibri.org/c/peertube](https://framacolibri.org/c/peertube)

View File

@ -1,6 +1,6 @@
{ {
"name": "peertube-client", "name": "peertube-client",
"version": "1.1.0", "version": "1.2.0",
"private": true, "private": true,
"licence": "GPLv3", "licence": "GPLv3",
"author": { "author": {
@ -28,7 +28,8 @@
"resolutions": { "resolutions": {
"video.js": "^7", "video.js": "^7",
"webtorrent/create-torrent/junk": "^1", "webtorrent/create-torrent/junk": "^1",
"simple-get": "^2.8.1" "simple-get": "^2.8.1",
"punycode": "^1.4.1"
}, },
"jest": { "jest": {
"globals": { "globals": {
@ -63,20 +64,20 @@
"setupTestFrameworkScriptFile": "<rootDir>/src/setupJest.ts" "setupTestFrameworkScriptFile": "<rootDir>/src/setupJest.ts"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "~0.11.1", "@angular-devkit/build-angular": "~0.13.1",
"@angular/animations": "~7.1.1", "@angular/animations": "~7.2.4",
"@angular/cli": "~7.1.1", "@angular/cli": "~7.3.1",
"@angular/common": "~7.1.1", "@angular/common": "~7.2.4",
"@angular/compiler": "~7.1.1", "@angular/compiler": "~7.2.4",
"@angular/compiler-cli": "~7.1.1", "@angular/compiler-cli": "~7.2.4",
"@angular/core": "~7.1.1", "@angular/core": "~7.2.4",
"@angular/forms": "~7.1.1", "@angular/forms": "~7.2.4",
"@angular/http": "~7.1.1", "@angular/http": "~7.2.4",
"@angular/language-service": "~7.1.1", "@angular/language-service": "~7.2.4",
"@angular/platform-browser": "~7.1.1", "@angular/platform-browser": "~7.2.4",
"@angular/platform-browser-dynamic": "~7.1.1", "@angular/platform-browser-dynamic": "~7.2.4",
"@angular/router": "~7.1.1", "@angular/router": "~7.2.4",
"@angular/service-worker": "~7.1.1", "@angular/service-worker": "~7.2.4",
"@angularclass/hmr": "^2.1.3", "@angularclass/hmr": "^2.1.3",
"@neos21/bootstrap3-glyphicons": "^1.0.1", "@neos21/bootstrap3-glyphicons": "^1.0.1",
"@ng-bootstrap/ng-bootstrap": "^4.0.0", "@ng-bootstrap/ng-bootstrap": "^4.0.0",
@ -85,7 +86,9 @@
"@ngx-loading-bar/router": "^3.0.0", "@ngx-loading-bar/router": "^3.0.0",
"@ngx-meta/core": "^6.0.0-rc.1", "@ngx-meta/core": "^6.0.0-rc.1",
"@ngx-translate/i18n-polyfill": "^1.0.0", "@ngx-translate/i18n-polyfill": "^1.0.0",
"@streamroot/videojs-hlsjs-plugin": "^1.0.7",
"@types/core-js": "^2.5.0", "@types/core-js": "^2.5.0",
"@types/hls.js": "^0.12.0",
"@types/jasmine": "^2.8.7", "@types/jasmine": "^2.8.7",
"@types/jasminewd2": "^2.0.3", "@types/jasminewd2": "^2.0.3",
"@types/jest": "^23.3.1", "@types/jest": "^23.3.1",
@ -109,6 +112,7 @@
"extract-text-webpack-plugin": "4.0.0-beta.0", "extract-text-webpack-plugin": "4.0.0-beta.0",
"file-loader": "^2.0.0", "file-loader": "^2.0.0",
"focus-visible": "^4.1.5", "focus-visible": "^4.1.5",
"hls.js": "^0.12.2",
"html-loader": "^0.5.5", "html-loader": "^0.5.5",
"html-webpack-plugin": "^3.2.0", "html-webpack-plugin": "^3.2.0",
"https-browserify": "^1.0.0", "https-browserify": "^1.0.0",
@ -131,6 +135,7 @@
"ngx-qrcode2": "^0.0.9", "ngx-qrcode2": "^0.0.9",
"node-sass": "^4.9.3", "node-sass": "^4.9.3",
"npm-font-source-sans-pro": "^1.0.2", "npm-font-source-sans-pro": "^1.0.2",
"p2p-media-loader-hlsjs": "^0.4.0",
"path-browserify": "^1.0.0", "path-browserify": "^1.0.0",
"primeng": "^7.0.0", "primeng": "^7.0.0",
"process": "^0.11.10", "process": "^0.11.10",
@ -152,9 +157,9 @@
"typescript": "3.1.6", "typescript": "3.1.6",
"video.js": "^7", "video.js": "^7",
"videojs-contextmenu-ui": "^5.0.0", "videojs-contextmenu-ui": "^5.0.0",
"videojs-contrib-quality-levels": "^2.0.9",
"videojs-dock": "^2.0.2", "videojs-dock": "^2.0.2",
"videojs-hotkeys": "^0.2.21", "videojs-hotkeys": "^0.2.21",
"webpack": "^4.17.1",
"webpack-bundle-analyzer": "^3.0.2", "webpack-bundle-analyzer": "^3.0.2",
"webpack-cli": "^3.0.8", "webpack-cli": "^3.0.8",
"webtorrent": "https://github.com/webtorrent/webtorrent#e9b209c7970816fc29e0cc871157a4918d66001d", "webtorrent": "https://github.com/webtorrent/webtorrent#e9b209c7970816fc29e0cc871157a4918d66001d",

View File

@ -1,9 +1,9 @@
import { Component, OnInit, ViewChild } from '@angular/core' import { Component, OnInit, ViewChild } from '@angular/core'
import { Notifier, ServerService } from '@app/core' import { Notifier, ServerService } from '@app/core'
import { MarkdownService } from '@app/videos/shared'
import { I18n } from '@ngx-translate/i18n-polyfill' import { I18n } from '@ngx-translate/i18n-polyfill'
import { ContactAdminModalComponent } from '@app/+about/about-instance/contact-admin-modal.component' import { ContactAdminModalComponent } from '@app/+about/about-instance/contact-admin-modal.component'
import { InstanceService } from '@app/shared/instance/instance.service' import { InstanceService } from '@app/shared/instance/instance.service'
import { MarkdownService } from '@app/shared/renderer'
@Component({ @Component({
selector: 'my-about-instance', selector: 'my-about-instance',

View File

@ -1,7 +1,7 @@
<ng-template #modal> <ng-template #modal>
<div class="modal-header"> <div class="modal-header">
<h4 i18n class="modal-title">Contact {{ instanceName }} administrator</h4> <h4 i18n class="modal-title">Contact {{ instanceName }} administrator</h4>
<span class="close" aria-label="Close" role="button" (click)="hide()"></span> <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
</div> </div>
<div class="modal-body"> <div class="modal-body">

View File

@ -1,9 +1,9 @@
import { Component, OnInit, OnDestroy } from '@angular/core' import { Component, OnDestroy, OnInit } from '@angular/core'
import { Account } from '@app/shared/account/account.model' import { Account } from '@app/shared/account/account.model'
import { AccountService } from '@app/shared/account/account.service' import { AccountService } from '@app/shared/account/account.service'
import { I18n } from '@ngx-translate/i18n-polyfill' import { I18n } from '@ngx-translate/i18n-polyfill'
import { Subscription } from 'rxjs' import { Subscription } from 'rxjs'
import { MarkdownService } from '@app/videos/shared' import { MarkdownService } from '@app/shared/renderer'
@Component({ @Component({
selector: 'my-account-about', selector: 'my-account-about',

View File

@ -26,8 +26,7 @@ export class AccountsComponent implements OnInit, OnDestroy {
private notifier: Notifier, private notifier: Notifier,
private restExtractor: RestExtractor, private restExtractor: RestExtractor,
private redirectService: RedirectService, private redirectService: RedirectService,
private authService: AuthService, private authService: AuthService
private i18n: I18n
) {} ) {}
ngOnInit () { ngOnInit () {

View File

@ -10,7 +10,7 @@ import { FollowingListComponent } from './follows/following-list/following-list.
import { JobsComponent } from './jobs/job.component' import { JobsComponent } from './jobs/job.component'
import { JobsListComponent } from './jobs/jobs-list/jobs-list.component' import { JobsListComponent } from './jobs/jobs-list/jobs-list.component'
import { JobService } from './jobs/shared/job.service' import { JobService } from './jobs/shared/job.service'
import { UserCreateComponent, UserListComponent, UsersComponent, UserUpdateComponent } from './users' import { UserCreateComponent, UserListComponent, UsersComponent, UserUpdateComponent, UserPasswordComponent } from './users'
import { ModerationCommentModalComponent, VideoAbuseListComponent, VideoBlacklistListComponent } from './moderation' import { ModerationCommentModalComponent, VideoAbuseListComponent, VideoBlacklistListComponent } from './moderation'
import { ModerationComponent } from '@app/+admin/moderation/moderation.component' import { ModerationComponent } from '@app/+admin/moderation/moderation.component'
import { RedundancyCheckboxComponent } from '@app/+admin/follows/shared/redundancy-checkbox.component' import { RedundancyCheckboxComponent } from '@app/+admin/follows/shared/redundancy-checkbox.component'
@ -36,6 +36,7 @@ import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } f
UsersComponent, UsersComponent,
UserCreateComponent, UserCreateComponent,
UserUpdateComponent, UserUpdateComponent,
UserPasswordComponent,
UserListComponent, UserListComponent,
ModerationComponent, ModerationComponent,

View File

@ -10,6 +10,7 @@
font-weight: $font-semibold; font-weight: $font-semibold;
min-width: 200px; min-width: 200px;
display: inline-block; display: inline-block;
vertical-align: top;
} }
.moderation-expanded-text { .moderation-expanded-text {

View File

@ -1,7 +1,8 @@
<ng-template #modal> <ng-template #modal>
<div class="modal-header"> <div class="modal-header">
<h4 i18n class="modal-title">Moderation comment</h4> <h4 i18n class="modal-title">Moderation comment</h4>
<span class="close" aria-hidden="true" (click)="hideModerationCommentModal()"></span>
<my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@ -14,12 +15,12 @@
</div> </div>
</div> </div>
<div i18n> <div class="form-group" i18n>
This comment can only be seen by you or the other moderators. This comment can only be seen by you or the other moderators.
</div> </div>
<div class="form-group inputs"> <div class="form-group inputs">
<span i18n class="action-button action-button-cancel" (click)="hideModerationCommentModal()">Cancel</span> <span i18n class="action-button action-button-cancel" (click)="hide()">Cancel</span>
<input <input
type="submit" i18n-value value="Update this comment" class="action-button-submit" type="submit" i18n-value value="Update this comment" class="action-button-submit"
@ -29,4 +30,4 @@
</form> </form>
</div> </div>
</ng-template> </ng-template>

View File

@ -45,7 +45,7 @@ export class ModerationCommentModalComponent extends FormReactive implements OnI
}) })
} }
hideModerationCommentModal () { hide () {
this.abuseToComment = undefined this.abuseToComment = undefined
this.openedModal.close() this.openedModal.close()
this.form.reset() this.form.reset()
@ -60,7 +60,7 @@ export class ModerationCommentModalComponent extends FormReactive implements OnI
this.notifier.success(this.i18n('Comment updated.')) this.notifier.success(this.i18n('Comment updated.'))
this.commentUpdated.emit(moderationComment) this.commentUpdated.emit(moderationComment)
this.hideModerationCommentModal() this.hide()
}, },
err => this.notifier.error(err.message) err => this.notifier.error(err.message)

View File

@ -51,11 +51,11 @@
<td class="moderation-expanded" colspan="6"> <td class="moderation-expanded" colspan="6">
<div> <div>
<span i18n class="moderation-expanded-label">Reason:</span> <span i18n class="moderation-expanded-label">Reason:</span>
<span class="moderation-expanded-text">{{ videoAbuse.reason }}</span> <span class="moderation-expanded-text" [innerHTML]="toHtml(videoAbuse.reason)"></span>
</div> </div>
<div *ngIf="videoAbuse.moderationComment"> <div *ngIf="videoAbuse.moderationComment">
<span i18n class="moderation-expanded-label">Moderation comment:</span> <span i18n class="moderation-expanded-label">Moderation comment:</span>
<span class="moderation-expanded-text">{{ videoAbuse.moderationComment }}</span> <span class="moderation-expanded-text" [innerHTML]="toHtml(videoAbuse.moderationComment)"></span>
</div> </div>
</td> </td>
</tr> </tr>

View File

@ -9,6 +9,7 @@ import { DropdownAction } from '../../../shared/buttons/action-dropdown.componen
import { ConfirmService } from '../../../core/index' import { ConfirmService } from '../../../core/index'
import { ModerationCommentModalComponent } from './moderation-comment-modal.component' import { ModerationCommentModalComponent } from './moderation-comment-modal.component'
import { Video } from '../../../shared/video/video.model' import { Video } from '../../../shared/video/video.model'
import { MarkdownService } from '@app/shared/renderer'
@Component({ @Component({
selector: 'my-video-abuse-list', selector: 'my-video-abuse-list',
@ -30,7 +31,8 @@ export class VideoAbuseListComponent extends RestTable implements OnInit {
private notifier: Notifier, private notifier: Notifier,
private videoAbuseService: VideoAbuseService, private videoAbuseService: VideoAbuseService,
private confirmService: ConfirmService, private confirmService: ConfirmService,
private i18n: I18n private i18n: I18n,
private markdownRenderer: MarkdownService
) { ) {
super() super()
@ -108,6 +110,10 @@ export class VideoAbuseListComponent extends RestTable implements OnInit {
} }
toHtml (text: string) {
return this.markdownRenderer.textMarkdownToHTML(text)
}
protected loadData () { protected loadData () {
return this.videoAbuseService.getVideoAbuses(this.pagination, this.sort) return this.videoAbuseService.getVideoAbuses(this.pagination, this.sort)
.subscribe( .subscribe(

View File

@ -7,6 +7,7 @@
<th style="width: 40px"></th> <th style="width: 40px"></th>
<th i18n pSortableColumn="name">Video name <p-sortIcon field="name"></p-sortIcon></th> <th i18n pSortableColumn="name">Video name <p-sortIcon field="name"></p-sortIcon></th>
<th i18n>Sensitive</th> <th i18n>Sensitive</th>
<th i18n>Unfederated</th>
<th i18n pSortableColumn="createdAt">Date <p-sortIcon field="createdAt"></p-sortIcon></th> <th i18n pSortableColumn="createdAt">Date <p-sortIcon field="createdAt"></p-sortIcon></th>
<th style="width: 120px;"></th> <th style="width: 120px;"></th>
</tr> </tr>
@ -26,7 +27,8 @@
</a> </a>
</td> </td>
<td>{{ videoBlacklist.video.nsfw }}</td> <td>{{ booleanToText(videoBlacklist.video.nsfw) }}</td>
<td>{{ booleanToText(videoBlacklist.unfederated) }}</td>
<td>{{ videoBlacklist.createdAt }}</td> <td>{{ videoBlacklist.createdAt }}</td>
<td class="action-cell"> <td class="action-cell">
@ -37,9 +39,9 @@
<ng-template pTemplate="rowexpansion" let-videoBlacklist> <ng-template pTemplate="rowexpansion" let-videoBlacklist>
<tr> <tr>
<td class="moderation-expanded" colspan="5"> <td class="moderation-expanded" colspan="6">
<span i18n class="moderation-expanded-label">Blacklist reason:</span> <span i18n class="moderation-expanded-label">Blacklist reason:</span>
<span class="moderation-expanded-text">{{ videoBlacklist.reason }}</span> <span class="moderation-expanded-text" [innerHTML]="toHtml(videoBlacklist.reason)"></span>
</td> </td>
</tr> </tr>
</ng-template> </ng-template>

View File

@ -7,6 +7,7 @@ import { VideoBlacklist } from '../../../../../../shared'
import { I18n } from '@ngx-translate/i18n-polyfill' import { I18n } from '@ngx-translate/i18n-polyfill'
import { DropdownAction } from '../../../shared/buttons/action-dropdown.component' import { DropdownAction } from '../../../shared/buttons/action-dropdown.component'
import { Video } from '../../../shared/video/video.model' import { Video } from '../../../shared/video/video.model'
import { MarkdownService } from '@app/shared/renderer'
@Component({ @Component({
selector: 'my-video-blacklist-list', selector: 'my-video-blacklist-list',
@ -26,6 +27,7 @@ export class VideoBlacklistListComponent extends RestTable implements OnInit {
private notifier: Notifier, private notifier: Notifier,
private confirmService: ConfirmService, private confirmService: ConfirmService,
private videoBlacklistService: VideoBlacklistService, private videoBlacklistService: VideoBlacklistService,
private markdownRenderer: MarkdownService,
private i18n: I18n private i18n: I18n
) { ) {
super() super()
@ -46,6 +48,16 @@ export class VideoBlacklistListComponent extends RestTable implements OnInit {
return Video.buildClientUrl(videoBlacklist.video.uuid) return Video.buildClientUrl(videoBlacklist.video.uuid)
} }
booleanToText (value: boolean) {
if (value === true) return this.i18n('yes')
return this.i18n('no')
}
toHtml (text: string) {
return this.markdownRenderer.textMarkdownToHTML(text)
}
async removeVideoFromBlacklist (entry: VideoBlacklist) { async removeVideoFromBlacklist (entry: VideoBlacklist) {
const confirmMessage = this.i18n( const confirmMessage = this.i18n(
'Do you really want to remove this video from the blacklist? It will be available again in the videos list.' 'Do you really want to remove this video from the blacklist? It will be available again in the videos list.'

View File

@ -1,2 +1,3 @@
export * from './user-create.component' export * from './user-create.component'
export * from './user-update.component' export * from './user-update.component'
export * from './user-password.component'

View File

@ -81,3 +81,17 @@
<input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid"> <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
</form> </form>
<div *ngIf="!isCreation()" class="danger-zone">
<div class="account-title" i18n>Danger Zone</div>
<div class="form-group reset-password-email">
<label i18n>Send a link to reset the password by email to the user</label>
<button (click)="resetPassword()" i18n>Ask for new password</button>
</div>
<div class="form-group">
<label i18n>Manually set the user password</label>
<my-user-password [userId]="userId"></my-user-password>
</div>
</div>

View File

@ -14,7 +14,7 @@ input:not([type=submit]) {
@include peertube-select-container(340px); @include peertube-select-container(340px);
} }
input[type=submit] { input[type=submit], button {
@include peertube-button; @include peertube-button;
@include orange-button; @include orange-button;
@ -25,3 +25,23 @@ input[type=submit] {
margin-top: 5px; margin-top: 5px;
font-size: 11px; font-size: 11px;
} }
.account-title {
@include in-content-small-title;
margin-top: 55px;
margin-bottom: 30px;
}
.danger-zone {
.reset-password-email {
margin-bottom: 30px;
padding-bottom: 30px;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
button {
display: block;
margin-top: 0;
}
}
}

View File

@ -8,6 +8,7 @@ export abstract class UserEdit extends FormReactive {
videoQuotaDailyOptions: { value: string, label: string }[] = [] videoQuotaDailyOptions: { value: string, label: string }[] = []
roles = Object.keys(USER_ROLE_LABELS).map(key => ({ value: key.toString(), label: USER_ROLE_LABELS[key] })) roles = Object.keys(USER_ROLE_LABELS).map(key => ({ value: key.toString(), label: USER_ROLE_LABELS[key] }))
username: string username: string
userId: number
protected abstract serverService: ServerService protected abstract serverService: ServerService
protected abstract configService: ConfigService protected abstract configService: ConfigService
@ -22,7 +23,9 @@ export abstract class UserEdit extends FormReactive {
} }
computeQuotaWithTranscoding () { computeQuotaWithTranscoding () {
const resolutions = this.serverService.getConfig().transcoding.enabledResolutions const transcodingConfig = this.serverService.getConfig().transcoding
const resolutions = transcodingConfig.enabledResolutions
const higherResolution = VideoResolution.H_1080P const higherResolution = VideoResolution.H_1080P
let multiplier = 0 let multiplier = 0
@ -30,9 +33,15 @@ export abstract class UserEdit extends FormReactive {
multiplier += resolution / higherResolution multiplier += resolution / higherResolution
} }
if (transcodingConfig.hls.enabled) multiplier *= 2
return multiplier * parseInt(this.form.value['videoQuota'], 10) return multiplier * parseInt(this.form.value['videoQuota'], 10)
} }
resetPassword () {
return
}
protected buildQuotaOptions () { protected buildQuotaOptions () {
// These are used by a HTML select, so convert key into strings // These are used by a HTML select, so convert key into strings
this.videoQuotaOptions = this.configService this.videoQuotaOptions = this.configService

View File

@ -0,0 +1,21 @@
<form role="form" (ngSubmit)="formValidated()" [formGroup]="form">
<div class="form-group">
<div class="input-group">
<input id="password" [attr.type]="showPassword ? 'text' : 'password'"
formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }"
>
<div class="input-group-append">
<button class="btn btn-sm btn-outline-secondary" (click)="togglePasswordVisibility()" type="button">
<ng-container *ngIf="!showPassword" i18n>Show</ng-container>
<ng-container *ngIf="!!showPassword" i18n>Hide</ng-container>
</button>
</div>
</div>
<div *ngIf="formErrors.password" class="form-error">
{{ formErrors.password }}
</div>
</div>
<input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
</form>

View File

@ -0,0 +1,22 @@
@import '_variables';
@import '_mixins';
input:not([type=submit]):not([type=checkbox]) {
@include peertube-input-text(340px);
display: block;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right: none;
}
input[type=submit] {
@include peertube-button;
@include orange-button;
margin-top: 10px;
}
.input-group-append {
height: 30px;
}

View File

@ -0,0 +1,64 @@
import { Component, Input, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { UserService } from '@app/shared/users/user.service'
import { Notifier } from '../../../core'
import { User, UserUpdate } from '../../../../../../shared'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
import { FormReactive } from '../../../shared'
@Component({
selector: 'my-user-password',
templateUrl: './user-password.component.html',
styleUrls: [ './user-password.component.scss' ]
})
export class UserPasswordComponent extends FormReactive implements OnInit {
error: string
username: string
showPassword = false
@Input() userId: number
constructor (
protected formValidatorService: FormValidatorService,
private userValidatorsService: UserValidatorsService,
private route: ActivatedRoute,
private router: Router,
private notifier: Notifier,
private userService: UserService,
private i18n: I18n
) {
super()
}
ngOnInit () {
this.buildForm({
password: this.userValidatorsService.USER_PASSWORD
})
}
formValidated () {
this.error = undefined
const userUpdate: UserUpdate = this.form.value
this.userService.updateUser(this.userId, userUpdate).subscribe(
() => {
this.notifier.success(
this.i18n('Password changed for user {{username}}.', { username: this.username })
)
},
err => this.error = err.message
)
}
togglePasswordVisibility () {
this.showPassword = !this.showPassword
}
getFormButtonTitle () {
return this.i18n('Update user password')
}
}

View File

@ -19,6 +19,7 @@ import { UserService } from '@app/shared'
export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy { export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
error: string error: string
userId: number userId: number
userEmail: string
username: string username: string
private paramsSub: Subscription private paramsSub: Subscription
@ -89,9 +90,22 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
return this.i18n('Update user') return this.i18n('Update user')
} }
resetPassword () {
this.userService.askResetPassword(this.userEmail).subscribe(
() => {
this.notifier.success(
this.i18n('An email asking for password reset has been sent to {{username}}.', { username: this.username })
)
},
err => this.error = err.message
)
}
private onUserFetched (userJson: User) { private onUserFetched (userJson: User) {
this.userId = userJson.id this.userId = userJson.id
this.username = userJson.username this.username = userJson.username
this.userEmail = userJson.email
this.form.patchValue({ this.form.patchValue({
email: userJson.email, email: userJson.email,

View File

@ -2,7 +2,7 @@
<div i18n class="form-sub-title">Users list</div> <div i18n class="form-sub-title">Users list</div>
<a class="add-button" routerLink="/admin/users/create"> <a class="add-button" routerLink="/admin/users/create">
<span class="icon icon-add"></span> <my-global-icon iconName="add"></my-global-icon>
<ng-container i18n>Create user</ng-container> <ng-container i18n>Create user</ng-container>
</a> </a>
</div> </div>
@ -65,7 +65,9 @@
<span i18n *ngIf="user.blocked" class="banned-info">(banned)</span> <span i18n *ngIf="user.blocked" class="banned-info">(banned)</span>
</a> </a>
</td> </td>
<td *ngIf="!requiresEmailVerification || user.blocked; else emailWithVerificationStatus">{{ user.email }}</td> <td *ngIf="!requiresEmailVerification || user.blocked; else emailWithVerificationStatus">{{ user.email }}</td>
<ng-template #emailWithVerificationStatus> <ng-template #emailWithVerificationStatus>
<td *ngIf="user.emailVerified === false; else emailVerifiedNotFalse" i18n-title title="User's email must be verified to login"> <td *ngIf="user.emailVerified === false; else emailVerifiedNotFalse" i18n-title title="User's email must be verified to login">
<em>? {{ user.email }}</em> <em>? {{ user.email }}</em>
@ -76,6 +78,7 @@
</td> </td>
</ng-template> </ng-template>
</ng-template> </ng-template>
<td>{{ user.videoQuotaUsed }} / {{ user.videoQuota }}</td> <td>{{ user.videoQuotaUsed }} / {{ user.videoQuota }}</td>
<td>{{ user.roleLabel }}</td> <td>{{ user.roleLabel }}</td>
<td>{{ user.createdAt }}</td> <td>{{ user.createdAt }}</td>

View File

@ -2,7 +2,7 @@
@import '_mixins'; @import '_mixins';
.add-button { .add-button {
@include create-button('../../../../assets/images/global/add.svg'); @include create-button;
} }
tr.banned { tr.banned {
@ -23,4 +23,4 @@ tr.banned {
input { input {
@include peertube-input-text(250px); @include peertube-input-text(250px);
} }
} }

View File

@ -65,10 +65,10 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-size: 14px; font-size: 14px;
color: #585858; color: $grey-foreground-color;
&:hover { &:hover {
color: #303030; color: $grey-foreground-hover-color;
} }
} }
} }

View File

@ -1,7 +1,13 @@
<div class="header"> <div class="header">
<a routerLink="/my-account/settings" fragment="notifications" i18n>Notification preferences</a> <a routerLink="/my-account/settings" fragment="notifications" i18n>
<my-global-icon iconName="cog"></my-global-icon>
Notification preferences
</a>
<button (click)="markAllAsRead()" i18n>Mark all as read</button> <button (click)="markAllAsRead()" i18n>
<my-global-icon iconName="circle-tick"></my-global-icon>
Mark all as read
</button>
</div> </div>
<my-user-notifications #userNotification></my-user-notifications> <my-user-notifications #userNotification></my-user-notifications>

View File

@ -5,16 +5,18 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
font-size: 15px; font-size: 15px;
margin-bottom: 10px; margin-bottom: 20px;
a { a {
@include peertube-button-link; @include peertube-button-link;
@include grey-button; @include grey-button;
@include button-with-icon(18px, 3px, -1px);
} }
button { button {
@include peertube-button; @include peertube-button;
@include grey-button; @include grey-button;
@include button-with-icon(20px, 3px, -1px);
} }
} }

View File

@ -1,7 +1,8 @@
<ng-template #modal let-close="close" let-dismiss="dismiss"> <ng-template #modal let-close="close" let-dismiss="dismiss">
<div class="modal-header"> <div class="modal-header">
<h4 i18n class="modal-title">Accept ownership</h4> <h4 i18n class="modal-title">Accept ownership</h4>
<span class="close" aria-label="Close" role="button" (click)="dismiss()"></span>
<my-global-icon iconName="cross" aria-label="Close" role="button" (click)="dismiss()"></my-global-icon>
</div> </div>
<div class="modal-body" [formGroup]="form"> <div class="modal-body" [formGroup]="form">

View File

@ -40,10 +40,10 @@
<td class="action-cell"> <td class="action-cell">
<ng-container *ngIf="videoChangeOwnership.status === 'WAITING'"> <ng-container *ngIf="videoChangeOwnership.status === 'WAITING'">
<my-button i18n label="Accept" <my-button i18n label="Accept"
icon="icon-tick" icon="tick"
(click)="openAcceptModal(videoChangeOwnership)"></my-button> (click)="openAcceptModal(videoChangeOwnership)"></my-button>
<my-button i18n label="Refuse" <my-button i18n label="Refuse"
icon="icon-cross" icon="cross"
(click)="refuse(videoChangeOwnership)">Refuse</my-button> (click)="refuse(videoChangeOwnership)">Refuse</my-button>
</ng-container> </ng-container>
</td> </td>

View File

@ -1,6 +1,6 @@
<div class="video-channels-header"> <div class="video-channels-header">
<a class="create-button" routerLink="create"> <a class="create-button" routerLink="create">
<span class="icon icon-add"></span> <my-global-icon iconName="add"></my-global-icon>
<ng-container i18n>Create another video channel</ng-container> <ng-container i18n>Create another video channel</ng-container>
</a> </a>
</div> </div>

View File

@ -2,7 +2,7 @@
@import '_mixins'; @import '_mixins';
.create-button { .create-button {
@include create-button('../../../assets/images/global/add.svg'); @include create-button;
} }
/deep/ .action-button { /deep/ .action-button {

View File

@ -35,10 +35,14 @@ export class MyAccountVideoChannelsComponent implements OnInit {
async deleteVideoChannel (videoChannel: VideoChannel) { async deleteVideoChannel (videoChannel: VideoChannel) {
const res = await this.confirmService.confirmWithInput( const res = await this.confirmService.confirmWithInput(
this.i18n( this.i18n(
'Do you really want to delete {{videoChannelName}}? It will delete all videos uploaded in this channel too.', 'Do you really want to delete {{channelDisplayName}}? It will delete all videos uploaded in this channel, ' +
{ videoChannelName: videoChannel.displayName } 'and you will not be able to create another channel with the same name ({{channelName}})!',
{ channelDisplayName: videoChannel.displayName, channelName: videoChannel.name }
),
this.i18n(
'Please type the display name of the video channel ({{displayName}}) to confirm',
{ displayName: videoChannel.displayName }
), ),
this.i18n('Please type the name of the video channel to confirm'),
videoChannel.displayName, videoChannel.displayName,
this.i18n('Delete') this.i18n('Delete')
) )

View File

@ -32,7 +32,7 @@
</span> </span>
<span class="action-button action-button-delete-selection" (click)="deleteSelectedVideos()"> <span class="action-button action-button-delete-selection" (click)="deleteSelectedVideos()">
<span class="icon icon-delete-white"></span> <my-global-icon iconName="delete"></my-global-icon>
<ng-container i18n>Delete</ng-container> <ng-container i18n>Delete</ng-container>
</span> </span>
</div> </div>
@ -45,7 +45,7 @@
<my-button i18n-label label="Change ownership" <my-button i18n-label label="Change ownership"
className="action-button-change-ownership" className="action-button-change-ownership"
icon="icon-im-with-her" icon="im-with-her"
(click)="changeOwnership($event, video)" (click)="changeOwnership($event, video)"
></my-button> ></my-button>
</div> </div>
@ -53,4 +53,4 @@
</div> </div>
</div> </div>
<my-video-change-ownership #videoChangeOwnershipModal></my-video-change-ownership> <my-video-change-ownership #videoChangeOwnershipModal></my-video-change-ownership>

View File

@ -23,14 +23,11 @@
.action-button-delete-selection { .action-button-delete-selection {
@include peertube-button; @include peertube-button;
@include orange-button; @include orange-button;
} @include button-with-icon(21px);
.icon.icon-delete-white { my-global-icon {
@include icon(21px); @include apply-svg-color(#fff);
}
position: relative;
top: -2px;
background-image: url('../../../assets/images/global/delete-white.svg');
} }
} }
} }

View File

@ -1,7 +1,8 @@
<ng-template #modal let-close="close" let-dismiss="dismiss"> <ng-template #modal let-close="close" let-dismiss="dismiss">
<div class="modal-header"> <div class="modal-header">
<h4 i18n class="modal-title">Change ownership</h4> <h4 i18n class="modal-title">Change ownership</h4>
<span class="close" aria-label="Close" role="button" (click)="dismiss()"></span>
<my-global-icon iconName="cross" aria-label="Close" role="button" (click)="dismiss()"></my-global-icon>
</div> </div>
<div class="modal-body" [formGroup]="form"> <div class="modal-body" [formGroup]="form">

View File

@ -3,7 +3,7 @@ import { VideoChannelService } from '@app/shared/video-channel/video-channel.ser
import { VideoChannel } from '@app/shared/video-channel/video-channel.model' import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
import { I18n } from '@ngx-translate/i18n-polyfill' import { I18n } from '@ngx-translate/i18n-polyfill'
import { Subscription } from 'rxjs' import { Subscription } from 'rxjs'
import { MarkdownService } from '@app/videos/shared' import { MarkdownService } from '@app/shared/renderer'
@Component({ @Component({
selector: 'my-video-channel-about', selector: 'my-video-channel-about',

View File

@ -30,14 +30,16 @@
<footer class="row"> <footer class="row">
<a href="https://joinpeertube.org" title="PeerTube website" target="_blank" rel="noopener noreferrer">PeerTube v{{ serverVersion }}{{ serverCommit }}</a>&nbsp;-&nbsp; <a href="https://joinpeertube.org" title="PeerTube website" target="_blank" rel="noopener noreferrer">PeerTube v{{ serverVersion }}{{ serverCommit }}</a>&nbsp;-&nbsp;
<a href="https://github.com/Chocobozzz/PeerTube/blob/develop/LICENSE" title="PeerTube license" target="_blank" rel="noopener noreferrer">CopyLeft 2015-2018</a> <a href="https://github.com/Chocobozzz/PeerTube/blob/develop/LICENSE" title="PeerTube license" target="_blank" rel="noopener noreferrer">CopyLeft 2015-2019</a>
</footer> </footer>
</div> </div>
</div> </div>
</div> </div>
<ngx-loading-bar [includeSpinner]="false"></ngx-loading-bar> <ngx-loading-bar [includeSpinner]="false"></ngx-loading-bar>
<my-confirm></my-confirm> <my-confirm></my-confirm>
<p-toast position="bottom-right"> <p-toast position="bottom-right">
<ng-template let-message pTemplate="message"> <ng-template let-message pTemplate="message">
<div class="notification-block"> <div class="notification-block">

View File

@ -3,12 +3,12 @@ import { catchError, map, mergeMap, share, tap } from 'rxjs/operators'
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http' import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { Router } from '@angular/router' import { Router } from '@angular/router'
import { Notifier } from '@app/core/notification' import { Notifier } from '@app/core/notification/notifier.service'
import { OAuthClientLocal, User as UserServerModel, UserRefreshToken } from '../../../../../shared' import { OAuthClientLocal, User as UserServerModel, UserRefreshToken } from '../../../../../shared'
import { User } from '../../../../../shared/models/users' import { User } from '../../../../../shared/models/users'
import { UserLogin } from '../../../../../shared/models/users/user-login.model' import { UserLogin } from '../../../../../shared/models/users/user-login.model'
import { environment } from '../../../environments/environment' import { environment } from '../../../environments/environment'
import { RestExtractor } from '../../shared/rest' import { RestExtractor } from '../../shared/rest/rest-extractor.service'
import { AuthStatus } from './auth-status.model' import { AuthStatus } from './auth-status.model'
import { AuthUser } from './auth-user.model' import { AuthUser } from './auth-user.model'
import { objectToUrlEncoded } from '@app/shared/misc/utils' import { objectToUrlEncoded } from '@app/shared/misc/utils'

View File

@ -1,2 +1 @@
export * from './confirm.component'
export * from './confirm.service' export * from './confirm.service'

View File

@ -8,7 +8,7 @@ import { LoadingBarHttpClientModule } from '@ngx-loading-bar/http-client'
import { LoadingBarRouterModule } from '@ngx-loading-bar/router' import { LoadingBarRouterModule } from '@ngx-loading-bar/router'
import { AuthService } from './auth' import { AuthService } from './auth'
import { ConfirmComponent, ConfirmService } from './confirm' import { ConfirmService } from './confirm'
import { throwIfAlreadyLoaded } from './module-import-guard' import { throwIfAlreadyLoaded } from './module-import-guard'
import { LoginGuard, RedirectService, UserRightGuard } from './routing' import { LoginGuard, RedirectService, UserRightGuard } from './routing'
import { ServerService } from './server' import { ServerService } from './server'
@ -18,6 +18,7 @@ import { CheatSheetComponent } from './hotkeys'
import { ToastModule } from 'primeng/toast' import { ToastModule } from 'primeng/toast'
import { Notifier } from './notification' import { Notifier } from './notification'
import { MessageService } from 'primeng/api' import { MessageService } from 'primeng/api'
import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service'
@NgModule({ @NgModule({
imports: [ imports: [
@ -37,7 +38,6 @@ import { MessageService } from 'primeng/api'
], ],
declarations: [ declarations: [
ConfirmComponent,
CheatSheetComponent CheatSheetComponent
], ],
@ -47,7 +47,6 @@ import { MessageService } from 'primeng/api'
ToastModule, ToastModule,
ConfirmComponent,
CheatSheetComponent CheatSheetComponent
], ],
@ -60,7 +59,8 @@ import { MessageService } from 'primeng/api'
UserRightGuard, UserRightGuard,
RedirectService, RedirectService,
Notifier, Notifier,
MessageService MessageService,
UserNotificationSocket
] ]
}) })
export class CoreModule { export class CoreModule {

View File

@ -1 +1,2 @@
export * from './notifier.service' export * from './notifier.service'
export * from './user-notification-socket.service'

View File

@ -0,0 +1,41 @@
import { Injectable } from '@angular/core'
import { environment } from '../../../environments/environment'
import { UserNotification as UserNotificationServer } from '../../../../../shared'
import { Subject } from 'rxjs'
import * as io from 'socket.io-client'
import { AuthService } from '../auth'
export type NotificationEvent = 'new' | 'read' | 'read-all'
@Injectable()
export class UserNotificationSocket {
private notificationSubject = new Subject<{ type: NotificationEvent, notification?: UserNotificationServer }>()
private socket: SocketIOClient.Socket
constructor (
private auth: AuthService
) {}
dispatch (type: NotificationEvent, notification?: UserNotificationServer) {
this.notificationSubject.next({ type, notification })
}
getMyNotificationsSocket () {
const socket = this.getSocket()
socket.on('new-notification', (n: UserNotificationServer) => this.dispatch('new', n))
return this.notificationSubject.asObservable()
}
private getSocket () {
if (this.socket) return this.socket
this.socket = io(environment.apiUrl + '/user-notifications', {
query: { accessToken: this.auth.getAccessToken() }
})
return this.socket
}
}

View File

@ -51,7 +51,10 @@ export class ServerService {
requiresEmailVerification: false requiresEmailVerification: false
}, },
transcoding: { transcoding: {
enabledResolutions: [] enabledResolutions: [],
hls: {
enabled: false
}
}, },
avatar: { avatar: {
file: { file: {
@ -87,6 +90,11 @@ export class ServerService {
enabled: false enabled: false
} }
} }
},
trending: {
videos: {
intervalDays: 0
}
} }
} }
private videoCategories: Array<VideoConstant<number>> = [] private videoCategories: Array<VideoConstant<number>> = []

View File

@ -5,6 +5,6 @@
<span (click)="doSearch()" class="icon icon-search"></span> <span (click)="doSearch()" class="icon icon-search"></span>
<a class="upload-button" routerLink="/videos/upload"> <a class="upload-button" routerLink="/videos/upload">
<span class="icon icon-upload"></span> <my-global-icon iconName="upload"></my-global-icon>
<span i18n class="upload-button-label">Upload</span> <span i18n class="upload-button-label">Upload</span>
</a> </a>

View File

@ -6,6 +6,7 @@
padding-left: 10px; padding-left: 10px;
margin-right: 15px; margin-right: 15px;
padding-right: 40px; // For the search icon padding-right: 40px; // For the search icon
font-size: 14px;
&::placeholder { &::placeholder {
color: var(--inputPlaceholderColor); color: var(--inputPlaceholderColor);
@ -40,6 +41,7 @@
.upload-button { .upload-button {
@include peertube-button-link; @include peertube-button-link;
@include orange-button; @include orange-button;
@include button-with-icon(22px, 3px, -1px);
margin-right: 25px; margin-right: 25px;
@ -47,15 +49,6 @@
margin-right: 0; margin-right: 0;
} }
.icon.icon-upload {
@include icon(22px);
background-image: url('../../assets/images/header/upload-white.svg');
height: 24px;
vertical-align: middle;
margin-right: 6px;
}
@media screen and (max-width: 600px) { @media screen and (max-width: 600px) {
margin-right: 10px; margin-right: 10px;
padding: 0 10px; padding: 0 10px;

View File

@ -55,7 +55,8 @@
<ng-template #forgotPasswordModal> <ng-template #forgotPasswordModal>
<div class="modal-header"> <div class="modal-header">
<h4 i18n class="modal-title">Forgot your password</h4> <h4 i18n class="modal-title">Forgot your password</h4>
<span class="close" aria-hidden="true" (click)="hideForgotPasswordModal()"></span>
<my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hideForgotPasswordModal()"></my-global-icon>
</div> </div>
<div class="modal-body"> <div class="modal-body">

View File

@ -17,7 +17,7 @@
></a> ></a>
</div> </div>
<my-user-notifications [ignoreLoadingBar]="true" [infiniteScroll]="false"></my-user-notifications> <my-user-notifications [ignoreLoadingBar]="true" [infiniteScroll]="false" itemsPerPage="10"></my-user-notifications>
<a class="all-notifications" routerLink="/my-account/notifications" i18n>See all your notifications</a> <a class="all-notifications" routerLink="/my-account/notifications" i18n>See all your notifications</a>
</ng-template> </ng-template>

View File

@ -3,7 +3,7 @@
/deep/ { /deep/ {
.popover-notifications.popover { .popover-notifications.popover {
max-width: 400px; max-width: none;
.popover-body { .popover-body {
padding: 0; padding: 0;
@ -11,6 +11,7 @@
font-family: $main-fonts; font-family: $main-fonts;
overflow-y: auto; overflow-y: auto;
max-height: 500px; max-height: 500px;
width: 400px;
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.30); box-shadow: 0 6px 14px rgba(0, 0, 0, 0.30);
.notifications-header { .notifications-header {
@ -40,7 +41,7 @@
justify-content: center; justify-content: center;
font-weight: $font-semibold; font-weight: $font-semibold;
color: var(--mainForegroundColor); color: var(--mainForegroundColor);
height: 30px; padding: 7px 0;
} }
} }
} }
@ -71,7 +72,7 @@
justify-content: center; justify-content: center;
background-color: var(--mainColor); background-color: var(--mainColor);
color: var(--mainBackgroundColor); color: var(#fff);
font-size: 10px; font-size: 10px;
font-weight: $font-semibold; font-weight: $font-semibold;
@ -80,3 +81,11 @@
height: 15px; height: 15px;
} }
} }
@media screen and (max-width: $mobile-view) {
/deep/ {
.popover-notifications.popover .popover-body {
width: 400px;
}
}
}

View File

@ -2,7 +2,7 @@ import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'
import { User } from '../shared/users/user.model' import { User } from '../shared/users/user.model'
import { UserNotificationService } from '@app/shared/users/user-notification.service' import { UserNotificationService } from '@app/shared/users/user-notification.service'
import { Subscription } from 'rxjs' import { Subscription } from 'rxjs'
import { Notifier } from '@app/core' import { Notifier, UserNotificationSocket } from '@app/core'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { NavigationEnd, Router } from '@angular/router' import { NavigationEnd, Router } from '@angular/router'
import { filter } from 'rxjs/operators' import { filter } from 'rxjs/operators'
@ -23,6 +23,7 @@ export class AvatarNotificationComponent implements OnInit, OnDestroy {
constructor ( constructor (
private userNotificationService: UserNotificationService, private userNotificationService: UserNotificationService,
private userNotificationSocket: UserNotificationSocket,
private notifier: Notifier, private notifier: Notifier,
private router: Router private router: Router
) {} ) {}
@ -53,7 +54,7 @@ export class AvatarNotificationComponent implements OnInit, OnDestroy {
} }
private subscribeToNotifications () { private subscribeToNotifications () {
this.notificationSub = this.userNotificationService.getMyNotificationsSocket() this.notificationSub = this.userNotificationSocket.getMyNotificationsSocket()
.subscribe(data => { .subscribe(data => {
if (data.type === 'new') return this.unreadNotifications++ if (data.type === 'new') return this.unreadNotifications++
if (data.type === 'read') return this.unreadNotifications-- if (data.type === 'read') return this.unreadNotifications--

View File

@ -1,7 +1,7 @@
<ng-template #modal let-hide="close"> <ng-template #modal let-hide="close">
<div class="modal-header"> <div class="modal-header">
<h4 i18n class="modal-title">Change the language</h4> <h4 i18n class="modal-title">Change the language</h4>
<span class="close" aria-label="Close" role="button" (click)="hide()"></span> <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
</div> </div>

View File

@ -16,7 +16,7 @@ menu {
height: 100%; height: 100%;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: auto;
color: var(--menuForegroundColor); color: var(--menuForegroundColor);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -243,7 +243,7 @@ menu {
} }
} }
@media screen and (max-width: 400px) { @media screen and (max-width: $mobile-view) {
.menu-wrapper { .menu-wrapper {
width: 100% !important; width: 100% !important;
} }

View File

@ -48,7 +48,7 @@
</div> </div>
<div *ngIf="isVideo(result)" class="entry video"> <div *ngIf="isVideo(result)" class="entry video">
<my-video-thumbnail [video]="result"></my-video-thumbnail> <my-video-thumbnail [video]="result" [nsfw]="isVideoBlur(result)"></my-video-thumbnail>
<div class="video-info"> <div class="video-info">
<a tabindex="-1" class="video-info-name" [routerLink]="['/videos/watch', result.uuid]" [attr.title]="result.name">{{ result.name }}</a> <a tabindex="-1" class="video-info-name" [routerLink]="['/videos/watch', result.uuid]" [attr.title]="result.name">{{ result.name }}</a>

View File

@ -87,10 +87,10 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-size: 14px; font-size: 14px;
color: #585858; color: $grey-foreground-color;
&:hover { &:hover {
color: #303030; color: $grey-foreground-hover-color;
} }
} }
} }

View File

@ -1,6 +1,6 @@
import { Component, OnDestroy, OnInit } from '@angular/core' import { Component, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, Notifier } from '@app/core' import { AuthService, Notifier, ServerService } from '@app/core'
import { forkJoin, Subscription } from 'rxjs' import { forkJoin, Subscription } from 'rxjs'
import { SearchService } from '@app/search/search.service' import { SearchService } from '@app/search/search.service'
import { ComponentPagination } from '@app/shared/rest/component-pagination.model' import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
@ -41,7 +41,8 @@ export class SearchComponent implements OnInit, OnDestroy {
private metaService: MetaService, private metaService: MetaService,
private notifier: Notifier, private notifier: Notifier,
private searchService: SearchService, private searchService: SearchService,
private authService: AuthService private authService: AuthService,
private serverService: ServerService
) { } ) { }
ngOnInit () { ngOnInit () {
@ -75,6 +76,10 @@ export class SearchComponent implements OnInit, OnDestroy {
if (this.subActivatedRoute) this.subActivatedRoute.unsubscribe() if (this.subActivatedRoute) this.subActivatedRoute.unsubscribe()
} }
isVideoBlur (video: Video) {
return video.isVideoNSFWForUser(this.authService.getUser(), this.serverService.getConfig())
}
isVideoChannel (d: VideoChannel | Video): d is VideoChannel { isVideoChannel (d: VideoChannel | Video): d is VideoChannel {
return d instanceof VideoChannel return d instanceof VideoChannel
} }

View File

@ -16,7 +16,7 @@ export abstract class Actor implements ActorServer {
avatarUrl: string avatarUrl: string
static GET_ACTOR_AVATAR_URL (actor: { avatar: Avatar }) { static GET_ACTOR_AVATAR_URL (actor: { avatar?: { path: string } }) {
const absoluteAPIUrl = getAbsoluteAPIUrl() const absoluteAPIUrl = getAbsoluteAPIUrl()
if (actor && actor.avatar) return absoluteAPIUrl + actor.avatar.path if (actor && actor.avatar) return absoluteAPIUrl + actor.avatar.path

View File

@ -3,7 +3,7 @@
class="action-button" [ngClass]="{ small: buttonSize === 'small', grey: theme === 'grey', orange: theme === 'orange' }" class="action-button" [ngClass]="{ small: buttonSize === 'small', grey: theme === 'grey', orange: theme === 'orange' }"
ngbDropdownToggle role="button" ngbDropdownToggle role="button"
> >
<span *ngIf="!label" class="icon icon-action"></span> <my-global-icon *ngIf="!label" class="more-icon" iconName="more"></my-global-icon>
<span *ngIf="label" class="dropdown-toggle">{{ label }}</span> <span *ngIf="label" class="dropdown-toggle">{{ label }}</span>
</div> </div>

View File

@ -24,14 +24,11 @@
} }
&:hover, &:active, &:focus { &:hover, &:active, &:focus {
background-color: $grey-color; background-color: $grey-background-color;
} }
.icon-action { .more-icon {
@include icon(21px); width: 21px;
background-image: url('../../../assets/images/video/more.svg');
top: -1px;
} }
&.small { &.small {

View File

@ -1,4 +1,4 @@
<span class="action-button" [ngClass]="className" [title]="getTitle()"> <span class="action-button" [ngClass]="className" [title]="getTitle()">
<span class="icon" [ngClass]="icon"></span> <my-global-icon [iconName]="icon"></my-global-icon>
<span class="button-label">{{ label }}</span> <span class="button-label">{{ label }}</span>
</span> </span>

View File

@ -3,41 +3,18 @@
.action-button { .action-button {
@include peertube-button-link; @include peertube-button-link;
@include button-with-icon(21px, 0, -2px);
font-size: 15px;
font-weight: $font-semibold; font-weight: $font-semibold;
color: #585858; color: $grey-foreground-color;
background-color: #E5E5E5; background-color: $grey-background-color;
&:hover { &:hover {
background-color: #EFEFEF; background-color: $grey-background-hover-color;
} }
.icon { my-global-icon {
@include icon(21px); @include apply-svg-color($grey-foreground-color);
position: relative;
top: -2px;
&.icon-edit {
background-image: url('../../../assets/images/global/edit-grey.svg');
}
&.icon-delete-grey {
background-image: url('../../../assets/images/global/delete-grey.svg');
}
&.icon-im-with-her {
background-image: url('../../../assets/images/global/im-with-her.svg');
}
&.icon-tick {
background-image: url('../../../assets/images/global/tick.svg');
}
&.icon-cross {
background-image: url('../../../assets/images/global/cross.svg');
}
} }
} }

View File

@ -1,4 +1,5 @@
import { Component, Input } from '@angular/core' import { Component, Input } from '@angular/core'
import { GlobalIconName } from '@app/shared/icons/global-icon.component'
@Component({ @Component({
selector: 'my-button', selector: 'my-button',
@ -9,7 +10,7 @@ import { Component, Input } from '@angular/core'
export class ButtonComponent { export class ButtonComponent {
@Input() label = '' @Input() label = ''
@Input() className: string = undefined @Input() className: string = undefined
@Input() icon: string = undefined @Input() icon: GlobalIconName = undefined
@Input() title: string = undefined @Input() title: string = undefined
getTitle () { getTitle () {

View File

@ -1,5 +1,5 @@
<span class="action-button action-button-delete" [title]="getTitle()" role="button"> <span class="action-button action-button-delete" [title]="getTitle()" role="button">
<span class="icon icon-delete-grey"></span> <my-global-icon iconName="delete"></my-global-icon>
<span class="button-label" *ngIf="label">{{ label }}</span> <span class="button-label" *ngIf="label">{{ label }}</span>
<span class="button-label" i18n *ngIf="!label">Delete</span> <span class="button-label" i18n *ngIf="!label">Delete</span>

View File

@ -1,5 +1,5 @@
<a class="action-button action-button-edit" [routerLink]="routerLink" i18n-title title="Edit"> <a class="action-button action-button-edit" [routerLink]="routerLink" i18n-title title="Edit">
<span class="icon icon-edit"></span> <my-global-icon iconName="edit"></my-global-icon>
<span class="button-label" *ngIf="label">{{ label }}</span> <span class="button-label" *ngIf="label">{{ label }}</span>
<span i18n class="button-label" *ngIf="!label">Edit</span> <span i18n class="button-label" *ngIf="!label">Edit</span>

View File

@ -2,7 +2,8 @@
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title">{{ title }}</h4> <h4 class="modal-title">{{ title }}</h4>
<span class="close" aria-label="Close" role="button" (click)="dismiss()"></span>
<my-global-icon iconName="cross" aria-label="Close" role="button" (click)="dismiss()"></my-global-icon>
</div> </div>
<div class="modal-body" > <div class="modal-body" >

View File

@ -1,5 +1,5 @@
import { Component, ElementRef, HostListener, OnInit, ViewChild } from '@angular/core' import { Component, ElementRef, HostListener, OnInit, ViewChild } from '@angular/core'
import { ConfirmService } from './confirm.service' import { ConfirmService } from '@app/core/confirm/confirm.service'
import { I18n } from '@ngx-translate/i18n-polyfill' import { I18n } from '@ngx-translate/i18n-polyfill'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'

View File

@ -10,20 +10,20 @@ export class VideoAbuseValidatorsService {
constructor (private i18n: I18n) { constructor (private i18n: I18n) {
this.VIDEO_ABUSE_REASON = { this.VIDEO_ABUSE_REASON = {
VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(300) ], VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
MESSAGES: { MESSAGES: {
'required': this.i18n('Report reason is required.'), 'required': this.i18n('Report reason is required.'),
'minlength': this.i18n('Report reason must be at least 2 characters long.'), 'minlength': this.i18n('Report reason must be at least 2 characters long.'),
'maxlength': this.i18n('Report reason cannot be more than 300 characters long.') 'maxlength': this.i18n('Report reason cannot be more than 3000 characters long.')
} }
} }
this.VIDEO_ABUSE_MODERATION_COMMENT = { this.VIDEO_ABUSE_MODERATION_COMMENT = {
VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(300) ], VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
MESSAGES: { MESSAGES: {
'required': this.i18n('Moderation comment is required.'), 'required': this.i18n('Moderation comment is required.'),
'minlength': this.i18n('Moderation comment must be at least 2 characters long.'), 'minlength': this.i18n('Moderation comment must be at least 2 characters long.'),
'maxlength': this.i18n('Moderation comment cannot be more than 300 characters long.') 'maxlength': this.i18n('Moderation comment cannot be more than 3000 characters long.')
} }
} }
} }

View File

@ -1,10 +1,10 @@
import { debounceTime, distinctUntilChanged } from 'rxjs/operators' import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
import { Component, forwardRef, Input, OnInit } from '@angular/core' import { Component, forwardRef, Input, OnInit } from '@angular/core'
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
import { MarkdownService } from '@app/videos/shared'
import { Subject } from 'rxjs' import { Subject } from 'rxjs'
import truncate from 'lodash-es/truncate' import truncate from 'lodash-es/truncate'
import { ScreenService } from '@app/shared/misc/screen.service' import { ScreenService } from '@app/shared/misc/screen.service'
import { MarkdownService } from '@app/shared/renderer'
@Component({ @Component({
selector: 'my-markdown-textarea', selector: 'my-markdown-textarea',

View File

@ -53,6 +53,17 @@ export class ReactiveFileComponent implements OnInit, ControlValueAccessor {
return return
} }
const extension = '.' + file.name.split('.').pop()
if (this.extensions.includes(extension) === false) {
const message = this.i18n(
'PeerTube cannot handle this kind of file. Accepted extensions are {{extensions}}.',
{ extensions: this.allowedExtensionsMessage }
)
this.notifier.error(message)
return
}
this.file = file this.file = file
this.propagateChange(this.file) this.propagateChange(this.file)

View File

@ -0,0 +1,4 @@
/deep/ svg {
width: inherit;
height: inherit;
}

View File

@ -0,0 +1,48 @@
import { Component, ElementRef, Input, OnInit } from '@angular/core'
const icons = {
'add': require('../../../assets/images/global/add.html'),
'syndication': require('../../../assets/images/global/syndication.html'),
'help': require('../../../assets/images/global/help.html'),
'sparkle': require('../../../assets/images/global/sparkle.html'),
'alert': require('../../../assets/images/global/alert.html'),
'cloud-error': require('../../../assets/images/global/cloud-error.html'),
'user-add': require('../../../assets/images/global/user-add.html'),
'no': require('../../../assets/images/global/no.html'),
'cloud-download': require('../../../assets/images/global/cloud-download.html'),
'undo': require('../../../assets/images/global/undo.html'),
'circle-tick': require('../../../assets/images/global/circle-tick.html'),
'cog': require('../../../assets/images/global/cog.html'),
'download': require('../../../assets/images/global/download.html'),
'edit': require('../../../assets/images/global/edit.html'),
'im-with-her': require('../../../assets/images/global/im-with-her.html'),
'delete': require('../../../assets/images/global/delete.html'),
'cross': require('../../../assets/images/global/cross.html'),
'validate': require('../../../assets/images/global/validate.html'),
'tick': require('../../../assets/images/global/tick.html'),
'dislike': require('../../../assets/images/video/dislike.html'),
'heart': require('../../../assets/images/video/heart.html'),
'like': require('../../../assets/images/video/like.html'),
'more': require('../../../assets/images/video/more.html'),
'share': require('../../../assets/images/video/share.html'),
'upload': require('../../../assets/images/video/upload.html')
}
export type GlobalIconName = keyof typeof icons
@Component({
selector: 'my-global-icon',
template: '',
styleUrls: [ './global-icon.component.scss' ]
})
export class GlobalIconComponent implements OnInit {
@Input() iconName: GlobalIconName
constructor (private el: ElementRef) {}
ngOnInit () {
const nativeElement = this.el.nativeElement
nativeElement.innerHTML = icons[this.iconName]
}
}

View File

@ -25,4 +25,6 @@
[autoClose]="true" [autoClose]="true"
(onHidden)="onPopoverHidden()" (onHidden)="onPopoverHidden()"
(onShown)="onPopoverShown()" (onShown)="onPopoverShown()"
></span> >
<my-global-icon iconName="help"></my-global-icon>
</span>

View File

@ -2,13 +2,17 @@
@import '_mixins'; @import '_mixins';
.help-tooltip-button { .help-tooltip-button {
@include icon(17px); cursor: pointer;
position: relative;
top: -2px;
background-image: url('../../../assets/images/global/help.svg');
border: none; border: none;
margin: 5px;
my-global-icon {
width: 17px;
position: relative;
top: -2px;
margin: 5px;
@include apply-svg-color(var(--mainForegroundColor))
}
} }
/deep/ { /deep/ {
@ -16,16 +20,21 @@
max-width: 300px; max-width: 300px;
.popover-body { .popover-body {
font-family: $main-fonts;
text-align: left; text-align: left;
padding: 10px; padding: 10px;
font-size: 13px; font-size: 13px;
font-family: $main-fonts; background-color: var(--mainBackgroundColor);
background-color: #fff; color: var(--mainForegroundColor);
color: #000;
box-shadow: 0 0 6px rgba(0, 0, 0, 0.5); box-shadow: 0 0 6px rgba(0, 0, 0, 0.5);
p {
margin-bottom: 0;
}
ul { ul {
padding-left: 20px; padding-left: 20px;
margin-bottom: 0;
} }
} }
} }

View File

@ -1,6 +1,6 @@
import { Component, Input, OnChanges, OnInit } from '@angular/core' import { Component, Input, OnChanges, OnInit } from '@angular/core'
import { MarkdownService } from '@app/videos/shared'
import { I18n } from '@ngx-translate/i18n-polyfill' import { I18n } from '@ngx-translate/i18n-polyfill'
import { MarkdownService } from '@app/shared/renderer'
@Component({ @Component({
selector: 'my-help', selector: 'my-help',

View File

@ -102,12 +102,18 @@ function objectToFormData (obj: any, form?: FormData, namespace?: string) {
return fd return fd
} }
function lineFeedToHtml (obj: any, keyToNormalize: string) { function objectLineFeedToHtml (obj: any, keyToNormalize: string) {
return immutableAssign(obj, { return immutableAssign(obj, {
[keyToNormalize]: obj[keyToNormalize].replace(/\r?\n|\r/g, '<br />') [keyToNormalize]: lineFeedToHtml(obj[keyToNormalize])
}) })
} }
function lineFeedToHtml (text: string) {
if (!text) return text
return text.replace(/\r?\n|\r/g, '<br />')
}
function removeElementFromArray <T> (arr: T[], elem: T) { function removeElementFromArray <T> (arr: T[], elem: T) {
const index = arr.indexOf(elem) const index = arr.indexOf(elem)
if (index !== -1) arr.splice(index, 1) if (index !== -1) arr.splice(index, 1)
@ -131,6 +137,7 @@ function scrollToTop () {
export { export {
sortBy, sortBy,
durationToString, durationToString,
lineFeedToHtml,
objectToUrlEncoded, objectToUrlEncoded,
getParameterByName, getParameterByName,
populateAsyncUserVideoChannels, populateAsyncUserVideoChannels,
@ -138,7 +145,7 @@ export {
dateToHuman, dateToHuman,
immutableAssign, immutableAssign,
objectToFormData, objectToFormData,
lineFeedToHtml, objectLineFeedToHtml,
removeElementFromArray, removeElementFromArray,
scrollToTop scrollToTop
} }

View File

@ -1,7 +1,8 @@
<ng-template #modal> <ng-template #modal>
<div class="modal-header"> <div class="modal-header">
<h4 i18n class="modal-title">Ban</h4> <h4 i18n class="modal-title">Ban</h4>
<span class="close" aria-hidden="true" (click)="hideBanUserModal()"></span>
<my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@ -19,7 +20,7 @@
</div> </div>
<div class="form-group inputs"> <div class="form-group inputs">
<span i18n class="action-button action-button-cancel" (click)="hideBanUserModal()">Cancel</span> <span i18n class="action-button action-button-cancel" (click)="hide()">Cancel</span>
<input <input
type="submit" i18n-value value="Ban this user" class="action-button-submit" type="submit" i18n-value value="Ban this user" class="action-button-submit"
@ -29,4 +30,4 @@
</form> </form>
</div> </div>
</ng-template> </ng-template>

View File

@ -42,7 +42,7 @@ export class UserBanModalComponent extends FormReactive implements OnInit {
this.openedModal = this.modalService.open(this.modal) this.openedModal = this.modalService.open(this.modal)
} }
hideBanUserModal () { hide () {
this.usersToBan = undefined this.usersToBan = undefined
this.openedModal.close() this.openedModal.close()
} }
@ -60,7 +60,7 @@ export class UserBanModalComponent extends FormReactive implements OnInit {
this.notifier.success(message) this.notifier.success(message)
this.userBanned.emit(this.usersToBan) this.userBanned.emit(this.usersToBan)
this.hideBanUserModal() this.hide()
}, },
err => this.notifier.error(err.message) err => this.notifier.error(err.message)

View File

@ -0,0 +1,35 @@
import { Injectable } from '@angular/core'
import { LinkifierService } from '@app/shared/renderer/linkifier.service'
import * as sanitizeHtml from 'sanitize-html'
@Injectable()
export class HtmlRendererService {
constructor (private linkifier: LinkifierService) {
}
toSafeHtml (text: string) {
// Convert possible markdown to html
const html = this.linkifier.linkify(text)
return sanitizeHtml(html, {
allowedTags: [ 'a', 'p', 'span', 'br' ],
allowedSchemes: [ 'http', 'https' ],
allowedAttributes: {
'a': [ 'href', 'class', 'target' ]
},
transformTags: {
a: (tagName, attribs) => {
return {
tagName,
attribs: Object.assign(attribs, {
target: '_blank',
rel: 'noopener noreferrer'
})
}
}
}
})
}
}

View File

@ -0,0 +1,3 @@
export * from './html-renderer.service'
export * from './linkifier.service'
export * from './markdown.service'

View File

@ -6,7 +6,6 @@ import { RouterModule } from '@angular/router'
import { MarkdownTextareaComponent } from '@app/shared/forms/markdown-textarea.component' import { MarkdownTextareaComponent } from '@app/shared/forms/markdown-textarea.component'
import { HelpComponent } from '@app/shared/misc/help.component' import { HelpComponent } from '@app/shared/misc/help.component'
import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive' import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive'
import { MarkdownService } from '@app/videos/shared'
import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes' import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes'
import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared' import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared'
@ -34,10 +33,10 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
import { import {
CustomConfigValidatorsService, CustomConfigValidatorsService,
InstanceValidatorsService,
LoginValidatorsService, LoginValidatorsService,
ReactiveFileComponent, ReactiveFileComponent,
ResetPasswordValidatorsService, ResetPasswordValidatorsService,
InstanceValidatorsService,
TextareaAutoResizeDirective, TextareaAutoResizeDirective,
UserValidatorsService, UserValidatorsService,
VideoAbuseValidatorsService, VideoAbuseValidatorsService,
@ -67,6 +66,9 @@ import { UserHistoryService } from '@app/shared/users/user-history.service'
import { UserNotificationService } from '@app/shared/users/user-notification.service' import { UserNotificationService } from '@app/shared/users/user-notification.service'
import { UserNotificationsComponent } from '@app/shared/users/user-notifications.component' import { UserNotificationsComponent } from '@app/shared/users/user-notifications.component'
import { InstanceService } from '@app/shared/instance/instance.service' import { InstanceService } from '@app/shared/instance/instance.service'
import { HtmlRendererService, LinkifierService, MarkdownService } from '@app/shared/renderer'
import { ConfirmComponent } from '@app/shared/confirm/confirm.component'
import { GlobalIconComponent } from '@app/shared/icons/global-icon.component'
@NgModule({ @NgModule({
imports: [ imports: [
@ -110,7 +112,9 @@ import { InstanceService } from '@app/shared/instance/instance.service'
UserBanModalComponent, UserBanModalComponent,
UserModerationDropdownComponent, UserModerationDropdownComponent,
TopMenuDropdownComponent, TopMenuDropdownComponent,
UserNotificationsComponent UserNotificationsComponent,
ConfirmComponent,
GlobalIconComponent
], ],
exports: [ exports: [
@ -151,6 +155,8 @@ import { InstanceService } from '@app/shared/instance/instance.service'
UserModerationDropdownComponent, UserModerationDropdownComponent,
TopMenuDropdownComponent, TopMenuDropdownComponent,
UserNotificationsComponent, UserNotificationsComponent,
ConfirmComponent,
GlobalIconComponent,
NumberFormatterPipe, NumberFormatterPipe,
ObjectLengthPipe, ObjectLengthPipe,
@ -167,7 +173,6 @@ import { InstanceService } from '@app/shared/instance/instance.service'
UserService, UserService,
VideoService, VideoService,
AccountService, AccountService,
MarkdownService,
VideoChannelService, VideoChannelService,
VideoCaptionService, VideoCaptionService,
VideoImportService, VideoImportService,
@ -192,6 +197,10 @@ import { InstanceService } from '@app/shared/instance/instance.service'
UserHistoryService, UserHistoryService,
InstanceService, InstanceService,
MarkdownService,
LinkifierService,
HtmlRendererService,
I18nPrimengCalendarService, I18nPrimengCalendarService,
ScreenService, ScreenService,

View File

@ -1,4 +1,5 @@
import { UserNotification as UserNotificationServer, UserNotificationType, VideoInfo } from '../../../../../shared' import { UserNotification as UserNotificationServer, UserNotificationType, VideoInfo, ActorInfo } from '../../../../../shared'
import { Actor } from '@app/shared/actor/actor.model'
export class UserNotification implements UserNotificationServer { export class UserNotification implements UserNotificationServer {
id: number id: number
@ -6,10 +7,7 @@ export class UserNotification implements UserNotificationServer {
read: boolean read: boolean
video?: VideoInfo & { video?: VideoInfo & {
channel: { channel: ActorInfo & { avatarUrl?: string }
id: number
displayName: string
}
} }
videoImport?: { videoImport?: {
@ -23,10 +21,7 @@ export class UserNotification implements UserNotificationServer {
comment?: { comment?: {
id: number id: number
threadId: number threadId: number
account: { account: ActorInfo & { avatarUrl?: string }
id: number
displayName: string
}
video: VideoInfo video: VideoInfo
} }
@ -40,18 +35,11 @@ export class UserNotification implements UserNotificationServer {
video: VideoInfo video: VideoInfo
} }
account?: { account?: ActorInfo & { avatarUrl?: string }
id: number
displayName: string
name: string
}
actorFollow?: { actorFollow?: {
id: number id: number
follower: { follower: ActorInfo & { avatarUrl?: string }
name: string
displayName: string
}
following: { following: {
type: 'account' | 'channel' type: 'account' | 'channel'
name: string name: string
@ -76,12 +64,22 @@ export class UserNotification implements UserNotificationServer {
this.read = hash.read this.read = hash.read
this.video = hash.video this.video = hash.video
if (this.video) this.setAvatarUrl(this.video.channel)
this.videoImport = hash.videoImport this.videoImport = hash.videoImport
this.comment = hash.comment this.comment = hash.comment
if (this.comment) this.setAvatarUrl(this.comment.account)
this.videoAbuse = hash.videoAbuse this.videoAbuse = hash.videoAbuse
this.videoBlacklist = hash.videoBlacklist this.videoBlacklist = hash.videoBlacklist
this.account = hash.account this.account = hash.account
if (this.account) this.setAvatarUrl(this.account)
this.actorFollow = hash.actorFollow this.actorFollow = hash.actorFollow
if (this.actorFollow) this.setAvatarUrl(this.actorFollow.follower)
this.createdAt = hash.createdAt this.createdAt = hash.createdAt
this.updatedAt = hash.updatedAt this.updatedAt = hash.updatedAt
@ -97,6 +95,7 @@ export class UserNotification implements UserNotificationServer {
case UserNotificationType.NEW_COMMENT_ON_MY_VIDEO: case UserNotificationType.NEW_COMMENT_ON_MY_VIDEO:
case UserNotificationType.COMMENT_MENTION: case UserNotificationType.COMMENT_MENTION:
this.accountUrl = this.buildAccountUrl(this.comment.account)
this.commentUrl = [ this.buildVideoUrl(this.comment.video), { threadId: this.comment.threadId } ] this.commentUrl = [ this.buildVideoUrl(this.comment.video), { threadId: this.comment.threadId } ]
break break
@ -138,8 +137,8 @@ export class UserNotification implements UserNotificationServer {
return '/videos/watch/' + video.uuid return '/videos/watch/' + video.uuid
} }
private buildAccountUrl (account: { name: string }) { private buildAccountUrl (account: { name: string, host: string }) {
return '/accounts/' + account.name return '/accounts/' + Actor.CREATE_BY_STRING(account.name, account.host)
} }
private buildVideoImportUrl () { private buildVideoImportUrl () {
@ -150,4 +149,7 @@ export class UserNotification implements UserNotificationServer {
return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName
} }
private setAvatarUrl (actor: { avatarUrl?: string, avatar?: { path: string } }) {
actor.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(actor)
}
} }

View File

@ -1,30 +1,26 @@
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { HttpClient, HttpParams } from '@angular/common/http' import { HttpClient, HttpParams } from '@angular/common/http'
import { RestExtractor, RestService } from '@app/shared/rest' import { RestExtractor, RestService } from '../rest'
import { catchError, map, tap } from 'rxjs/operators' import { catchError, map, tap } from 'rxjs/operators'
import { environment } from '../../../environments/environment' import { environment } from '../../../environments/environment'
import { ResultList, UserNotification as UserNotificationServer, UserNotificationSetting } from '../../../../../shared' import { ResultList, UserNotification as UserNotificationServer, UserNotificationSetting } from '../../../../../shared'
import { UserNotification } from '@app/shared/users/user-notification.model' import { UserNotification } from './user-notification.model'
import { Subject } from 'rxjs' import { AuthService } from '../../core'
import * as io from 'socket.io-client' import { ComponentPagination } from '../rest/component-pagination.model'
import { AuthService } from '@app/core' import { User } from '..'
import { ComponentPagination } from '@app/shared/rest/component-pagination.model' import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service'
import { User } from '@app/shared'
@Injectable() @Injectable()
export class UserNotificationService { export class UserNotificationService {
static BASE_NOTIFICATIONS_URL = environment.apiUrl + '/api/v1/users/me/notifications' static BASE_NOTIFICATIONS_URL = environment.apiUrl + '/api/v1/users/me/notifications'
static BASE_NOTIFICATION_SETTINGS = environment.apiUrl + '/api/v1/users/me/notification-settings' static BASE_NOTIFICATION_SETTINGS = environment.apiUrl + '/api/v1/users/me/notification-settings'
private notificationSubject = new Subject<{ type: 'new' | 'read' | 'read-all', notification?: UserNotification }>()
private socket: SocketIOClient.Socket
constructor ( constructor (
private auth: AuthService, private auth: AuthService,
private authHttp: HttpClient, private authHttp: HttpClient,
private restExtractor: RestExtractor, private restExtractor: RestExtractor,
private restService: RestService private restService: RestService,
private userNotificationSocket: UserNotificationSocket
) {} ) {}
listMyNotifications (pagination: ComponentPagination, unread?: boolean, ignoreLoadingBar = false) { listMyNotifications (pagination: ComponentPagination, unread?: boolean, ignoreLoadingBar = false) {
@ -48,16 +44,6 @@ export class UserNotificationService {
.pipe(map(n => n.total)) .pipe(map(n => n.total))
} }
getMyNotificationsSocket () {
const socket = this.getSocket()
socket.on('new-notification', (n: UserNotificationServer) => {
this.notificationSubject.next({ type: 'new', notification: new UserNotification(n) })
})
return this.notificationSubject.asObservable()
}
markAsRead (notification: UserNotification) { markAsRead (notification: UserNotification) {
const url = UserNotificationService.BASE_NOTIFICATIONS_URL + '/read' const url = UserNotificationService.BASE_NOTIFICATIONS_URL + '/read'
@ -67,7 +53,7 @@ export class UserNotificationService {
return this.authHttp.post(url, body, { headers }) return this.authHttp.post(url, body, { headers })
.pipe( .pipe(
map(this.restExtractor.extractDataBool), map(this.restExtractor.extractDataBool),
tap(() => this.notificationSubject.next({ type: 'read' })), tap(() => this.userNotificationSocket.dispatch('read')),
catchError(res => this.restExtractor.handleError(res)) catchError(res => this.restExtractor.handleError(res))
) )
} }
@ -79,7 +65,7 @@ export class UserNotificationService {
return this.authHttp.post(url, {}, { headers }) return this.authHttp.post(url, {}, { headers })
.pipe( .pipe(
map(this.restExtractor.extractDataBool), map(this.restExtractor.extractDataBool),
tap(() => this.notificationSubject.next({ type: 'read-all' })), tap(() => this.userNotificationSocket.dispatch('read-all')),
catchError(res => this.restExtractor.handleError(res)) catchError(res => this.restExtractor.handleError(res))
) )
} }
@ -94,16 +80,6 @@ export class UserNotificationService {
) )
} }
private getSocket () {
if (this.socket) return this.socket
this.socket = io(environment.apiUrl + '/user-notifications', {
query: { accessToken: this.auth.getAccessToken() }
})
return this.socket
}
private formatNotification (notification: UserNotificationServer) { private formatNotification (notification: UserNotificationServer) {
return new UserNotification(notification) return new UserNotification(notification)
} }

View File

@ -1,61 +1,101 @@
<div *ngIf="componentPagination.totalItems === 0" class="no-notification" i18n>You don't have notifications.</div> <div *ngIf="componentPagination.totalItems === 0" class="no-notification" i18n>You don't have notifications.</div>
<div class="notifications" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()"> <div class="notifications" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()">
<div *ngFor="let notification of notifications" class="notification" [ngClass]="{ unread: !notification.read }"> <div *ngFor="let notification of notifications" class="notification" [ngClass]="{ unread: !notification.read }" (click)="markAsRead(notification)">
<div [ngSwitch]="notification.type"> <ng-container [ngSwitch]="notification.type">
<ng-container i18n *ngSwitchCase="UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION"> <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION">
{{ notification.video.channel.displayName }} published a <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">new video</a> <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.video.channel.avatarUrl" />
<div class="message">
{{ notification.video.channel.displayName }} published a <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">new video</a>
</div>
</ng-container> </ng-container>
<ng-container i18n *ngSwitchCase="UserNotificationType.UNBLACKLIST_ON_MY_VIDEO"> <ng-container i18n *ngSwitchCase="UserNotificationType.UNBLACKLIST_ON_MY_VIDEO">
Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a> has been unblacklisted <my-global-icon iconName="undo"></my-global-icon>
<div class="message">
Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a> has been unblacklisted
</div>
</ng-container> </ng-container>
<ng-container i18n *ngSwitchCase="UserNotificationType.BLACKLIST_ON_MY_VIDEO"> <ng-container i18n *ngSwitchCase="UserNotificationType.BLACKLIST_ON_MY_VIDEO">
Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.videoBlacklist.video.name }}</a> has been blacklisted <my-global-icon iconName="no"></my-global-icon>
<div class="message">
Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.videoBlacklist.video.name }}</a> has been blacklisted
</div>
</ng-container> </ng-container>
<ng-container i18n *ngSwitchCase="UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS"> <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS">
<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> <my-global-icon iconName="alert"></my-global-icon>
<div class="message">
<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>
</div>
</ng-container> </ng-container>
<ng-container i18n *ngSwitchCase="UserNotificationType.NEW_COMMENT_ON_MY_VIDEO"> <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_COMMENT_ON_MY_VIDEO">
{{ notification.comment.account.displayName }} commented your video <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">{{ notification.comment.video.name }}</a> <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.comment.account.avatarUrl" />
<div class="message">
<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>
</div>
</ng-container> </ng-container>
<ng-container i18n *ngSwitchCase="UserNotificationType.MY_VIDEO_PUBLISHED"> <ng-container i18n *ngSwitchCase="UserNotificationType.MY_VIDEO_PUBLISHED">
Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a> has been published <my-global-icon iconName="sparkle"></my-global-icon>
<div class="message">
Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a> has been published
</div>
</ng-container> </ng-container>
<ng-container i18n *ngSwitchCase="UserNotificationType.MY_VIDEO_IMPORT_SUCCESS"> <ng-container i18n *ngSwitchCase="UserNotificationType.MY_VIDEO_IMPORT_SUCCESS">
<a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">Your video import</a> {{ notification.videoImportIdentifier }} succeeded <my-global-icon iconName="cloud-download"></my-global-icon>
<div class="message">
<a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">Your video import</a> {{ notification.videoImportIdentifier }} succeeded
</div>
</ng-container> </ng-container>
<ng-container i18n *ngSwitchCase="UserNotificationType.MY_VIDEO_IMPORT_ERROR"> <ng-container i18n *ngSwitchCase="UserNotificationType.MY_VIDEO_IMPORT_ERROR">
<a (click)="markAsRead(notification)" [routerLink]="notification.videoImportUrl">Your video import</a> {{ notification.videoImportIdentifier }} failed <my-global-icon iconName="cloud-error"></my-global-icon>
<div class="message">
<a (click)="markAsRead(notification)" [routerLink]="notification.videoImportUrl">Your video import</a> {{ notification.videoImportIdentifier }} failed
</div>
</ng-container> </ng-container>
<ng-container i18n *ngSwitchCase="UserNotificationType.NEW_USER_REGISTRATION"> <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_USER_REGISTRATION">
User <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.account.name }} registered</a> on your instance <my-global-icon iconName="user-add"></my-global-icon>
<div class="message">
User <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.account.name }} registered</a> on your instance
</div>
</ng-container> </ng-container>
<ng-container i18n *ngSwitchCase="UserNotificationType.NEW_FOLLOW"> <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_FOLLOW">
<a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.actorFollow.follower.displayName }}</a> is following <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.actorFollow.follower.avatarUrl" />
<ng-container *ngIf="notification.actorFollow.following.type === 'channel'"> <div class="message">
your channel {{ notification.actorFollow.following.displayName }} <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.actorFollow.follower.displayName }}</a> is following
</ng-container>
<ng-container *ngIf="notification.actorFollow.following.type === 'account'">your account</ng-container> <ng-container *ngIf="notification.actorFollow.following.type === 'channel'">your channel {{ notification.actorFollow.following.displayName }}</ng-container>
<ng-container *ngIf="notification.actorFollow.following.type === 'account'">your account</ng-container>
</div>
</ng-container> </ng-container>
<ng-container i18n *ngSwitchCase="UserNotificationType.COMMENT_MENTION"> <ng-container i18n *ngSwitchCase="UserNotificationType.COMMENT_MENTION">
{{ notification.comment.account.displayName }} mentioned you on <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">video {{ notification.comment.video.name }}</a> <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.comment.account.avatarUrl" />
</ng-container>
</div>
<div i18n title="Mark as read" class="mark-as-read"> <div class="message">
<div class="glyphicon glyphicon-ok" (click)="markAsRead(notification)"></div> <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>
</div> </div>
</ng-container>
</ng-container>
<div class="from-date">{{ notification.createdAt | myFromNow }}</div>
</div> </div>
</div> </div>

View File

@ -1,30 +1,51 @@
@import '_variables';
@import '_mixins';
.no-notification {
display: flex;
justify-content: center;
align-items: center;
padding: 20px 0;
}
.notification { .notification {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
font-size: inherit; font-size: inherit;
padding: 15px 10px; padding: 15px 5px 15px 10px;
border-bottom: 1px solid rgba(0, 0, 0, 0.10); border-bottom: 1px solid rgba(0, 0, 0, 0.10);
.mark-as-read {
min-width: 35px;
.glyphicon {
display: none;
cursor: pointer;
color: rgba(20, 20, 20, 0.5)
}
}
&.unread { &.unread {
background-color: rgba(0, 0, 0, 0.05); background-color: rgba(0, 0, 0, 0.05);
}
&:hover .mark-as-read .glyphicon { my-global-icon {
display: block; width: 24px;
margin-right: 11px;
margin-left: 3px;
&:hover { @include apply-svg-color(#333);
color: rgba(20, 20, 20, 0.8); }
}
.avatar {
@include avatar(30px);
margin-right: 10px;
}
.message {
flex-grow: 1;
a {
font-weight: $font-semibold;
} }
} }
.from-date {
font-size: 0.85em;
color: $grey-foreground-color;
padding-left: 5px;
min-width: 70px;
text-align: right;
}
} }

View File

@ -13,17 +13,14 @@ import { UserNotification } from '@app/shared/users/user-notification.model'
export class UserNotificationsComponent implements OnInit { export class UserNotificationsComponent implements OnInit {
@Input() ignoreLoadingBar = false @Input() ignoreLoadingBar = false
@Input() infiniteScroll = true @Input() infiniteScroll = true
@Input() itemsPerPage = 20
notifications: UserNotification[] = [] notifications: UserNotification[] = []
// So we can access it in the template // So we can access it in the template
UserNotificationType = UserNotificationType UserNotificationType = UserNotificationType
componentPagination: ComponentPagination = { componentPagination: ComponentPagination
currentPage: 1,
itemsPerPage: 10,
totalItems: null
}
constructor ( constructor (
private userNotificationService: UserNotificationService, private userNotificationService: UserNotificationService,
@ -31,6 +28,12 @@ export class UserNotificationsComponent implements OnInit {
) { } ) { }
ngOnInit () { ngOnInit () {
this.componentPagination = {
currentPage: 1,
itemsPerPage: this.itemsPerPage, // Reset items per page, because of the @Input() variable
totalItems: null
}
this.loadMoreNotifications() this.loadMoreNotifications()
} }
@ -57,6 +60,8 @@ export class UserNotificationsComponent implements OnInit {
} }
markAsRead (notification: UserNotification) { markAsRead (notification: UserNotification) {
if (notification.read) return
this.userNotificationService.markAsRead(notification) this.userNotificationService.markAsRead(notification)
.subscribe( .subscribe(
() => { () => {

View File

@ -32,9 +32,7 @@ export class VideoAbuseService {
reportVideo (id: number, reason: string) { reportVideo (id: number, reason: string) {
const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + id + '/abuse' const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + id + '/abuse'
const body = { const body = { reason }
reason
}
return this.authHttp.post(url, body) return this.authHttp.post(url, body)
.pipe( .pipe(

View File

@ -36,8 +36,11 @@ export class VideoBlacklistService {
) )
} }
blacklistVideo (videoId: number, reason?: string) { blacklistVideo (videoId: number, reason: string, unfederate: boolean) {
const body = reason ? { reason } : {} const body = {
unfederate,
reason
}
return this.authHttp.post(VideoBlacklistService.BASE_VIDEOS_URL + videoId + '/blacklist', body) return this.authHttp.post(VideoBlacklistService.BASE_VIDEOS_URL + videoId + '/blacklist', body)
.pipe( .pipe(

View File

@ -82,6 +82,7 @@ export class VideoImportService {
nsfw: video.nsfw, nsfw: video.nsfw,
waitTranscoding: video.waitTranscoding, waitTranscoding: video.waitTranscoding,
commentsEnabled: video.commentsEnabled, commentsEnabled: video.commentsEnabled,
downloadEnabled: video.downloadEnabled,
thumbnailfile: video.thumbnailfile, thumbnailfile: video.thumbnailfile,
previewfile: video.previewfile, previewfile: video.previewfile,
scheduleUpdate, scheduleUpdate,

View File

@ -1,8 +1,11 @@
<div [ngClass]="{ 'margin-content': marginContent }"> <div [ngClass]="{ 'margin-content': marginContent }">
<div class="videos-header"> <div class="videos-header">
<div *ngIf="titlePage" class="title-page title-page-single"> <div *ngIf="titlePage" class="title-page title-page-single">
{{ titlePage }} <div placement="bottom" [ngbTooltip]="titleTooltip" container="body">
{{ titlePage }}
</div>
</div> </div>
<my-feed [syndicationItems]="syndicationItems"></my-feed> <my-feed [syndicationItems]="syndicationItems"></my-feed>
<div class="moderation-block" *ngIf="displayModerationBlock"> <div class="moderation-block" *ngIf="displayModerationBlock">

View File

@ -19,8 +19,8 @@
my-feed { my-feed {
display: inline-block; display: inline-block;
position: relative;
top: 1px; top: 1px;
min-width: 60px;
} }
.moderation-block { .moderation-block {

View File

@ -39,6 +39,7 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy {
ownerDisplayType: OwnerDisplayType = 'account' ownerDisplayType: OwnerDisplayType = 'account'
firstLoadedPage: number firstLoadedPage: number
displayModerationBlock = false displayModerationBlock = false
titleTooltip: string
protected baseVideoWidth = 215 protected baseVideoWidth = 215
protected baseVideoHeight = 205 protected baseVideoHeight = 205

View File

@ -1,10 +1,11 @@
<div class="video-feed"> <div class="video-feed">
<span <my-global-icon
*ngIf="syndicationItems.length !== 0" [ngbPopover]="feedsList" [autoClose]="true" placement="bottom" *ngIf="syndicationItems.length !== 0" [ngbPopover]="feedsList" [autoClose]="true" placement="bottom"
class="icon icon-syndication" role="button" class="icon-syndication" role="button" iconName="syndication"
></span> >
</my-global-icon>
<ng-template #feedsList> <ng-template #feedsList>
<a *ngFor="let item of syndicationItems" [href]="item.url" target="_blank" rel="noopener noreferrer">{{ item.label }}</a> <a *ngFor="let item of syndicationItems" [href]="item.url" target="_blank" rel="noopener noreferrer">{{ item.label }}</a>
</ng-template> </ng-template>
</div> </div>

View File

@ -1,3 +1,4 @@
@import '_variables';
@import '_mixins'; @import '_mixins';
.video-feed { .video-feed {
@ -6,14 +7,12 @@
display: block; display: block;
} }
.icon { my-global-icon {
@include icon(12px); cursor: pointer;
width: 12px;
position: relative;
top: -2px;
&.icon-syndication { @include apply-svg-color(var(--mainForegroundColor))
position: relative;
top: -2px;
background-color: var(--mainForegroundColor);
mask-image: url('../../../assets/images/global/syndication.svg');
}
} }
} }

View File

@ -3,6 +3,8 @@ import { AuthUser } from '../../core'
import { Video } from '../../shared/video/video.model' import { Video } from '../../shared/video/video.model'
import { Account } from '@app/shared/account/account.model' import { Account } from '@app/shared/account/account.model'
import { VideoChannel } from '@app/shared/video-channel/video-channel.model' import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
import { VideoStreamingPlaylist } from '../../../../../shared/models/videos/video-streaming-playlist.model'
import { VideoStreamingPlaylistType } from '../../../../../shared/models/videos/video-streaming-playlist.type'
export class VideoDetails extends Video implements VideoDetailsServerModel { export class VideoDetails extends Video implements VideoDetailsServerModel {
descriptionPath: string descriptionPath: string
@ -12,6 +14,7 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
files: VideoFile[] files: VideoFile[]
account: Account account: Account
commentsEnabled: boolean commentsEnabled: boolean
downloadEnabled: boolean
waitTranscoding: boolean waitTranscoding: boolean
state: VideoConstant<VideoState> state: VideoConstant<VideoState>
@ -19,6 +22,10 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
likesPercent: number likesPercent: number
dislikesPercent: number dislikesPercent: number
trackerUrls: string[]
streamingPlaylists: VideoStreamingPlaylist[]
constructor (hash: VideoDetailsServerModel, translations = {}) { constructor (hash: VideoDetailsServerModel, translations = {}) {
super(hash, translations) super(hash, translations)
@ -29,6 +36,10 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
this.tags = hash.tags this.tags = hash.tags
this.support = hash.support this.support = hash.support
this.commentsEnabled = hash.commentsEnabled this.commentsEnabled = hash.commentsEnabled
this.downloadEnabled = hash.downloadEnabled
this.trackerUrls = hash.trackerUrls
this.streamingPlaylists = hash.streamingPlaylists
this.buildLikeAndDislikePercents() this.buildLikeAndDislikePercents()
} }
@ -53,4 +64,8 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
this.likesPercent = (this.likes / (this.likes + this.dislikes)) * 100 this.likesPercent = (this.likes / (this.likes + this.dislikes)) * 100
this.dislikesPercent = (this.dislikes / (this.likes + this.dislikes)) * 100 this.dislikesPercent = (this.dislikes / (this.likes + this.dislikes)) * 100
} }
getHlsPlaylist () {
return this.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
}
} }

View File

@ -14,6 +14,7 @@ export class VideoEdit implements VideoUpdate {
tags: string[] tags: string[]
nsfw: boolean nsfw: boolean
commentsEnabled: boolean commentsEnabled: boolean
downloadEnabled: boolean
waitTranscoding: boolean waitTranscoding: boolean
channelId: number channelId: number
privacy: VideoPrivacy privacy: VideoPrivacy
@ -27,7 +28,15 @@ export class VideoEdit implements VideoUpdate {
scheduleUpdate?: VideoScheduleUpdate scheduleUpdate?: VideoScheduleUpdate
originallyPublishedAt?: Date | string originallyPublishedAt?: Date | string
constructor (video?: Video & { tags: string[], commentsEnabled: boolean, support: string, thumbnailUrl: string, previewUrl: string }) { constructor (
video?: Video & {
tags: string[],
commentsEnabled: boolean,
downloadEnabled: boolean,
support: string,
thumbnailUrl: string,
previewUrl: string
}) {
if (video) { if (video) {
this.id = video.id this.id = video.id
this.uuid = video.uuid this.uuid = video.uuid
@ -39,6 +48,7 @@ export class VideoEdit implements VideoUpdate {
this.tags = video.tags this.tags = video.tags
this.nsfw = video.nsfw this.nsfw = video.nsfw
this.commentsEnabled = video.commentsEnabled this.commentsEnabled = video.commentsEnabled
this.downloadEnabled = video.downloadEnabled
this.waitTranscoding = video.waitTranscoding this.waitTranscoding = video.waitTranscoding
this.channelId = video.channel.id this.channelId = video.channel.id
this.privacy = video.privacy.id this.privacy = video.privacy.id
@ -88,6 +98,7 @@ export class VideoEdit implements VideoUpdate {
tags: this.tags, tags: this.tags,
nsfw: this.nsfw, nsfw: this.nsfw,
commentsEnabled: this.commentsEnabled, commentsEnabled: this.commentsEnabled,
downloadEnabled: this.downloadEnabled,
waitTranscoding: this.waitTranscoding, waitTranscoding: this.waitTranscoding,
channelId: this.channelId, channelId: this.channelId,
privacy: this.privacy, privacy: this.privacy,

View File

@ -50,10 +50,10 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-size: 13px; font-size: 13px;
color: #585858; color: $grey-foreground-color;
&:hover { &:hover {
color: #303030; color: $grey-foreground-hover-color;
} }
} }
} }

View File

@ -54,7 +54,7 @@ export class Video implements VideoServerModel {
displayName: string displayName: string
url: string url: string
host: string host: string
avatar: Avatar avatar?: Avatar
} }
channel: { channel: {
@ -64,7 +64,7 @@ export class Video implements VideoServerModel {
displayName: string displayName: string
url: string url: string
host: string host: string
avatar: Avatar avatar?: Avatar
} }
userHistory?: { userHistory?: {

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