diff --git a/client/src/app/videos/+video-watch/comment/video-comment-add.component.html b/client/src/app/videos/+video-watch/comment/video-comment-add.component.html index 0eaa0d447b4554df83de46bec175782d0e0b85cb..41d00da085e319c4ca3621edd05ebd43f23e7766 100644 --- a/client/src/app/videos/+video-watch/comment/video-comment-add.component.html +++ b/client/src/app/videos/+video-watch/comment/video-comment-add.component.html @@ -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 }} diff --git a/client/src/app/videos/+video-watch/comment/video-comment-add.component.ts b/client/src/app/videos/+video-watch/comment/video-comment-add.component.ts index 27655eca79c018db8f25b71feaece704faa4633a..3e064efcbb67d738ce024bc312312d6485a2b737 100644 --- a/client/src/app/videos/+video-watch/comment/video-comment-add.component.ts +++ b/client/src/app/videos/+video-watch/comment/video-comment-add.component.ts @@ -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 () { diff --git a/client/src/app/videos/+video-watch/comment/video-comment.component.html b/client/src/app/videos/+video-watch/comment/video-comment.component.html index 8edd12124e9d1913233988c482cafe98710444fc..1d325aff9bf2fe4acc2dddb3ac91c8db0ed2f381 100644 --- a/client/src/app/videos/+video-watch/comment/video-comment.component.html +++ b/client/src/app/videos/+video-watch/comment/video-comment.component.html @@ -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()" diff --git a/client/src/app/videos/+video-watch/comment/video-comment.component.ts b/client/src/app/videos/+video-watch/comment/video-comment.component.ts index 2ecc8a143a08e5e043f0716cd34fec5a3a06bbd8..38e603d0dc8cdf3bf8b6d0a1702a9485ac53f34f 100644 --- a/client/src/app/videos/+video-watch/comment/video-comment.component.ts +++ b/client/src/app/videos/+video-watch/comment/video-comment.component.ts @@ -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) { diff --git a/client/src/app/videos/+video-watch/comment/video-comments.component.scss b/client/src/app/videos/+video-watch/comment/video-comments.component.scss index be122eb2c13c02c9427f4d8e66195988f6084bb0..19ab3b633d4924218ae29a6396de9ed9956a2804 100644 --- a/client/src/app/videos/+video-watch/comment/video-comments.component.scss +++ b/client/src/app/videos/+video-watch/comment/video-comments.component.scss @@ -6,6 +6,7 @@ font-size: 15px; cursor: pointer; margin-left: 56px; + margin-bottom: 10px; } .glyphicon, .comment-thread-loading { diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts index e0ab3188b77921a22d179a83384391644175adf2..71747391242e065e2641516c2ec278bb5469a0ed 100644 --- a/server/controllers/activitypub/client.ts +++ b/server/controllers/activitypub/client.ts @@ -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)) } diff --git a/server/lib/activitypub/send/misc.ts b/server/lib/activitypub/send/misc.ts index 05f327b29714f31d0b456e8ae00c892a8b2d1a5d..4aa514c150acef8fbcf45325d523b8e353e05c30 100644 --- a/server/lib/activitypub/send/misc.ts +++ b/server/lib/activitypub/send/misc.ts @@ -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 } diff --git a/server/lib/activitypub/send/send-create.ts b/server/lib/activitypub/send/send-create.ts index 2f5cdc8c5f46c8924b9d4bbe09c476d6d53d46c7..e2ee639d9cea3e378ebbda95d1b3e4081c31138e 100644 --- a/server/lib/activitypub/send/send-create.ts +++ b/server/lib/activitypub/send/send-create.ts @@ -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) { diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts index 66fca2484484084b4ad3107dbdf9f7a4e84fb83b..dbb2fe42910f9cbffbff7ca66b9769bb5faa5c28 100644 --- a/server/models/video/video-comment.ts +++ b/server/models/video/video-comment.ts @@ -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 } } } diff --git a/shared/models/activitypub/objects/common-objects.ts b/shared/models/activitypub/objects/common-objects.ts index ea5a503acfe123de09dabaab83f5916b762db16a..aef728b8240d8e5697ba9258caa5bdc50f0093f5 100644 --- a/shared/models/activitypub/objects/common-objects.ts +++ b/shared/models/activitypub/objects/common-objects.ts @@ -4,7 +4,8 @@ export interface ActivityIdentifierObject { } export interface ActivityTagObject { - type: 'Hashtag' + type: 'Hashtag' | 'Mention' + href?: string name: string } diff --git a/shared/models/activitypub/objects/video-comment-object.ts b/shared/models/activitypub/objects/video-comment-object.ts index 785fbbc0df1e9aa90023d071a0ff50cee78e8900..1c058b86ca3b56726d544a6910f2a360e11fe98b 100644 --- a/shared/models/activitypub/objects/video-comment-object.ts +++ b/shared/models/activitypub/objects/video-comment-object.ts @@ -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[] }