Add mentions to comments
authorChocobozzz <me@florianbigard.com>
Fri, 5 Jan 2018 10:19:25 +0000 (11:19 +0100)
committerChocobozzz <me@florianbigard.com>
Fri, 5 Jan 2018 10:19:25 +0000 (11:19 +0100)
client/src/app/videos/+video-watch/comment/video-comment-add.component.html
client/src/app/videos/+video-watch/comment/video-comment-add.component.ts
client/src/app/videos/+video-watch/comment/video-comment.component.html
client/src/app/videos/+video-watch/comment/video-comment.component.ts
client/src/app/videos/+video-watch/comment/video-comments.component.scss
server/controllers/activitypub/client.ts
server/lib/activitypub/send/misc.ts
server/lib/activitypub/send/send-create.ts
server/models/video/video-comment.ts
shared/models/activitypub/objects/common-objects.ts
shared/models/activitypub/objects/video-comment-object.ts

index 0eaa0d447b4554df83de46bec175782d0e0b85cb..41d00da085e319c4ca3621edd05ebd43f23e7766 100644 (file)
@@ -4,6 +4,7 @@
 
     <div class="form-group">
       <textarea placeholder="Add comment..." formControlName="text" [ngClass]="{ 'input-error': formErrors['text'] }" #textarea>
+
       </textarea>
       <div *ngIf="formErrors.text" class="form-error">
         {{ formErrors.text }}
index 27655eca79c018db8f25b71feaece704faa4633a..3e064efcbb67d738ce024bc312312d6485a2b737 100644 (file)
@@ -2,7 +2,7 @@ import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild }
 import { FormBuilder, FormGroup } from '@angular/forms'
 import { NotificationsService } from 'angular2-notifications'
 import { Observable } from 'rxjs/Observable'
-import { VideoCommentCreate } from '../../../../../../shared/models/videos/video-comment.model'
+import { VideoCommentCreate, VideoCommentThreadTree } from '../../../../../../shared/models/videos/video-comment.model'
 import { FormReactive } from '../../../shared'
 import { VIDEO_COMMENT_TEXT } from '../../../shared/forms/form-validators/video-comment'
 import { User } from '../../../shared/users'
