Add new follow, mention and user registered notifs
authorChocobozzz <me@florianbigard.com>
Fri, 4 Jan 2019 07:56:20 +0000 (08:56 +0100)
committerChocobozzz <chocobozzz@cpy.re>
Wed, 9 Jan 2019 10:15:15 +0000 (11:15 +0100)
25 files changed:
server/controllers/api/users/index.ts
server/controllers/api/users/my-notifications.ts
server/helpers/custom-validators/activitypub/actor.ts
server/helpers/regexp.ts [new file with mode: 0644]
server/initializers/migrations/0315-user-notifications.ts
server/lib/activitypub/process/process-accept.ts
server/lib/activitypub/process/process-follow.ts
server/lib/emailer.ts
server/lib/job-queue/handlers/activitypub-follow.ts
server/lib/notifier.ts
server/lib/user.ts
server/models/account/account-blocklist.ts
server/models/account/user-notification-setting.ts
server/models/account/user-notification.ts
server/models/account/user.ts
server/models/video/video-comment.ts
server/tests/api/check-params/user-notifications.ts
server/tests/api/users/user-notifications.ts
server/tests/helpers/comment-model.ts [new file with mode: 0644]
server/tests/helpers/index.ts
shared/models/users/user-notification-setting.model.ts
shared/models/users/user-notification.model.ts
shared/models/users/user-right.enum.ts
shared/models/users/user-role.ts
shared/utils/users/user-notifications.ts

index 98be46ea2fedbc64e1df983408760613f9022ce5..9e6a019f6350c238533240210f8b143271b628f2 100644 (file)
@@ -40,6 +40,7 @@ import { deleteUserToken } from '../../../lib/oauth-model'
 import { myBlocklistRouter } from './my-blocklist'
 import { myVideosHistoryRouter } from './my-history'
 import { myNotificationsRouter } from './my-notifications'
+import { Notifier } from '../../../lib/notifier'
 
 const auditLogger = auditLoggerFactory('users')
 
@@ -213,6 +214,8 @@ async function registerUser (req: express.Request, res: express.Response) {
     await sendVerifyUserEmail(user)
   }
 
+  Notifier.Instance.notifyOnNewUserRegistration(user)
+
   return res.type('json').status(204).end()
 }
 
index 4b81777a47d27e019622a7ddd137567179c6c1eb..d74d26add62c4ea17817313334b0f835cbbfc90f 100644 (file)
@@ -18,7 +18,7 @@ import {
   markAsReadUserNotificationsValidator,
   updateNotificationSettingsValidator
 } from '../../../middlewares/validators/user-notifications'
-import { UserNotificationSetting, UserNotificationSettingValue } from '../../../../shared/models/users'
+import { UserNotificationSetting } from '../../../../shared/models/users'
 import { UserNotificationSettingModel } from '../../../models/account/user-notification-setting'
 
 const myNotificationsRouter = express.Router()
