From 29128b2f5ce00093ad81b4b72daae0e3444fd5a8 Mon Sep 17 00:00:00 2001 From: Rigel Kent Date: Thu, 2 Jan 2020 13:07:18 +0100 Subject: [PATCH] Add miniature quick actions to add video to Watch later playlist --- client/src/app/core/auth/auth-user.model.ts | 2 + client/src/app/core/auth/auth.service.ts | 2 +- client/src/app/login/login.component.ts | 2 - .../shared/images/global-icon.component.ts | 1 + .../video-playlist/video-playlist.service.ts | 1 + .../video/video-thumbnail.component.html | 14 ++++ .../video/video-thumbnail.component.scss | 42 +++++++++- .../shared/video/video-thumbnail.component.ts | 82 ++++++++++++++++++- client/src/assets/images/global/clock.svg | 11 +++ server/controllers/api/users/me.ts | 2 +- server/models/account/user.ts | 31 +++++-- server/tests/api/users/users.ts | 6 +- shared/models/users/user.model.ts | 5 ++ 13 files changed, 184 insertions(+), 17 deletions(-) create mode 100644 client/src/assets/images/global/clock.svg diff --git a/client/src/app/core/auth/auth-user.model.ts b/client/src/app/core/auth/auth-user.model.ts index d371a923f..55a5a6dde 100644 --- a/client/src/app/core/auth/auth-user.model.ts +++ b/client/src/app/core/auth/auth-user.model.ts @@ -5,6 +5,7 @@ import { User as ServerUserModel } from '../../../../../shared/models/users/user import { hasUserRight, UserRole } from '../../../../../shared/models/users/user-role' import { User } from '../../shared/users/user.model' import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type' +import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' export type TokenOptions = { accessToken: string @@ -79,6 +80,7 @@ export class AuthUser extends User { } tokens: Tokens + specialPlaylists: Partial[] static load () { const usernameLocalStorage = peertubeLocalStorage.getItem(this.KEYS.USERNAME) diff --git a/client/src/app/core/auth/auth.service.ts b/client/src/app/core/auth/auth.service.ts index d601cadf5..9ae008e39 100644 --- a/client/src/app/core/auth/auth.service.ts +++ b/client/src/app/core/auth/auth.service.ts @@ -4,7 +4,7 @@ import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http' import { Injectable } from '@angular/core' import { Router } from '@angular/router' import { Notifier } from '@app/core/notification/notifier.service' -import { OAuthClientLocal, User as UserServerModel, UserRefreshToken } from '../../../../../shared' +import { OAuthClientLocal, MyUser as UserServerModel, UserRefreshToken } from '../../../../../shared' import { User } from '../../../../../shared/models/users' import { UserLogin } from '../../../../../shared/models/users/user-login.model' import { environment } from '../../../environments/environment' diff --git a/client/src/app/login/login.component.ts b/client/src/app/login/login.component.ts index cf923492a..ffadc9aa4 100644 --- a/client/src/app/login/login.component.ts +++ b/client/src/app/login/login.component.ts @@ -28,13 +28,11 @@ export class LoginComponent extends FormReactive implements OnInit { constructor ( protected formValidatorService: FormValidatorService, - private router: Router, private route: ActivatedRoute, private modalService: NgbModal, private loginValidatorsService: LoginValidatorsService, private authService: AuthService, private userService: UserService, - private serverService: ServerService, private redirectService: RedirectService, private notifier: Notifier, private i18n: I18n diff --git a/client/src/app/shared/images/global-icon.component.ts b/client/src/app/shared/images/global-icon.component.ts index 8a4965926..17186cff4 100644 --- a/client/src/app/shared/images/global-icon.component.ts +++ b/client/src/app/shared/images/global-icon.component.ts @@ -10,6 +10,7 @@ const icons = { 'sparkle': require('!!raw-loader?!../../../assets/images/global/sparkle.svg'), 'alert': require('!!raw-loader?!../../../assets/images/global/alert.svg'), 'cloud-error': require('!!raw-loader?!../../../assets/images/global/cloud-error.svg'), + 'clock': require('!!raw-loader?!../../../assets/images/global/clock.svg'), 'user-add': require('!!raw-loader?!../../../assets/images/global/user-add.svg'), 'no': require('!!raw-loader?!../../../assets/images/global/no.svg'), 'cloud-download': require('!!raw-loader?!../../../assets/images/global/cloud-download.svg'), diff --git a/client/src/app/shared/video-playlist/video-playlist.service.ts b/client/src/app/shared/video-playlist/video-playlist.service.ts index 5f74dcd4c..fc3b77b2a 100644 --- a/client/src/app/shared/video-playlist/video-playlist.service.ts +++ b/client/src/app/shared/video-playlist/video-playlist.service.ts @@ -30,6 +30,7 @@ export class VideoPlaylistService { // Use a replay subject because we "next" a value before subscribing private videoExistsInPlaylistSubject: Subject = new ReplaySubject(1) private readonly videoExistsInPlaylistObservable: Observable + private cachedWatchLaterPlaylists: VideoPlaylist[] constructor ( private authHttp: HttpClient, diff --git a/client/src/app/shared/video/video-thumbnail.component.html b/client/src/app/shared/video/video-thumbnail.component.html index b302ebd0f..df15698c0 100644 --- a/client/src/app/shared/video/video-thumbnail.component.html +++ b/client/src/app/shared/video/video-thumbnail.component.html @@ -1,9 +1,23 @@ +
+ +
+ +
+
+ +
+ +
+
+
+
{{ video.durationLabel }}
diff --git a/client/src/app/shared/video/video-thumbnail.component.scss b/client/src/app/shared/video/video-thumbnail.component.scss index e48629778..aac50fd1b 100644 --- a/client/src/app/shared/video/video-thumbnail.component.scss +++ b/client/src/app/shared/video/video-thumbnail.component.scss @@ -18,16 +18,50 @@ } } + .video-thumbnail-watch-later-overlay, .video-thumbnail-duration-overlay { @include static-thumbnail-overlay; - position: absolute; - right: 5px; - bottom: 5px; - padding: 0 5px; border-radius: 3px; font-size: 12px; font-weight: $font-bold; z-index: 1; } + + .video-thumbnail-duration-overlay { + position: absolute; + padding: 0 5px; + right: 5px; + bottom: 5px; + } + + &:hover { + .video-thumbnail-actions-overlay { + opacity: 1; + } + } + + .video-thumbnail-actions-overlay { + position: absolute; + display: flex; + flex-direction: column; + right: 5px; + top: 5px; + opacity: 0; + + div:not(:first-child) { + margin-top: 2px; + } + + .video-thumbnail-watch-later-overlay { + padding: 3px; + + my-global-icon { + width: 22px; + height: 22px; + + @include apply-svg-color(#fff); + } + } + } } diff --git a/client/src/app/shared/video/video-thumbnail.component.ts b/client/src/app/shared/video/video-thumbnail.component.ts index fe65ade94..0f605e425 100644 --- a/client/src/app/shared/video/video-thumbnail.component.ts +++ b/client/src/app/shared/video/video-thumbnail.component.ts @@ -1,6 +1,14 @@ -import { Component, Input } from '@angular/core' +import { Component, Input, OnInit, ChangeDetectorRef } from '@angular/core' import { Video } from './video.model' import { ScreenService } from '@app/shared/misc/screen.service' +import { AuthService, ThemeService } from '@app/core' +import { VideoPlaylistService } from '../video-playlist/video-playlist.service' +import { VideoPlaylistType } from '@shared/models' +import { forkJoin } from 'rxjs' +import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist-element.model' +import { VideoPlaylist } from '../video-playlist/video-playlist.model' +import { VideoPlaylistElementCreate } from '../../../../../shared' +import { VideoExistInPlaylist } from '@shared/models/videos/playlist/video-exist-in-playlist.model' @Component({ selector: 'my-video-thumbnail', @@ -13,7 +21,44 @@ export class VideoThumbnailComponent { @Input() routerLink: any[] @Input() queryParams: any[] - constructor (private screenService: ScreenService) { + addToWatchLaterText = 'Add to watch later' + addedToWatchLaterText = 'Added to watch later' + addedToWatchLater: boolean + + watchLaterPlaylist: any + + constructor ( + private screenService: ScreenService, + private authService: AuthService, + private videoPlaylistService: VideoPlaylistService, + private cd: ChangeDetectorRef + ) {} + + load () { + if (this.addedToWatchLater !== undefined) return + + this.videoPlaylistService.doesVideoExistInPlaylist(this.video.id) + .subscribe( + existResult => { + for (const playlist of this.authService.getUser().specialPlaylists) { + const existingPlaylist = existResult[ this.video.id ].find(p => p.playlistId === playlist.id) + this.addedToWatchLater = !!existingPlaylist + + if (existingPlaylist) { + this.watchLaterPlaylist = { + playlistId: existingPlaylist.playlistId, + playlistElementId: existingPlaylist.playlistElementId + } + } else { + this.watchLaterPlaylist = { + playlistId: playlist.id + } + } + + this.cd.markForCheck() + } + } + ) } getImageUrl () { @@ -39,4 +84,37 @@ export class VideoThumbnailComponent { return [ '/videos/watch', this.video.uuid ] } + + isUserLoggedIn () { + return this.authService.isLoggedIn() + } + + addToWatchLater () { + if (this.addedToWatchLater === undefined) return + this.addedToWatchLater = true + + this.videoPlaylistService.addVideoInPlaylist( + this.watchLaterPlaylist.playlistId, + { videoId: this.video.id } as VideoPlaylistElementCreate + ).subscribe( + res => { + this.addedToWatchLater = true + this.watchLaterPlaylist.playlistElementId = res.videoPlaylistElement.id + } + ) + } + + removeFromWatchLater () { + if (this.addedToWatchLater === undefined) return + this.addedToWatchLater = false + + this.videoPlaylistService.removeVideoFromPlaylist( + this.watchLaterPlaylist.playlistId, + this.watchLaterPlaylist.playlistElementId + ).subscribe( + _ => { + this.addedToWatchLater = false + } + ) + } } diff --git a/client/src/assets/images/global/clock.svg b/client/src/assets/images/global/clock.svg new file mode 100644 index 000000000..f2d4f0397 --- /dev/null +++ b/client/src/assets/images/global/clock.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/server/controllers/api/users/me.ts b/server/controllers/api/users/me.ts index b1f29f252..2f3efe6aa 100644 --- a/server/controllers/api/users/me.ts +++ b/server/controllers/api/users/me.ts @@ -128,7 +128,7 @@ async function getUserInformation (req: express.Request, res: express.Response) // We did not load channels in res.locals.user const user = await UserModel.loadByUsernameAndPopulateChannels(res.locals.oauth.token.user.username) - return res.json(user.toFormattedJSON()) + return res.json(user.toFormattedJSON({ me: true })) } async function getUserVideoQuotaUsed (req: express.Request, res: express.Response) { diff --git a/server/models/account/user.ts b/server/models/account/user.ts index 3a339b5c3..8bd41de22 100644 --- a/server/models/account/user.ts +++ b/server/models/account/user.ts @@ -19,7 +19,7 @@ import { Table, UpdatedAt } from 'sequelize-typescript' -import { hasUserRight, USER_ROLE_LABELS, UserRight, VideoPrivacy } from '../../../shared' +import { hasUserRight, USER_ROLE_LABELS, UserRight, VideoPrivacy, MyUser } from '../../../shared' import { User, UserRole } from '../../../shared/models/users' import { isNoInstanceConfigWarningModal, @@ -45,6 +45,7 @@ import { comparePassword, cryptPassword } from '../../helpers/peertube-crypto' import { OAuthTokenModel } from '../oauth/oauth-token' import { getSort, throwIfNotValid } from '../utils' import { VideoChannelModel } from '../video/video-channel' +import { VideoPlaylistModel } from '../video/video-playlist' import { AccountModel } from './account' import { NSFWPolicyType } from '../../../shared/models/videos/nsfw-policy.type' import { values } from 'lodash' @@ -68,7 +69,8 @@ import { } from '@server/typings/models' enum ScopeNames { - WITH_VIDEO_CHANNEL = 'WITH_VIDEO_CHANNEL' + WITH_VIDEO_CHANNEL = 'WITH_VIDEO_CHANNEL', + WITH_SPECIAL_PLAYLISTS = 'WITH_SPECIAL_PLAYLISTS' } @DefaultScope(() => ({ @@ -96,6 +98,16 @@ enum ScopeNames { required: true } ] + }, + [ScopeNames.WITH_SPECIAL_PLAYLISTS]: { + attributes: { + include: [ + [ + literal('(select array(select "id" from "videoPlaylist" where "ownerAccountId" in (select id from public.account where "userId" = "UserModel"."id") and name LIKE \'Watch later\'))'), + 'specialPlaylists' + ] + ] + } } })) @Table({ @@ -431,7 +443,10 @@ export class UserModel extends Model { } } - return UserModel.scope(ScopeNames.WITH_VIDEO_CHANNEL).findOne(query) + return UserModel.scope([ + ScopeNames.WITH_VIDEO_CHANNEL, + ScopeNames.WITH_SPECIAL_PLAYLISTS + ]).findOne(query) } static loadByEmail (email: string): Bluebird { @@ -610,11 +625,11 @@ export class UserModel extends Model { return comparePassword(password, this.password) } - toFormattedJSON (this: MUserFormattable, parameters: { withAdminFlags?: boolean } = {}): User { + toFormattedJSON (this: MUserFormattable, parameters: { withAdminFlags?: boolean, me?: boolean } = {}): User | MyUser { const videoQuotaUsed = this.get('videoQuotaUsed') const videoQuotaUsedDaily = this.get('videoQuotaUsedDaily') - const json: User = { + const json: User | MyUser = { id: this.id, username: this.username, email: this.email, @@ -675,6 +690,12 @@ export class UserModel extends Model { }) } + if (parameters.me) { + Object.assign(json, { + specialPlaylists: (this.get('specialPlaylists') as Array).map(p => ({ id: p })) + }) + } + return json } diff --git a/server/tests/api/users/users.ts b/server/tests/api/users/users.ts index 07b7fc747..3c3ee3ed7 100644 --- a/server/tests/api/users/users.ts +++ b/server/tests/api/users/users.ts @@ -2,7 +2,7 @@ import * as chai from 'chai' import 'mocha' -import { User, UserRole, Video } from '../../../../shared/index' +import { User, UserRole, Video, MyUser } from '../../../../shared/index' import { blockUser, cleanupTests, @@ -251,7 +251,7 @@ describe('Test users', function () { it('Should be able to get user information', async function () { const res1 = await getMyUserInformation(server.url, accessTokenUser) - const userMe: User = res1.body + const userMe: User & MyUser = res1.body const res2 = await getUserInformation(server.url, server.accessToken, userMe.id) const userGet: User = res2.body @@ -269,6 +269,8 @@ describe('Test users', function () { expect(userMe.adminFlags).to.be.undefined expect(userGet.adminFlags).to.equal(UserAdminFlag.BY_PASS_VIDEO_AUTO_BLACKLIST) + + expect(userMe.specialPlaylists).to.have.lengthOf(1) }) }) diff --git a/shared/models/users/user.model.ts b/shared/models/users/user.model.ts index 90d59ac56..1434dca81 100644 --- a/shared/models/users/user.model.ts +++ b/shared/models/users/user.model.ts @@ -1,5 +1,6 @@ import { Account } from '../actors' import { VideoChannel } from '../videos/channel/video-channel.model' +import { VideoPlaylist } from '../videos/playlist/video-playlist.model' import { UserRole } from './user-role' import { NSFWPolicyType } from '../videos/nsfw-policy.type' import { UserNotificationSetting } from './user-notification-setting.model' @@ -45,3 +46,7 @@ export interface User { createdAt: Date } + +export interface MyUser extends User { + specialPlaylists: Partial[] +}