@@ -19,6 +19,7 @@ export class VideoCommentAddComponent extends FormReactive implements OnInit {
   @Input() user: User
   @Input() video: Video
   @Input() parentComment: VideoComment
+  @Input() parentComments: VideoComment[]
   @Input() focusOnInit = false
 
   @Output() commentCreated = new EventEmitter<VideoCommentCreate>()
@@ -55,6 +56,17 @@ export class VideoCommentAddComponent extends FormReactive implements OnInit {
     if (this.focusOnInit === true) {
       this.textareaElement.nativeElement.focus()
     }
+
+    if (this.parentComment) {
+      const mentions = this.parentComments
+        .filter(c => c.account.id !== this.user.account.id)
+        .map(c => '@' + c.account.name)
+
+      const mentionsSet = new Set(mentions)
+      const mentionsText = Array.from(mentionsSet).join(' ') + ' '
+
+      this.form.patchValue({ text: mentionsText })
+    }
   }
 
   formValidated () {
index 8edd12124e9d1913233988c482cafe98710444fc..1d325aff9bf2fe4acc2dddb3ac91c8db0ed2f381 100644 (file)
@@ -18,6 +18,7 @@
       [user]="user"
       [video]="video"
       [parentComment]="comment"
+      [parentComments]="newParentComments"
       [focusOnInit]="true"
       (commentCreated)="onCommentReplyCreated($event)"
     ></my-video-comment-add>
@@ -29,6 +30,7 @@
           [video]="video"
           [inReplyToCommentId]="inReplyToCommentId"
           [commentTree]="commentChild"
+          [parentComments]="newParentComments"
           (wantedToReply)="onWantToReply($event)"
           (wantedToDelete)="onWantToDelete($event)"
           (resetReply)="onResetReply()"
index 2ecc8a143a08e5e043f0716cd34fec5a3a06bbd8..38e603d0dc8cdf3bf8b6d0a1702a9485ac53f34f 100644 (file)
@@ -16,6 +16,7 @@ import { VideoComment } from './video-comment.model'
 export class VideoCommentComponent implements OnInit {
   @Input() video: Video
   @Input() comment: VideoComment
+  @Input() parentComments: VideoComment[] = []
   @Input() commentTree: VideoCommentThreadTree
   @Input() inReplyToCommentId: number
 
@@ -25,6 +26,7 @@ export class VideoCommentComponent implements OnInit {
   @Output() resetReply = new EventEmitter()
 
   sanitizedCommentHTML = ''
+  newParentComments = []
 
   constructor (private authService: AuthService) {}
 
@@ -36,6 +38,8 @@ export class VideoCommentComponent implements OnInit {
     this.sanitizedCommentHTML = sanitizeHtml(this.comment.text, {
       allowedTags: [ 'p', 'span' ]
     })
+
+    this.newParentComments = this.parentComments.concat([ this.comment ])
   }
 
   onCommentReplyCreated (createdComment: VideoComment) {
index be122eb2c13c02c9427f4d8e66195988f6084bb0..19ab3b633d4924218ae29a6396de9ed9956a2804 100644 (file)
@@ -6,6 +6,7 @@
   font-size: 15px;
   cursor: pointer;
   margin-left: 56px;
+  margin-bottom: 10px;
 }
 
 .glyphicon, .comment-thread-loading {
index e0ab3188b77921a22d179a83384391644175adf2..71747391242e065e2641516c2ec278bb5469a0ed 100644 (file)
@@ -114,5 +114,6 @@ async function videoChannelController (req: express.Request, res: express.Respon
 async function videoCommentController (req: express.Request, res: express.Response, next: express.NextFunction) {
   const videoComment: VideoCommentModel = res.locals.videoComment
 
-  return res.json(videoComment.toActivityPubObject())
+  const threadParentComments = await VideoCommentModel.listThreadParentComments(videoComment, undefined)
+  return res.json(videoComment.toActivityPubObject(threadParentComments))
 }
index 05f327b29714f31d0b456e8ae00c892a8b2d1a5d..4aa514c150acef8fbcf45325d523b8e353e05c30 100644 (file)
@@ -5,6 +5,7 @@ import { ACTIVITY_PUB } from '../../../initializers'
 import { ActorModel } from '../../../models/activitypub/actor'
 import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
 import { VideoModel } from '../../../models/video/video'
+import { VideoCommentModel } from '../../../models/video/video-comment'
 import { VideoShareModel } from '../../../models/video/video-share'
 import { activitypubHttpJobScheduler, ActivityPubHttpPayload } from '../../jobs/activitypub-http-job-scheduler'
 
@@ -84,6 +85,34 @@ function getOriginVideoAudience (video: VideoModel, actorsInvolvedInVideo: Actor
   }
 }
 
+function getOriginVideoCommentAudience (
+  videoComment: VideoCommentModel,
+  threadParentComments: VideoCommentModel[],
+  actorsInvolvedInVideo: ActorModel[],
+  isOrigin = false
+) {
+  const to = [ ACTIVITY_PUB.PUBLIC ]
+  const cc = [ ]
+
+  // Owner of the video we comment
+  if (isOrigin === false) {
+    cc.push(videoComment.Video.VideoChannel.Account.Actor.url)
+  }
+
+  // Followers of the poster
+  cc.push(videoComment.Account.Actor.followersUrl)
+
+  // Send to actors we reply to
+  for (const parentComment of threadParentComments) {
+    cc.push(parentComment.Account.Actor.url)
+  }
+
+  return {
+    to,
+    cc: cc.concat(actorsInvolvedInVideo.map(a => a.followersUrl))
+  }
+}
+
 function getObjectFollowersAudience (actorsInvolvedInObject: ActorModel[]) {
   return {
     to: actorsInvolvedInObject.map(a => a.followersUrl),
@@ -92,10 +121,10 @@ function getObjectFollowersAudience (actorsInvolvedInObject: ActorModel[]) {
 }
 
 async function getActorsInvolvedInVideo (video: VideoModel, t: Transaction) {
-  const actorsToForwardView = await VideoShareModel.loadActorsByShare(video.id, t)
-  actorsToForwardView.push(video.VideoChannel.Account.Actor)
+  const actors = await VideoShareModel.loadActorsByShare(video.id, t)
+  actors.push(video.VideoChannel.Account.Actor)
 
-  return actorsToForwardView
+  return actors
 }
 
 async function getAudience (actorSender: ActorModel, t: Transaction, isPublic = true) {
@@ -138,5 +167,6 @@ export {
   getActorsInvolvedInVideo,
   getObjectFollowersAudience,
   forwardActivity,
-  audiencify
+  audiencify,
+  getOriginVideoCommentAudience
 }
index 2f5cdc8c5f46c8924b9d4bbe09c476d6d53d46c7..e2ee639d9cea3e378ebbda95d1b3e4081c31138e 100644 (file)
@@ -8,7 +8,8 @@ import { VideoAbuseModel } from '../../../models/video/video-abuse'
 import { VideoCommentModel } from '../../../models/video/video-comment'
 import { getVideoAbuseActivityPubUrl, getVideoDislikeActivityPubUrl, getVideoViewActivityPubUrl } from '../url'
 import {
-  audiencify, broadcastToFollowers, getActorsInvolvedInVideo, getAudience, getObjectFollowersAudience, getOriginVideoAudience,
+  audiencify, broadcastToFollowers, getActorsInvolvedInVideo, getAudience, getObjectFollowersAudience,
+  getOriginVideoAudience, getOriginVideoCommentAudience,
   unicastTo
 } from './misc'
 
@@ -35,11 +36,12 @@ async function sendVideoAbuse (byActor: ActorModel, videoAbuse: VideoAbuseModel,
 
 async function sendCreateVideoCommentToOrigin (comment: VideoCommentModel, t: Transaction) {
   const byActor = comment.Account.Actor
+  const threadParentComments = await VideoCommentModel.listThreadParentComments(comment, t)
+  const commentObject = comment.toActivityPubObject(threadParentComments)
 
   const actorsInvolvedInVideo = await getActorsInvolvedInVideo(comment.Video, t)
-  const audience = getOriginVideoAudience(comment.Video, actorsInvolvedInVideo)
+  const audience = getOriginVideoCommentAudience(comment, threadParentComments, actorsInvolvedInVideo)
 
-  const commentObject = comment.toActivityPubObject()
   const data = await createActivityData(comment.url, byActor, commentObject, t, audience)
 
   return unicastTo(data, byActor, comment.Video.VideoChannel.Account.Actor.sharedInboxUrl, t)
@@ -47,15 +49,15 @@ async function sendCreateVideoCommentToOrigin (comment: VideoCommentModel, t: Tr
 
 async function sendCreateVideoCommentToVideoFollowers (comment: VideoCommentModel, t: Transaction) {
   const byActor = comment.Account.Actor
+  const threadParentComments = await VideoCommentModel.listThreadParentComments(comment, t)
+  const commentObject = comment.toActivityPubObject(threadParentComments)
 
-  const actorsToForwardView = await getActorsInvolvedInVideo(comment.Video, t)
-  const audience = getObjectFollowersAudience(actorsToForwardView)
-
-  const commentObject = comment.toActivityPubObject()
+  const actorsInvolvedInVideo = await getActorsInvolvedInVideo(comment.Video, t)
+  const audience = getOriginVideoCommentAudience(comment, threadParentComments, actorsInvolvedInVideo)
   const data = await createActivityData(comment.url, byActor, commentObject, t, audience)
 
   const followersException = [ byActor ]
-  return broadcastToFollowers(data, byActor, actorsToForwardView, t, followersException)
+  return broadcastToFollowers(data, byActor, actorsInvolvedInVideo, t, followersException)
 }
 
 async function sendCreateViewToOrigin (byActor: ActorModel, video: VideoModel, t: Transaction) {
index 66fca2484484084b4ad3107dbdf9f7a4e84fb83b..dbb2fe42910f9cbffbff7ca66b9769bb5faa5c28 100644 (file)
@@ -3,6 +3,7 @@ import {
   AfterDestroy, AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, IFindOptions, Is, Model, Scopes, Table,
   UpdatedAt
 } from 'sequelize-typescript'
+import { ActivityTagObject } from '../../../shared/models/activitypub/objects/common-objects'
 import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
 import { VideoComment } from '../../../shared/models/videos/video-comment.model'
 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
@@ -270,6 +271,30 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
       })
   }
 
+  static listThreadParentComments (comment: VideoCommentModel, t: Sequelize.Transaction) {
+    const query = {
+      order: [ [ 'createdAt', 'ASC' ] ],
+      where: {
+        [ Sequelize.Op.or ]: [
+          { id: comment.getThreadId() },
+          { originCommentId: comment.getThreadId() }
+        ],
+        id: {
+          [ Sequelize.Op.ne ]: comment.id
+        }
+      },
+      transaction: t
+    }
+
+    return VideoCommentModel
+      .scope([ ScopeNames.WITH_ACCOUNT ])
+      .findAll(query)
+  }
+
+  getThreadId (): number {
+    return this.originCommentId || this.id
+  }
+
   isOwned () {
     return this.Account.isOwned()
   }
@@ -289,7 +314,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
     } as VideoComment
   }
 
-  toActivityPubObject (): VideoCommentObject {
+  toActivityPubObject (threadParentComments: VideoCommentModel[]): VideoCommentObject {
     let inReplyTo: string
     // New thread, so in AS we reply to the video
     if (this.inReplyToCommentId === null) {
@@ -298,6 +323,17 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
       inReplyTo = this.InReplyToVideoComment.url
     }
 
+    const tag: ActivityTagObject[] = []
+    for (const parentComment of threadParentComments) {
+      const actor = parentComment.Account.Actor
+
+      tag.push({
+        type: 'Mention',
+        href: actor.url,
+        name: `@${actor.preferredUsername}@${actor.getHost()}`
+      })
+    }
+
     return {
       type: 'Note' as 'Note',
       id: this.url,
@@ -306,7 +342,8 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
       updated: this.updatedAt.toISOString(),
       published: this.createdAt.toISOString(),
       url: this.url,
-      attributedTo: this.Account.Actor.url
+      attributedTo: this.Account.Actor.url,
+      tag
     }
   }
 }
index ea5a503acfe123de09dabaab83f5916b762db16a..aef728b8240d8e5697ba9258caa5bdc50f0093f5 100644 (file)
@@ -4,7 +4,8 @@ export interface ActivityIdentifierObject {
 }
 
 export interface ActivityTagObject {
-  type: 'Hashtag'
+  type: 'Hashtag' | 'Mention'
+  href?: string
   name: string
 }
 
index 785fbbc0df1e9aa90023d071a0ff50cee78e8900..1c058b86ca3b56726d544a6910f2a360e11fe98b 100644 (file)
@@ -1,3 +1,5 @@
+import { ActivityTagObject } from './common-objects'
+
 export interface VideoCommentObject {
   type: 'Note'
   id: string
@@ -7,4 +9,5 @@ export interface VideoCommentObject {
   updated: string
   url: string
   attributedTo: string
+  tag: ActivityTagObject[]
 }