Add miniature quick actions to add video to Watch later playlist
authorRigel Kent <sendmemail@rigelk.eu>
Thu, 2 Jan 2020 12:07:18 +0000 (13:07 +0100)
committerRigel Kent <sendmemail@rigelk.eu>
Thu, 2 Jan 2020 13:50:14 +0000 (14:50 +0100)
13 files changed:
client/src/app/core/auth/auth-user.model.ts
client/src/app/core/auth/auth.service.ts
client/src/app/login/login.component.ts
client/src/app/shared/images/global-icon.component.ts
client/src/app/shared/video-playlist/video-playlist.service.ts
client/src/app/shared/video/video-thumbnail.component.html
client/src/app/shared/video/video-thumbnail.component.scss
client/src/app/shared/video/video-thumbnail.component.ts
client/src/assets/images/global/clock.svg [new file with mode: 0644]
server/controllers/api/users/me.ts
server/models/account/user.ts
server/tests/api/users/users.ts
shared/models/users/user.model.ts

index d371a923f295930246d63c81577c80136aa0c478..55a5a6ddeb4ca8f9204f41328312b5fd86c73fd1 100644 (file)
@@ -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<VideoPlaylist>[]
 
   static load () {
     const usernameLocalStorage = peertubeLocalStorage.getItem(this.KEYS.USERNAME)
index d601cadf56c79b6425bba54356f9ba518c74587e..9ae008e390ddf00b7dd9182e4b7bcf69b3b9e705 100644 (file)
@@ -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'
index cf923492a9a9bb2913e07cfdedd223eb78597ab9..ffadc9aa46eda943e120d8f2a9cb3ea20197650c 100644 (file)
@@ -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
index 8a49659265076cbaf6064ec4b99bdb8e2f2b998e..17186cff4ae1401f45adecc3916ff1ad10f2a72c 100644 (file)
@@ -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'),
index 5f74dcd4c97d9a023fb65f22473907e9a6fc2405..fc3b77b2aaa9f10e05cbb96e1ade1e6c5eca454c 100644 (file)
@@ -30,6 +30,7 @@ export class VideoPlaylistService {
   // Use a replay subject because we "next" a value before subscribing
   private videoExistsInPlaylistSubject: Subject<number> = new ReplaySubject(1)
   private readonly videoExistsInPlaylistObservable: Observable<VideoExistInPlaylist>
+  private cachedWatchLaterPlaylists: VideoPlaylist[]
 
   constructor (
     private authHttp: HttpClient,
index b302ebd0f18921e2a636f9acd94b2a09a85b8083..df15698c0e5339fc53cbda1d4ded0ccf73a9042c 100644 (file)
@@ -1,9 +1,23 @@
 <a
   [routerLink]="getVideoRouterLink()" [queryParams]="queryParams" [attr.title]="video.name"
   class="video-thumbnail"
+  (mouseenter)="load()"
 >
   <img alt="" [attr.aria-labelledby]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" />
 
+  <div *ngIf="isUserLoggedIn()" class="video-thumbnail-actions-overlay">
+    <ng-container *ngIf="addedToWatchLater !== true">
+      <div class="video-thumbnail-watch-later-overlay" placement="left" [ngbTooltip]="addToWatchLaterText" container="body" (click)="addToWatchLater();$event.stopPropagation();false">
+        <my-global-icon iconName="clock" [attr.aria-label]="addToWatchLaterText" role="button"></my-global-icon>
+      </div>
+    </ng-container>
+    <ng-container *ngIf="addedToWatchLater === true">
+      <div class="video-thumbnail-watch-later-overlay" placement="left" [ngbTooltip]="addedToWatchLaterText" container="body" (click)="removeFromWatchLater();$event.stopPropagation();false">
+        <my-global-icon iconName="tick" [attr.aria-label]="addedToWatchLaterText" role="button"></my-global-icon>
+      </div>
+    </ng-container>
+  </div>
+
   <div class="video-thumbnail-duration-overlay">{{ video.durationLabel }}</div>
 
   <div class="play-overlay">
index e48629778044ce4060ac41dbdf093ee1459d25b9..aac50fd1bd355db2fd960252fb5078e60dada9fa 100644 (file)
     }
   }
 
+  .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);
+      }
+    }
+  }
 }
index fe65ade9442a1b5da0beedaab65b269fa699d0fd..0f605e425d849b7dd82f6fd4d96c4f618ab727c4 100644 (file)
@@ -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 (file)
index 0000000..f2d4f03
--- /dev/null
@@ -0,0 +1,11 @@
+<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Artboard-4" transform="translate(-488.000000, -159.000000)" stroke="#000000" stroke-width="2">
+            <g id="31" transform="translate(488.000000, 159.000000)">
+                <path d="M12,21 C7.02943725,21 3,16.9705627 3,12 C3,7.02943725 7.02943725,3 12,3 C16.9705627,3 21,7.02943725 21,12 C21,16.9705627 16.9705627,21 12,21 Z" id="Base"/>
+                <path d="M12,12 L16,12" id="Path-18" stroke-linecap="round"/>
+                <path d="M12,12 L12,7" id="Path-40" stroke-linecap="round" stroke-linejoin="round"/>
+            </g>
+        </g>
+    </g>
+</svg>
\ No newline at end of file
index b1f29f252632f3ecdc07d45372fba35cb388310d..2f3efe6aaa82ff0ef8bb008cef78f97f60b99e21 100644 (file)
@@ -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) {
index 3a339b5c35512506f0f1fa8dc0fe6f409e22274c..8bd41de22f85edbc188963db3ab4f11b1b34f217 100644 (file)
@@ -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<UserModel> {
       }
     }
 
-    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<MUserDefault> {
@@ -610,11 +625,11 @@ export class UserModel extends Model<UserModel> {
     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<UserModel> {
         })
     }
 
+    if (parameters.me) {
+      Object.assign(json, {
+        specialPlaylists: (this.get('specialPlaylists') as Array<number>).map(p => ({ id: p }))
+      })
+    }
+
     return json
   }
 
index 07b7fc74709d904ec50e32d68182cabe4bd86e9f..3c3ee3ed7ccf6882cfbf6288c7f882692ee88410 100644 (file)
@@ -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)
     })
   })
 
index 90d59ac56d6614c268eae7281cca4a0ddee0ea28..1434dca81d393822f15b1b667d56283cb7c4a631 100644 (file)
@@ -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<VideoPlaylist>[]
+}