@@ -53,7 +53,7 @@ export {
 
 async function updateNotificationSettings (req: express.Request, res: express.Response) {
   const user: UserModel = res.locals.oauth.token.User
-  const body: UserNotificationSetting = req.body
+  const body = req.body
 
   const query = {
     where: {
@@ -61,14 +61,19 @@ async function updateNotificationSettings (req: express.Request, res: express.Re
     }
   }
 
-  await UserNotificationSettingModel.update({
+  const values: UserNotificationSetting = {
     newVideoFromSubscription: body.newVideoFromSubscription,
     newCommentOnMyVideo: body.newCommentOnMyVideo,
     videoAbuseAsModerator: body.videoAbuseAsModerator,
     blacklistOnMyVideo: body.blacklistOnMyVideo,
     myVideoPublished: body.myVideoPublished,
-    myVideoImportFinished: body.myVideoImportFinished
-  }, query)
+    myVideoImportFinished: body.myVideoImportFinished,
+    newFollow: body.newFollow,
+    newUserRegistration: body.newUserRegistration,
+    commentMention: body.commentMention,
+  }
+
+  await UserNotificationSettingModel.update(values, query)
 
   return res.status(204).end()
 }
index 77c003cdf36dfa74df151c5e1104e9d6a09704f6..070632a20cbd77ab6d3c7e169460a4608b930b6f 100644 (file)
@@ -27,7 +27,8 @@ function isActorPublicKeyValid (publicKey: string) {
     validator.isLength(publicKey, CONSTRAINTS_FIELDS.ACTORS.PUBLIC_KEY)
 }
 
-const actorNameRegExp = new RegExp('^[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\\-_\.]+$')
+const actorNameAlphabet = '[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\\-_.]'
+const actorNameRegExp = new RegExp(`^${actorNameAlphabet}+$`)
 function isActorPreferredUsernameValid (preferredUsername: string) {
   return exists(preferredUsername) && validator.matches(preferredUsername, actorNameRegExp)
 }
@@ -127,6 +128,7 @@ function areValidActorHandles (handles: string[]) {
 
 export {
   normalizeActor,
+  actorNameAlphabet,
   areValidActorHandles,
   isActorEndpointsObjectValid,
   isActorPublicKeyObjectValid,
diff --git a/server/helpers/regexp.ts b/server/helpers/regexp.ts
new file mode 100644 (file)
index 0000000..2336654
--- /dev/null
@@ -0,0 +1,23 @@
+// Thanks to https://regex101.com
+function regexpCapture (str: string, regex: RegExp, maxIterations = 100) {
+  let m: RegExpExecArray
+  let i = 0
+  let result: RegExpExecArray[] = []
+
+  // tslint:disable:no-conditional-assignment
+  while ((m = regex.exec(str)) !== null && i < maxIterations) {
+    // This is necessary to avoid infinite loops with zero-width matches
+    if (m.index === regex.lastIndex) {
+      regex.lastIndex++
+    }
+
+    result.push(m)
+    i++
+  }
+
+  return result
+}
+
+export {
+  regexpCapture
+}
index 8c54c5d6c9e5d48067b314f05188ed3f36c90d82..34f9fd193abf987cfbaa561c93875b539111da87 100644 (file)
@@ -15,6 +15,9 @@ CREATE TABLE IF NOT EXISTS "userNotificationSetting" ("id" SERIAL,
 "blacklistOnMyVideo" INTEGER NOT NULL DEFAULT NULL,
 "myVideoPublished" INTEGER NOT NULL DEFAULT NULL,
 "myVideoImportFinished" INTEGER NOT NULL DEFAULT NULL,
+"newUserRegistration" INTEGER NOT NULL DEFAULT NULL,
+"newFollow" INTEGER NOT NULL DEFAULT NULL,
+"commentMention" INTEGER NOT NULL DEFAULT NULL,
 "userId" INTEGER REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
 "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL,
 "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
@@ -26,8 +29,9 @@ PRIMARY KEY ("id"))
   {
     const query = 'INSERT INTO "userNotificationSetting" ' +
       '("newVideoFromSubscription", "newCommentOnMyVideo", "videoAbuseAsModerator", "blacklistOnMyVideo", ' +
-      '"myVideoPublished", "myVideoImportFinished", "userId", "createdAt", "updatedAt") ' +
-      '(SELECT 2, 2, 4, 4, 2, 2, id, NOW(), NOW() FROM "user")'
+      '"myVideoPublished", "myVideoImportFinished", "newUserRegistration", "newFollow", "commentMention", ' +
+      '"userId", "createdAt", "updatedAt") ' +
+      '(SELECT 2, 2, 4, 4, 2, 2, 2, 2, 2, id, NOW(), NOW() FROM "user")'
 
     await utils.sequelize.query(query)
   }
index 89bda9c32a531c4d49472702e59801ccae403582..605705ad3a1e6cca830319cb7cd9dd60d7f580b7 100644 (file)
@@ -2,6 +2,7 @@ import { ActivityAccept } from '../../../../shared/models/activitypub'
 import { ActorModel } from '../../../models/activitypub/actor'
 import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
 import { addFetchOutboxJob } from '../actor'
+import { Notifier } from '../../notifier'
 
 async function processAcceptActivity (activity: ActivityAccept, targetActor: ActorModel, inboxActor?: ActorModel) {
   if (inboxActor === undefined) throw new Error('Need to accept on explicit inbox.')
@@ -24,6 +25,7 @@ async function processAccept (actor: ActorModel, targetActor: ActorModel) {
   if (follow.state !== 'accepted') {
     follow.set('state', 'accepted')
     await follow.save()
+
     await addFetchOutboxJob(targetActor)
   }
 }
index 24c9085f7ac77520a537e713e8c09a336518341c..a678924403fa41e56d7dfa0dc42d8d865d2ace52 100644 (file)
@@ -5,6 +5,7 @@ import { sequelizeTypescript } from '../../../initializers'
 import { ActorModel } from '../../../models/activitypub/actor'
 import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
 import { sendAccept } from '../send'
+import { Notifier } from '../../notifier'
 
 async function processFollowActivity (activity: ActivityFollow, byActor: ActorModel) {
   const activityObject = activity.object
@@ -21,13 +22,13 @@ export {
 // ---------------------------------------------------------------------------
 
 async function processFollow (actor: ActorModel, targetActorURL: string) {
-  await sequelizeTypescript.transaction(async t => {
+  const { actorFollow, created } = await sequelizeTypescript.transaction(async t => {
     const targetActor = await ActorModel.loadByUrlAndPopulateAccountAndChannel(targetActorURL, t)
 
     if (!targetActor) throw new Error('Unknown actor')
     if (targetActor.isOwned() === false) throw new Error('This is not a local actor.')
 
-    const [ actorFollow ] = await ActorFollowModel.findOrCreate({
+    const [ actorFollow, created ] = await ActorFollowModel.findOrCreate({
       where: {
         actorId: actor.id,
         targetActorId: targetActor.id
@@ -52,8 +53,12 @@ async function processFollow (actor: ActorModel, targetActorURL: string) {
     actorFollow.ActorFollowing = targetActor
 
     // Target sends to actor he accepted the follow request
-    return sendAccept(actorFollow)
+    await sendAccept(actorFollow)
+
+    return { actorFollow, created }
   })
 
+  if (created) Notifier.Instance.notifyOfNewFollow(actorFollow)
+
   logger.info('Actor %s is followed by actor %s.', targetActorURL, actor.url)
 }
index 6dc8f2adf094c6799c00b585875249e136f50955..3429498e7d5a9d070d26ce016c5093e39ae86958 100644 (file)
@@ -11,6 +11,7 @@ import { VideoCommentModel } from '../models/video/video-comment'
 import { VideoAbuseModel } from '../models/video/video-abuse'
 import { VideoBlacklistModel } from '../models/video/video-blacklist'
 import { VideoImportModel } from '../models/video/video-import'
+import { ActorFollowModel } from '../models/activitypub/actor-follow'
 
 class Emailer {
 
@@ -103,6 +104,25 @@ class Emailer {
     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
   }
 
+  addNewFollowNotification (to: string[], actorFollow: ActorFollowModel, followType: 'account' | 'channel') {
+    const followerName = actorFollow.ActorFollower.Account.getDisplayName()
+    const followingName = (actorFollow.ActorFollowing.VideoChannel || actorFollow.ActorFollowing.Account).getDisplayName()
+
+    const text = `Hi dear user,\n\n` +
+      `Your ${followType} ${followingName} has a new subscriber: ${followerName}` +
+      `\n\n` +
+      `Cheers,\n` +
+      `PeerTube.`
+
+    const emailPayload: EmailPayload = {
+      to,
+      subject: 'New follower on your channel ' + followingName,
+      text
+    }
+
+    return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
+  }
+
   myVideoPublishedNotification (to: string[], video: VideoModel) {
     const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath()
 
@@ -185,7 +205,29 @@ class Emailer {
     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
   }
 
-  async addVideoAbuseModeratorsNotification (to: string[], videoAbuse: VideoAbuseModel) {
+  addNewCommentMentionNotification (to: string[], comment: VideoCommentModel) {
+    const accountName = comment.Account.getDisplayName()
+    const video = comment.Video
+    const commentUrl = CONFIG.WEBSERVER.URL + comment.getCommentStaticPath()
+
+    const text = `Hi dear user,\n\n` +
+      `${accountName} mentioned you on video ${video.name}` +
+      `\n\n` +
+      `You can view the comment on ${commentUrl} ` +
+      `\n\n` +
+      `Cheers,\n` +
+      `PeerTube.`
+
+    const emailPayload: EmailPayload = {
+      to,
+      subject: 'Mention on video ' + video.name,
+      text
+    }
+
+    return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
+  }
+
+  addVideoAbuseModeratorsNotification (to: string[], videoAbuse: VideoAbuseModel) {
     const videoUrl = CONFIG.WEBSERVER.URL + videoAbuse.Video.getWatchStaticPath()
 
     const text = `Hi,\n\n` +
@@ -202,7 +244,22 @@ class Emailer {
     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
   }
 
-  async addVideoBlacklistNotification (to: string[], videoBlacklist: VideoBlacklistModel) {
+  addNewUserRegistrationNotification (to: string[], user: UserModel) {
+    const text = `Hi,\n\n` +
+      `User ${user.username} just registered on ${CONFIG.WEBSERVER.HOST} PeerTube instance.\n\n` +
+      `Cheers,\n` +
+      `PeerTube.`
+
+    const emailPayload: EmailPayload = {
+      to,
+      subject: '[PeerTube] New user registration on ' + CONFIG.WEBSERVER.HOST,
+      text
+    }
+
+    return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
+  }
+
+  addVideoBlacklistNotification (to: string[], videoBlacklist: VideoBlacklistModel) {
     const videoName = videoBlacklist.Video.name
     const videoUrl = CONFIG.WEBSERVER.URL + videoBlacklist.Video.getWatchStaticPath()
 
@@ -224,7 +281,7 @@ class Emailer {
     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
   }
 
-  async addVideoUnblacklistNotification (to: string[], video: VideoModel) {
+  addVideoUnblacklistNotification (to: string[], video: VideoModel) {
     const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath()
 
     const text = 'Hi,\n\n' +
index 36d0f237bd03dd6d54cde757feefdb9ceb182c1b..b4d381062564d5ba4b59178cedb26d8ad670f301 100644 (file)
@@ -8,6 +8,7 @@ import { getOrCreateActorAndServerAndModel } from '../../activitypub/actor'
 import { retryTransactionWrapper } from '../../../helpers/database-utils'
 import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
 import { ActorModel } from '../../../models/activitypub/actor'
+import { Notifier } from '../../notifier'
 
 export type ActivitypubFollowPayload = {
   followerActorId: number
@@ -42,7 +43,7 @@ export {
 
 // ---------------------------------------------------------------------------
 
-function follow (fromActor: ActorModel, targetActor: ActorModel) {
+async function follow (fromActor: ActorModel, targetActor: ActorModel) {
   if (fromActor.id === targetActor.id) {
     throw new Error('Follower is the same than target actor.')
   }
@@ -50,7 +51,7 @@ function follow (fromActor: ActorModel, targetActor: ActorModel) {
   // Same server, direct accept
   const state = !fromActor.serverId && !targetActor.serverId ? 'accepted' : 'pending'
 
-  return sequelizeTypescript.transaction(async t => {
+  const actorFollow = await sequelizeTypescript.transaction(async t => {
     const [ actorFollow ] = await ActorFollowModel.findOrCreate({
       where: {
         actorId: fromActor.id,
@@ -68,5 +69,9 @@ function follow (fromActor: ActorModel, targetActor: ActorModel) {
 
     // Send a notification to remote server if our follow is not already accepted
     if (actorFollow.state !== 'accepted') await sendFollow(actorFollow)
+
+    return actorFollow
   })
+
+  if (actorFollow.state === 'accepted') Notifier.Instance.notifyOfNewFollow(actorFollow)
 }
index 11b0937e9c72fb30a26ce3e41b13240a699a2fae..2c51d71018e4b17f2a0507fa2bf5a6b746e5a591 100644 (file)
@@ -13,6 +13,8 @@ import { VideoBlacklistModel } from '../models/video/video-blacklist'
 import * as Bluebird from 'bluebird'
 import { VideoImportModel } from '../models/video/video-import'
 import { AccountBlocklistModel } from '../models/account/account-blocklist'
+import { ActorFollowModel } from '../models/activitypub/actor-follow'
+import { AccountModel } from '../models/account/account'
 
 class Notifier {
 
@@ -38,7 +40,10 @@ class Notifier {
 
   notifyOnNewComment (comment: VideoCommentModel): void {
     this.notifyVideoOwnerOfNewComment(comment)
-        .catch(err => logger.error('Cannot notify of new comment %s.', comment.url, { err }))
+        .catch(err => logger.error('Cannot notify video owner of new comment %s.', comment.url, { err }))
+
+    this.notifyOfCommentMention(comment)
+        .catch(err => logger.error('Cannot notify mentions of comment %s.', comment.url, { err }))
   }
 
   notifyOnNewVideoAbuse (videoAbuse: VideoAbuseModel): void {
@@ -61,6 +66,23 @@ class Notifier {
       .catch(err => logger.error('Cannot notify owner that its video import %s is finished.', videoImport.getTargetIdentifier(), { err }))
   }
 
+  notifyOnNewUserRegistration (user: UserModel): void {
+    this.notifyModeratorsOfNewUserRegistration(user)
+        .catch(err => logger.error('Cannot notify moderators of new user registration (%s).', user.username, { err }))
+  }
+
+  notifyOfNewFollow (actorFollow: ActorFollowModel): void {
+    this.notifyUserOfNewActorFollow(actorFollow)
+      .catch(err => {
+        logger.error(
+          'Cannot notify owner of channel %s of a new follow by %s.',
+          actorFollow.ActorFollowing.VideoChannel.getDisplayName(),
+          actorFollow.ActorFollower.Account.getDisplayName(),
+          err
+        )
+      })
+  }
+
   private async notifySubscribersOfNewVideo (video: VideoModel) {
     // List all followers that are users
     const users = await UserModel.listUserSubscribersOf(video.VideoChannel.actorId)
@@ -90,6 +112,8 @@ class Notifier {
   }
 
   private async notifyVideoOwnerOfNewComment (comment: VideoCommentModel) {
+    if (comment.Video.isOwned() === false) return
+
     const user = await UserModel.loadByVideoId(comment.videoId)
 
     // Not our user or user comments its own video
@@ -122,11 +146,100 @@ class Notifier {
     return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
   }
 
-  private async notifyModeratorsOfNewVideoAbuse (videoAbuse: VideoAbuseModel) {
-    const users = await UserModel.listWithRight(UserRight.MANAGE_VIDEO_ABUSES)
+  private async notifyOfCommentMention (comment: VideoCommentModel) {
+    const usernames = comment.extractMentions()
+    let users = await UserModel.listByUsernames(usernames)
+
+    if (comment.Video.isOwned()) {
+      const userException = await UserModel.loadByVideoId(comment.videoId)
+      users = users.filter(u => u.id !== userException.id)
+    }
+
+    // Don't notify if I mentioned myself
+    users = users.filter(u => u.Account.id !== comment.accountId)
+
     if (users.length === 0) return
 
-    logger.info('Notifying %s user/moderators of new video abuse %s.', users.length, videoAbuse.Video.url)
+    const accountMutedHash = await AccountBlocklistModel.isAccountMutedByMulti(users.map(u => u.Account.id), comment.accountId)
+
+    logger.info('Notifying %d users of new comment %s.', users.length, comment.url)
+
+    function settingGetter (user: UserModel) {
+      if (accountMutedHash[user.Account.id] === true) return UserNotificationSettingValue.NONE
+
+      return user.NotificationSetting.commentMention
+    }
+
+    async function notificationCreator (user: UserModel) {
+      const notification = await UserNotificationModel.create({
+        type: UserNotificationType.COMMENT_MENTION,
+        userId: user.id,
+        commentId: comment.id
+      })
+      notification.Comment = comment
+
+      return notification
+    }
+
+    function emailSender (emails: string[]) {
+      return Emailer.Instance.addNewCommentMentionNotification(emails, comment)
+    }
+
+    return this.notify({ users, settingGetter, notificationCreator, emailSender })
+  }
+
+  private async notifyUserOfNewActorFollow (actorFollow: ActorFollowModel) {
+    if (actorFollow.ActorFollowing.isOwned() === false) return
+
+    // Account follows one of our account?
+    let followType: 'account' | 'channel' = 'channel'
+    let user = await UserModel.loadByChannelActorId(actorFollow.ActorFollowing.id)
+
+    // Account follows one of our channel?
+    if (!user) {
+      user = await UserModel.loadByAccountActorId(actorFollow.ActorFollowing.id)
+      followType = 'account'
+    }
+
+    if (!user) return
+
+    if (!actorFollow.ActorFollower.Account || !actorFollow.ActorFollower.Account.name) {
+      actorFollow.ActorFollower.Account = await actorFollow.ActorFollower.$get('Account') as AccountModel
+    }
+    const followerAccount = actorFollow.ActorFollower.Account
+
+    const accountMuted = await AccountBlocklistModel.isAccountMutedBy(user.Account.id, followerAccount.id)
+    if (accountMuted) return
+
+    logger.info('Notifying user %s of new follower: %s.', user.username, followerAccount.getDisplayName())
+
+    function settingGetter (user: UserModel) {
+      return user.NotificationSetting.newFollow
+    }
+
+    async function notificationCreator (user: UserModel) {
+      const notification = await UserNotificationModel.create({
+        type: UserNotificationType.NEW_FOLLOW,
+        userId: user.id,
+        actorFollowId: actorFollow.id
+      })
+      notification.ActorFollow = actorFollow
+
+      return notification
+    }
+
+    function emailSender (emails: string[]) {
+      return Emailer.Instance.addNewFollowNotification(emails, actorFollow, followType)
+    }
+
+    return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
+  }
+
+  private async notifyModeratorsOfNewVideoAbuse (videoAbuse: VideoAbuseModel) {
+    const moderators = await UserModel.listWithRight(UserRight.MANAGE_VIDEO_ABUSES)
+    if (moderators.length === 0) return
+
+    logger.info('Notifying %s user/moderators of new video abuse %s.', moderators.length, videoAbuse.Video.url)
 
     function settingGetter (user: UserModel) {
       return user.NotificationSetting.videoAbuseAsModerator
@@ -147,7 +260,7 @@ class Notifier {
       return Emailer.Instance.addVideoAbuseModeratorsNotification(emails, videoAbuse)
     }
 
-    return this.notify({ users, settingGetter, notificationCreator, emailSender })
+    return this.notify({ users: moderators, settingGetter, notificationCreator, emailSender })
   }
 
   private async notifyVideoOwnerOfBlacklist (videoBlacklist: VideoBlacklistModel) {
@@ -264,6 +377,37 @@ class Notifier {
     return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
   }
 
+  private async notifyModeratorsOfNewUserRegistration (registeredUser: UserModel) {
+    const moderators = await UserModel.listWithRight(UserRight.MANAGE_USERS)
+    if (moderators.length === 0) return
+
+    logger.info(
+      'Notifying %s moderators of new user registration of %s.',
+      moderators.length, registeredUser.Account.Actor.preferredUsername
+    )
+
+    function settingGetter (user: UserModel) {
+      return user.NotificationSetting.newUserRegistration
+    }
+
+    async function notificationCreator (user: UserModel) {
+      const notification = await UserNotificationModel.create({
+        type: UserNotificationType.NEW_USER_REGISTRATION,
+        userId: user.id,
+        accountId: registeredUser.Account.id
+      })
+      notification.Account = registeredUser.Account
+
+      return notification
+    }
+
+    function emailSender (emails: string[]) {
+      return Emailer.Instance.addNewUserRegistrationNotification(emails, registeredUser)
+    }
+
+    return this.notify({ users: moderators, settingGetter, notificationCreator, emailSender })
+  }
+
   private async notify (options: {
     users: UserModel[],
     notificationCreator: (user: UserModel) => Promise<UserNotificationModel>,
index 481571828265c05dee3170a0115968d9839e6ce6..9e24e85a0826fb1016f32b272028c45d0cadb4c1 100644 (file)
@@ -10,7 +10,7 @@ import { VideoChannelModel } from '../models/video/video-channel'
 import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model'
 import { ActorModel } from '../models/activitypub/actor'
 import { UserNotificationSettingModel } from '../models/account/user-notification-setting'
-import { UserNotificationSettingValue } from '../../shared/models/users'
+import { UserNotificationSetting, UserNotificationSettingValue } from '../../shared/models/users'
 
 async function createUserAccountAndChannel (userToCreate: UserModel, validateUser = true) {
   const { user, account, videoChannel } = await sequelizeTypescript.transaction(async t => {
@@ -96,13 +96,18 @@ export {
 // ---------------------------------------------------------------------------
 
 function createDefaultUserNotificationSettings (user: UserModel, t: Sequelize.Transaction | undefined) {
-  return UserNotificationSettingModel.create({
+  const values: UserNotificationSetting & { userId: number } = {
     userId: user.id,
     newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION,
     newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION,
     myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION,
     myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION,
     videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
-    blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL
-  }, { transaction: t })
+    blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
+    newUserRegistration: UserNotificationSettingValue.WEB_NOTIFICATION,
+    commentMention: UserNotificationSettingValue.WEB_NOTIFICATION,
+    newFollow: UserNotificationSettingValue.WEB_NOTIFICATION
+  }
+
+  return UserNotificationSettingModel.create(values, { transaction: t })
 }
index 54ac290c4b69eb13873a9bf8e86d1864afde604b..efd6ed59e5a3c1a0b33b713fd51bf44818bd0fab 100644 (file)
@@ -2,6 +2,7 @@ import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, Updated
 import { AccountModel } from './account'
 import { getSort } from '../utils'
 import { AccountBlock } from '../../../shared/models/blocklist'
+import { Op } from 'sequelize'
 
 enum ScopeNames {
   WITH_ACCOUNTS = 'WITH_ACCOUNTS'
@@ -73,18 +74,33 @@ export class AccountBlocklistModel extends Model<AccountBlocklistModel> {
   BlockedAccount: AccountModel
 
   static isAccountMutedBy (accountId: number, targetAccountId: number) {
+    return AccountBlocklistModel.isAccountMutedByMulti([ accountId ], targetAccountId)
+      .then(result => result[accountId])
+  }
+
+  static isAccountMutedByMulti (accountIds: number[], targetAccountId: number) {
     const query = {
-      attributes: [ 'id' ],
+      attributes: [ 'accountId', 'id' ],
       where: {
-        accountId,
+        accountId: {
+          [Op.any]: accountIds
+        },
         targetAccountId
       },
       raw: true
     }
 
     return AccountBlocklistModel.unscoped()
-                                .findOne(query)
-                                .then(a => !!a)
+                                .findAll(query)
+                                .then(rows => {
+                                  const result: { [accountId: number]: boolean } = {}
+
+                                  for (const accountId of accountIds) {
+                                    result[accountId] = !!rows.find(r => r.accountId === accountId)
+                                  }
+
+                                  return result
+                                })
   }
 
   static loadByAccountAndTarget (accountId: number, targetAccountId: number) {
index 6470defa75c927319dc203e0b9ecb95e02e67c59..f1c3ac223e6be4e5355714b7b3848a254037b5d2 100644 (file)
@@ -83,6 +83,33 @@ export class UserNotificationSettingModel extends Model<UserNotificationSettingM
   @Column
   myVideoImportFinished: UserNotificationSettingValue
 
+  @AllowNull(false)
+  @Default(null)
+  @Is(
+    'UserNotificationSettingNewUserRegistration',
+    value => throwIfNotValid(value, isUserNotificationSettingValid, 'newUserRegistration')
+  )
+  @Column
+  newUserRegistration: UserNotificationSettingValue
+
+  @AllowNull(false)
+  @Default(null)
+  @Is(
+    'UserNotificationSettingNewFollow',
+    value => throwIfNotValid(value, isUserNotificationSettingValid, 'newFollow')
+  )
+  @Column
+  newFollow: UserNotificationSettingValue
+
+  @AllowNull(false)
+  @Default(null)
+  @Is(
+    'UserNotificationSettingCommentMention',
+    value => throwIfNotValid(value, isUserNotificationSettingValid, 'commentMention')
+  )
+  @Column
+  commentMention: UserNotificationSettingValue
+
   @ForeignKey(() => UserModel)
   @Column
   userId: number
@@ -114,7 +141,10 @@ export class UserNotificationSettingModel extends Model<UserNotificationSettingM
       videoAbuseAsModerator: this.videoAbuseAsModerator,
       blacklistOnMyVideo: this.blacklistOnMyVideo,
       myVideoPublished: this.myVideoPublished,
-      myVideoImportFinished: this.myVideoImportFinished
+      myVideoImportFinished: this.myVideoImportFinished,
+      newUserRegistration: this.newUserRegistration,
+      commentMention: this.commentMention,
+      newFollow: this.newFollow
     }
   }
 }
index 25124437418e4f4ddbe52bc56a199861ecee9c65..79afce600ddd6a2cc7b3a7bd207c60ffa465ed2b 100644 (file)
@@ -25,6 +25,8 @@ import { AccountModel } from './account'
 import { VideoAbuseModel } from '../video/video-abuse'
 import { VideoBlacklistModel } from '../video/video-blacklist'
 import { VideoImportModel } from '../video/video-import'
+import { ActorModel } from '../activitypub/actor'
+import { ActorFollowModel } from '../activitypub/actor-follow'
 
 enum ScopeNames {
   WITH_ALL = 'WITH_ALL'
@@ -38,17 +40,17 @@ function buildVideoInclude (required: boolean) {
   }
 }
 
-function buildChannelInclude () {
+function buildChannelInclude (required: boolean) {
   return {
-    required: true,
+    required,
     attributes: [ 'id', 'name' ],
     model: () => VideoChannelModel.unscoped()
   }
 }
 
-function buildAccountInclude () {
+function buildAccountInclude (required: boolean) {
   return {
-    required: true,
+    required,
     attributes: [ 'id', 'name' ],
     model: () => AccountModel.unscoped()
   }
@@ -58,14 +60,14 @@ function buildAccountInclude () {
   [ScopeNames.WITH_ALL]: {
     include: [
       Object.assign(buildVideoInclude(false), {
-        include: [ buildChannelInclude() ]
+        include: [ buildChannelInclude(true) ]
       }),
       {
         attributes: [ 'id', 'originCommentId' ],
         model: () => VideoCommentModel.unscoped(),
         required: false,
         include: [
-          buildAccountInclude(),
+          buildAccountInclude(true),
           buildVideoInclude(true)
         ]
       },
@@ -86,6 +88,42 @@ function buildAccountInclude () {
         model: () => VideoImportModel.unscoped(),
         required: false,
         include: [ buildVideoInclude(false) ]
+      },
+      {
+        attributes: [ 'id', 'name' ],
+        model: () => AccountModel.unscoped(),
+        required: false,
+        include: [
+          {
+            attributes: [ 'id', 'preferredUsername' ],
+            model: () => ActorModel.unscoped(),
+            required: true
+          }
+        ]
+      },
+      {
+        attributes: [ 'id' ],
+        model: () => ActorFollowModel.unscoped(),
+        required: false,
+        include: [
+          {
+            attributes: [ 'preferredUsername' ],
+            model: () => ActorModel.unscoped(),
+            required: true,
+            as: 'ActorFollower',
+            include: [ buildAccountInclude(true) ]
+          },
+          {
+            attributes: [ 'preferredUsername' ],
+            model: () => ActorModel.unscoped(),
+            required: true,
+            as: 'ActorFollowing',
+            include: [
+              buildChannelInclude(false),
+              buildAccountInclude(false)
+            ]
+          }
+        ]
       }
     ]
   }
@@ -193,6 +231,30 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
   })
   VideoImport: VideoImportModel
 
+  @ForeignKey(() => AccountModel)
+  @Column
+  accountId: number
+
+  @BelongsTo(() => AccountModel, {
+    foreignKey: {
+      allowNull: true
+    },
+    onDelete: 'cascade'
+  })
+  Account: AccountModel
+
+  @ForeignKey(() => ActorFollowModel)
+  @Column
+  actorFollowId: number
+
+  @BelongsTo(() => ActorFollowModel, {
+    foreignKey: {
+      allowNull: true
+    },
+    onDelete: 'cascade'
+  })
+  ActorFollow: ActorFollowModel
+
   static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) {
     const query: IFindOptions<UserNotificationModel> = {
       offset: start,
@@ -264,6 +326,25 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
       video: this.formatVideo(this.VideoBlacklist.Video)
     } : undefined
 
+    const account = this.Account ? {
+      id: this.Account.id,
+      displayName: this.Account.getDisplayName(),
+      name: this.Account.Actor.preferredUsername
+    } : undefined
+
+    const actorFollow = this.ActorFollow ? {
+      id: this.ActorFollow.id,
+      follower: {
+        displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(),
+        name: this.ActorFollow.ActorFollower.preferredUsername
+      },
+      following: {
+        type: this.ActorFollow.ActorFollowing.VideoChannel ? 'channel' as 'channel' : 'account' as 'account',
+        displayName: (this.ActorFollow.ActorFollowing.VideoChannel || this.ActorFollow.ActorFollowing.Account).getDisplayName(),
+        name: this.ActorFollow.ActorFollowing.preferredUsername
+      }
+    } : undefined
+
     return {
       id: this.id,
       type: this.type,
@@ -273,6 +354,8 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
       comment,
       videoAbuse,
       videoBlacklist,
+      account,
+      actorFollow,
       createdAt: this.createdAt.toISOString(),
       updatedAt: this.updatedAt.toISOString()
     }
index 33f56f64193b5eecfca16b4892edb30d2ca13f2b..017a96657b0a0c6289b13cca65860000848e735a 100644 (file)
@@ -330,6 +330,16 @@ export class UserModel extends Model<UserModel> {
     return UserModel.unscoped().findAll(query)
   }
 
+  static listByUsernames (usernames: string[]) {
+    const query = {
+      where: {
+        username: usernames
+      }
+    }
+
+    return UserModel.findAll(query)
+  }
+
   static loadById (id: number) {
     return UserModel.findById(id)
   }
@@ -424,6 +434,47 @@ export class UserModel extends Model<UserModel> {
     return UserModel.findOne(query)
   }
 
+  static loadByChannelActorId (videoChannelActorId: number) {
+    const query = {
+      include: [
+        {
+          required: true,
+          attributes: [ 'id' ],
+          model: AccountModel.unscoped(),
+          include: [
+            {
+              required: true,
+              attributes: [ 'id' ],
+              model: VideoChannelModel.unscoped(),
+              where: {
+                actorId: videoChannelActorId
+              }
+            }
+          ]
+        }
+      ]
+    }
+
+    return UserModel.findOne(query)
+  }
+
+  static loadByAccountActorId (accountActorId: number) {
+    const query = {
+      include: [
+        {
+          required: true,
+          attributes: [ 'id' ],
+          model: AccountModel.unscoped(),
+          where: {
+            actorId: accountActorId
+          }
+        }
+      ]
+    }
+
+    return UserModel.findOne(query)
+  }
+
   static getOriginalVideoFileTotalFromUser (user: UserModel) {
     // Don't use sequelize because we need to use a sub query
     const query = UserModel.generateUserQuotaBaseSQL()
index d8fc2a564986f6c07674cf3315b7da81275a0cab..cf6278da77399a423f18040cee265f25b294ffb3 100644 (file)
@@ -18,7 +18,7 @@ import { ActivityTagObject } from '../../../shared/models/activitypub/objects/co
 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'
-import { CONSTRAINTS_FIELDS } from '../../initializers'
+import { CONFIG, CONSTRAINTS_FIELDS } from '../../initializers'
 import { sendDeleteVideoComment } from '../../lib/activitypub/send'
 import { AccountModel } from '../account/account'
 import { ActorModel } from '../activitypub/actor'
@@ -29,6 +29,9 @@ import { VideoModel } from './video'
 import { VideoChannelModel } from './video-channel'
 import { getServerActor } from '../../helpers/utils'
 import { UserModel } from '../account/user'
+import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor'
+import { regexpCapture } from '../../helpers/regexp'
+import { uniq } from 'lodash'
 
 enum ScopeNames {
   WITH_ACCOUNT = 'WITH_ACCOUNT',
@@ -370,9 +373,11 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
         id: {
           [ Sequelize.Op.in ]: Sequelize.literal('(' +
             'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
-            'SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ' + comment.id + ' UNION ' +
-            'SELECT p.id, p."inReplyToCommentId" from "videoComment" p ' +
-            'INNER JOIN children c ON c."inReplyToCommentId" = p.id) ' +
+              `SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` +
+              'UNION ' +
+              'SELECT "parent"."id", "parent"."inReplyToCommentId" FROM "videoComment" "parent" ' +
+              'INNER JOIN "children" ON "children"."inReplyToCommentId" = "parent"."id"' +
+            ') ' +
             'SELECT id FROM children' +
           ')'),
           [ Sequelize.Op.ne ]: comment.id
@@ -460,6 +465,34 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
     return this.Account.isOwned()
   }
 
+  extractMentions () {
+    if (!this.text) return []
+
+    const localMention = `@(${actorNameAlphabet}+)`
+    const remoteMention = `${localMention}@${CONFIG.WEBSERVER.HOST}`
+
+    const remoteMentionsRegex = new RegExp(' ' + remoteMention + ' ', 'g')
+    const localMentionsRegex = new RegExp(' ' + localMention + ' ', 'g')
+    const firstMentionRegex = new RegExp('^(?:(?:' + remoteMention + ')|(?:' + localMention + ')) ', 'g')
+    const endMentionRegex = new RegExp(' (?:(?:' + remoteMention + ')|(?:' + localMention + '))$', 'g')
+
+    return uniq(
+      [].concat(
+        regexpCapture(this.text, remoteMentionsRegex)
+          .map(([ , username ]) => username),
+
+        regexpCapture(this.text, localMentionsRegex)
+          .map(([ , username ]) => username),
+
+        regexpCapture(this.text, firstMentionRegex)
+          .map(([ , username1, username2 ]) => username1 || username2),
+
+        regexpCapture(this.text, endMentionRegex)
+          .map(([ , username1, username2 ]) => username1 || username2)
+      )
+    )
+  }
+
   toFormattedJSON () {
     return {
       id: this.id,
index 4f21f7b9520807a5479912082fb81d85317ef59c..635f5c9a3650ca62e779f0679e31a775e42c8fa1 100644 (file)
@@ -139,7 +139,10 @@ describe('Test user notifications API validators', function () {
       videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION,
       blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION,
       myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION,
-      myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION
+      myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION,
+      commentMention: UserNotificationSettingValue.WEB_NOTIFICATION,
+      newFollow: UserNotificationSettingValue.WEB_NOTIFICATION,
+      newUserRegistration: UserNotificationSettingValue.WEB_NOTIFICATION
     }
 
     it('Should fail with missing fields', async function () {
index e4966dbf570e4f5d7e87e32efd33393103640c67..ae77b4db221bd3087235b562787e2702e82f8012 100644 (file)
@@ -10,9 +10,12 @@ import {
   flushTests,
   getMyUserInformation,
   immutableAssign,
+  registerUser,
   removeVideoFromBlacklist,
   reportVideoAbuse,
+  updateMyUser,
   updateVideo,
+  updateVideoChannel,
   userLogin,
   wait
 } from '../../../../shared/utils'
@@ -21,16 +24,20 @@ import { setAccessTokensToServers } from '../../../../shared/utils/users/login'
 import { waitJobs } from '../../../../shared/utils/server/jobs'
 import { getUserNotificationSocket } from '../../../../shared/utils/socket/socket-io'
 import {
+  checkCommentMention,
   CheckerBaseParams,
+  checkMyVideoImportIsFinished,
+  checkNewActorFollow,
   checkNewBlacklistOnMyVideo,
   checkNewCommentOnMyVideo,
   checkNewVideoAbuseForModerators,
   checkNewVideoFromSubscription,
+  checkUserRegistered,
+  checkVideoIsPublished,
   getLastNotification,
   getUserNotifications,
   markAsReadNotifications,
-  updateMyNotificationSettings,
-  checkVideoIsPublished, checkMyVideoImportIsFinished
+  updateMyNotificationSettings
 } from '../../../../shared/utils/users/user-notifications'
 import {
   User,
@@ -40,9 +47,9 @@ import {
   UserNotificationType
 } from '../../../../shared/models/users'
 import { MockSmtpServer } from '../../../../shared/utils/miscs/email'
-import { addUserSubscription } from '../../../../shared/utils/users/user-subscriptions'
+import { addUserSubscription, removeUserSubscription } from '../../../../shared/utils/users/user-subscriptions'
 import { VideoPrivacy } from '../../../../shared/models/videos'
-import { getYoutubeVideoUrl, importVideo, getBadVideoUrl } from '../../../../shared/utils/videos/video-imports'
+import { getBadVideoUrl, getYoutubeVideoUrl, importVideo } from '../../../../shared/utils/videos/video-imports'
 import { addVideoCommentReply, addVideoCommentThread } from '../../../../shared/utils/videos/video-comments'
 import * as uuidv4 from 'uuid/v4'
 import { addAccountToAccountBlocklist, removeAccountFromAccountBlocklist } from '../../../../shared/utils/users/blocklist'
@@ -81,12 +88,15 @@ describe('Test users notifications', function () {
   let channelId: number
 
   const allNotificationSettings: UserNotificationSetting = {
-    myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
-    myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
-    newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
     newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
+    newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
     videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
-    blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL
+    blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
+    myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
+    myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
+    commentMention: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
+    newFollow: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
+    newUserRegistration: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL
   }
 
   before(async function () {
@@ -424,6 +434,114 @@ describe('Test users notifications', function () {
     })
   })
 
+  describe('Mention notifications', function () {
+    let baseParams: CheckerBaseParams
+
+    before(async () => {
+      baseParams = {
+        server: servers[0],
+        emails,
+        socketNotifications: userNotifications,
+        token: userAccessToken
+      }
+
+      await updateMyUser({
+        url: servers[0].url,
+        accessToken: servers[0].accessToken,
+        displayName: 'super root name'
+      })
+
+      await updateMyUser({
+        url: servers[1].url,
+        accessToken: servers[1].accessToken,
+        displayName: 'super root 2 name'
+      })
+    })
+
+    it('Should not send a new mention comment notification if I mention the video owner', async function () {
+      this.timeout(10000)
+
+      const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: 'super video' })
+      const uuid = resVideo.body.video.uuid
+
+      const resComment = await addVideoCommentThread(servers[0].url, servers[0].accessToken, uuid, '@user_1 hello')
+      const commentId = resComment.body.comment.id
+
+      await wait(500)
+      await checkCommentMention(baseParams, uuid, commentId, commentId, 'super root name', 'absence')
+    })
+
+    it('Should not send a new mention comment notification if I mention myself', async function () {
+      this.timeout(10000)
+
+      const resVideo = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'super video' })
+      const uuid = resVideo.body.video.uuid
+
+      const resComment = await addVideoCommentThread(servers[0].url, userAccessToken, uuid, '@user_1 hello')
+      const commentId = resComment.body.comment.id
+
+      await wait(500)
+      await checkCommentMention(baseParams, uuid, commentId, commentId, 'super root name', 'absence')
+    })
+
+    it('Should not send a new mention notification if the account is muted', async function () {
+      this.timeout(10000)
+
+      await addAccountToAccountBlocklist(servers[ 0 ].url, userAccessToken, 'root')
+
+      const resVideo = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'super video' })
+      const uuid = resVideo.body.video.uuid
+
+      const resComment = await addVideoCommentThread(servers[0].url, servers[0].accessToken, uuid, '@user_1 hello')
+      const commentId = resComment.body.comment.id
+
+      await wait(500)
+      await checkCommentMention(baseParams, uuid, commentId, commentId, 'super root name', 'absence')
+
+      await removeAccountFromAccountBlocklist(servers[ 0 ].url, userAccessToken, 'root')
+    })
+
+    it('Should send a new mention notification after local comments', async function () {
+      this.timeout(10000)
+
+      const resVideo = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'super video' })
+      const uuid = resVideo.body.video.uuid
+
+      const resThread = await addVideoCommentThread(servers[0].url, servers[0].accessToken, uuid, '@user_1 hello 1')
+      const threadId = resThread.body.comment.id
+
+      await wait(500)
+      await checkCommentMention(baseParams, uuid, threadId, threadId, 'super root name', 'presence')
+
+      const resComment = await addVideoCommentReply(servers[0].url, servers[0].accessToken, uuid, threadId, 'hello 2 @user_1')
+      const commentId = resComment.body.comment.id
+
+      await wait(500)
+      await checkCommentMention(baseParams, uuid, commentId, threadId, 'super root name', 'presence')
+    })
+
+    it('Should send a new mention notification after remote comments', async function () {
+      this.timeout(20000)
+
+      const resVideo = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'super video' })
+      const uuid = resVideo.body.video.uuid
+
+      await waitJobs(servers)
+      const resThread = await addVideoCommentThread(servers[1].url, servers[1].accessToken, uuid, 'hello @user_1@localhost:9001 1')
+      const threadId = resThread.body.comment.id
+
+      await waitJobs(servers)
+      await checkCommentMention(baseParams, uuid, threadId, threadId, 'super root 2 name', 'presence')
+
+      const text = '@user_1@localhost:9001 hello 2 @root@localhost:9001'
+      const resComment = await addVideoCommentReply(servers[1].url, servers[1].accessToken, uuid, threadId, text)
+      const commentId = resComment.body.comment.id
+
+      await waitJobs(servers)
+      await checkCommentMention(baseParams, uuid, commentId, threadId, 'super root 2 name', 'presence')
+    })
+  })
+
   describe('Video abuse for moderators notification' , function () {
     let baseParams: CheckerBaseParams
 
@@ -645,6 +763,101 @@ describe('Test users notifications', function () {
     })
   })
 
+  describe('New registration', function () {
+    let baseParams: CheckerBaseParams
+
+    before(() => {
+      baseParams = {
+        server: servers[0],
+        emails,
+        socketNotifications: adminNotifications,
+        token: servers[0].accessToken
+      }
+    })
+
+    it('Should send a notification only to moderators when a user registers on the instance', async function () {
+      await registerUser(servers[0].url, 'user_45', 'password')
+
+      await waitJobs(servers)
+
+      await checkUserRegistered(baseParams, 'user_45', 'presence')
+
+      const userOverride = { socketNotifications: userNotifications, token: userAccessToken, check: { web: true, mail: false } }
+      await checkUserRegistered(immutableAssign(baseParams, userOverride), 'user_45', 'absence')
+    })
+  })
+
+  describe('New actor follow', function () {
+    let baseParams: CheckerBaseParams
+    let myChannelName = 'super channel name'
+    let myUserName = 'super user name'
+
+    before(async () => {
+      baseParams = {
+        server: servers[0],
+        emails,
+        socketNotifications: userNotifications,
+        token: userAccessToken
+      }
+
+      await updateMyUser({
+        url: servers[0].url,
+        accessToken: servers[0].accessToken,
+        displayName: 'super root name'
+      })
+
+      await updateMyUser({
+        url: servers[0].url,
+        accessToken: userAccessToken,
+        displayName: myUserName
+      })
+
+      await updateMyUser({
+        url: servers[1].url,
+        accessToken: servers[1].accessToken,
+        displayName: 'super root 2 name'
+      })
+
+      await updateVideoChannel(servers[0].url, userAccessToken, 'user_1_channel', { displayName: myChannelName })
+    })
+
+    it('Should notify when a local channel is following one of our channel', async function () {
+      await addUserSubscription(servers[0].url, servers[0].accessToken, 'user_1_channel@localhost:9001')
+
+      await waitJobs(servers)
+
+      await checkNewActorFollow(baseParams, 'channel', 'root', 'super root name', myChannelName, 'presence')
+
+      await removeUserSubscription(servers[0].url, servers[0].accessToken, 'user_1_channel@localhost:9001')
+    })
+
+    it('Should notify when a remote channel is following one of our channel', async function () {
+      await addUserSubscription(servers[1].url, servers[1].accessToken, 'user_1_channel@localhost:9001')
+
+      await waitJobs(servers)
+
+      await checkNewActorFollow(baseParams, 'channel', 'root', 'super root 2 name', myChannelName, 'presence')
+
+      await removeUserSubscription(servers[1].url, servers[1].accessToken, 'user_1_channel@localhost:9001')
+    })
+
+    it('Should notify when a local account is following one of our channel', async function () {
+      await addUserSubscription(servers[0].url, servers[0].accessToken, 'user_1@localhost:9001')
+
+      await waitJobs(servers)
+
+      await checkNewActorFollow(baseParams, 'account', 'root', 'super root name', myUserName, 'presence')
+    })
+
+    it('Should notify when a remote account is following one of our channel', async function () {
+      await addUserSubscription(servers[1].url, servers[1].accessToken, 'user_1@localhost:9001')
+
+      await waitJobs(servers)
+
+      await checkNewActorFollow(baseParams, 'account', 'root', 'super root 2 name', myUserName, 'presence')
+    })
+  })
+
   describe('Mark as read', function () {
     it('Should mark as read some notifications', async function () {
       const res = await getUserNotifications(servers[ 0 ].url, userAccessToken, 2, 3)
diff --git a/server/tests/helpers/comment-model.ts b/server/tests/helpers/comment-model.ts
new file mode 100644 (file)
index 0000000..76bb0f2
--- /dev/null
@@ -0,0 +1,25 @@
+/* tslint:disable:no-unused-expression */
+
+import * as chai from 'chai'
+import 'mocha'
+import { VideoCommentModel } from '../../models/video/video-comment'
+
+const expect = chai.expect
+
+class CommentMock {
+  text: string
+
+  extractMentions = VideoCommentModel.prototype.extractMentions
+}
+
+describe('Comment model', function () {
+  it('Should correctly extract mentions', async function () {
+    const comment = new CommentMock()
+
+    comment.text = '@florian @jean@localhost:9000 @flo @another@localhost:9000 @flo2@jean.com hello ' +
+      'email@localhost:9000 coucou.com no? @chocobozzz @chocobozzz @end'
+    const result = comment.extractMentions().sort()
+
+    expect(result).to.deep.equal([ 'another', 'chocobozzz', 'end', 'flo', 'florian', 'jean' ])
+  })
+})
index 40c7dc70e29878d98a33a6ba650be70c365321fc..55120824516e067ec92b372c62b60f762e4f9847 100644 (file)
@@ -1 +1,2 @@
 import './core-utils'
+import './comment-model'
index 55d351abfe582c10303f49577182cbfd5f54e161..f580e827eefa9c66afd2245d763dfdd2c41a59b6 100644 (file)
@@ -12,4 +12,7 @@ export interface UserNotificationSetting {
   blacklistOnMyVideo: UserNotificationSettingValue
   myVideoPublished: UserNotificationSettingValue
   myVideoImportFinished: UserNotificationSettingValue
+  newUserRegistration: UserNotificationSettingValue
+  newFollow: UserNotificationSettingValue
+  commentMention: UserNotificationSettingValue
 }
index ee9ac275abb9b4c617bae5f10df7f2c60ad338ae..9dd4f099f26d5617ab0a223593b77c8913ff6290 100644 (file)
@@ -6,7 +6,10 @@ export enum UserNotificationType {
   UNBLACKLIST_ON_MY_VIDEO = 5,
   MY_VIDEO_PUBLISHED = 6,
   MY_VIDEO_IMPORT_SUCCESS = 7,
-  MY_VIDEO_IMPORT_ERROR = 8
+  MY_VIDEO_IMPORT_ERROR = 8,
+  NEW_USER_REGISTRATION = 9,
+  NEW_FOLLOW = 10,
+  COMMENT_MENTION = 11
 }
 
 export interface VideoInfo {
@@ -55,6 +58,25 @@ export interface UserNotification {
     video: VideoInfo
   }
 
+  account?: {
+    id: number
+    displayName: string
+    name: string
+  }
+
+  actorFollow?: {
+    id: number
+    follower: {
+      name: string
+      displayName: string
+    }
+    following: {
+      type: 'account' | 'channel'
+      name: string
+      displayName: string
+    }
+  }
+
   createdAt: string
   updatedAt: string
 }
index 51c59d20ad0e7ef3bef72548c558f1e26b77284c..090256bca0dd066fe41d9b019a1e7940b7f7ffa1 100644 (file)
@@ -2,10 +2,15 @@ export enum UserRight {
   ALL,
 
   MANAGE_USERS,
+
   MANAGE_SERVER_FOLLOW,
+
   MANAGE_SERVER_REDUNDANCY,
+
   MANAGE_VIDEO_ABUSES,
+
   MANAGE_JOBS,
+
   MANAGE_CONFIGURATION,
 
   MANAGE_ACCOUNTS_BLOCKLIST,
index adef8fd955893ada64d26b604955062ba2c21baf..59c2ba10656a63a896ca8a429c437ff0e367fe69 100644 (file)
@@ -29,7 +29,8 @@ const userRoleRights: { [ id: number ]: UserRight[] } = {
     UserRight.UPDATE_ANY_VIDEO,
     UserRight.SEE_ALL_VIDEOS,
     UserRight.MANAGE_ACCOUNTS_BLOCKLIST,
-    UserRight.MANAGE_SERVERS_BLOCKLIST
+    UserRight.MANAGE_SERVERS_BLOCKLIST,
+    UserRight.MANAGE_USERS
   ],
 
   [UserRole.USER]: []
index 75d52023a31904b0a4a7179b358dc3a2dbdc43a7..1222899e769db92799677c1acd76b5a5f3bad188 100644 (file)
@@ -98,9 +98,11 @@ async function checkNotification (
     })
 
     if (checkType === 'presence') {
-      expect(socketNotification, 'The socket notification is absent. ' + inspect(base.socketNotifications)).to.not.be.undefined
+      const obj = inspect(base.socketNotifications, { depth: 5 })
+      expect(socketNotification, 'The socket notification is absent. ' + obj).to.not.be.undefined
     } else {
-      expect(socketNotification, 'The socket notification is present. ' + inspect(socketNotification)).to.be.undefined
+      const obj = inspect(socketNotification, { depth: 5 })
+      expect(socketNotification, 'The socket notification is present. ' + obj).to.be.undefined
     }
   }
 
@@ -131,10 +133,9 @@ function checkVideo (video: any, videoName?: string, videoUUID?: string) {
   expect(video.id).to.be.a('number')
 }
 
-function checkActor (channel: any) {
-  expect(channel.id).to.be.a('number')
-  expect(channel.displayName).to.be.a('string')
-  expect(channel.displayName).to.not.be.empty
+function checkActor (actor: any) {
+  expect(actor.displayName).to.be.a('string')
+  expect(actor.displayName).to.not.be.empty
 }
 
 function checkComment (comment: any, commentId: number, threadId: number) {
@@ -220,6 +221,103 @@ async function checkMyVideoImportIsFinished (
   await checkNotification(base, notificationChecker, emailFinder, type)
 }
 
+async function checkUserRegistered (base: CheckerBaseParams, username: string, type: CheckerType) {
+  const notificationType = UserNotificationType.NEW_USER_REGISTRATION
+
+  function notificationChecker (notification: UserNotification, type: CheckerType) {
+    if (type === 'presence') {
+      expect(notification).to.not.be.undefined
+      expect(notification.type).to.equal(notificationType)
+
+      checkActor(notification.account)
+      expect(notification.account.name).to.equal(username)
+    } else {
+      expect(notification).to.satisfy(n => n.type !== notificationType || n.account.name !== username)
+    }
+  }
+
+  function emailFinder (email: object) {
+    const text: string = email[ 'text' ]
+
+    return text.includes(' registered ') && text.includes(username)
+  }
+
+  await checkNotification(base, notificationChecker, emailFinder, type)
+}
+
+async function checkNewActorFollow (
+  base: CheckerBaseParams,
+  followType: 'channel' | 'account',
+  followerName: string,
+  followerDisplayName: string,
+  followingDisplayName: string,
+  type: CheckerType
+) {
+  const notificationType = UserNotificationType.NEW_FOLLOW
+
+  function notificationChecker (notification: UserNotification, type: CheckerType) {
+    if (type === 'presence') {
+      expect(notification).to.not.be.undefined
+      expect(notification.type).to.equal(notificationType)
+
+      checkActor(notification.actorFollow.follower)
+      expect(notification.actorFollow.follower.displayName).to.equal(followerDisplayName)
+      expect(notification.actorFollow.follower.name).to.equal(followerName)
+
+      checkActor(notification.actorFollow.following)
+      expect(notification.actorFollow.following.displayName).to.equal(followingDisplayName)
+      expect(notification.actorFollow.following.type).to.equal(followType)
+    } else {
+      expect(notification).to.satisfy(n => {
+        return n.type !== notificationType ||
+          (n.actorFollow.follower.name !== followerName && n.actorFollow.following !== followingDisplayName)
+      })
+    }
+  }
+
+  function emailFinder (email: object) {
+    const text: string = email[ 'text' ]
+
+    return text.includes('Your ' + followType) && text.includes(followingDisplayName) && text.includes(followerDisplayName)
+  }
+
+  await checkNotification(base, notificationChecker, emailFinder, type)
+}
+
+async function checkCommentMention (
+  base: CheckerBaseParams,
+  uuid: string,
+  commentId: number,
+  threadId: number,
+  byAccountDisplayName: string,
+  type: CheckerType
+) {
+  const notificationType = UserNotificationType.COMMENT_MENTION
+
+  function notificationChecker (notification: UserNotification, type: CheckerType) {
+    if (type === 'presence') {
+      expect(notification).to.not.be.undefined
+      expect(notification.type).to.equal(notificationType)
+
+      checkComment(notification.comment, commentId, threadId)
+      checkActor(notification.comment.account)
+      expect(notification.comment.account.displayName).to.equal(byAccountDisplayName)
+
+      checkVideo(notification.comment.video, undefined, uuid)
+    } else {
+      expect(notification).to.satisfy(n => n.type !== notificationType || n.comment.id !== commentId)
+    }
+  }
+
+  function emailFinder (email: object) {
+    const text: string = email[ 'text' ]
+
+    return text.includes(' mentioned ') && text.includes(uuid) && text.includes(byAccountDisplayName)
+  }
+
+  await checkNotification(base, notificationChecker, emailFinder, type)
+}
+
 let lastEmailCount = 0
 async function checkNewCommentOnMyVideo (base: CheckerBaseParams, uuid: string, commentId: number, threadId: number, type: CheckerType) {
   const notificationType = UserNotificationType.NEW_COMMENT_ON_MY_VIDEO
@@ -312,10 +410,13 @@ export {
   CheckerType,
   checkNotification,
   checkMyVideoImportIsFinished,
+  checkUserRegistered,
   checkVideoIsPublished,
   checkNewVideoFromSubscription,
+  checkNewActorFollow,
   checkNewCommentOnMyVideo,
   checkNewBlacklistOnMyVideo,
+  checkCommentMention,
   updateMyNotificationSettings,
   checkNewVideoAbuseForModerators,
   getUserNotifications,