video-comment.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869
  1. import { uniq } from 'lodash'
  2. import { FindAndCountOptions, FindOptions, Op, Order, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
  3. import {
  4. AllowNull,
  5. BelongsTo,
  6. Column,
  7. CreatedAt,
  8. DataType,
  9. ForeignKey,
  10. HasMany,
  11. Is,
  12. Model,
  13. Scopes,
  14. Table,
  15. UpdatedAt
  16. } from 'sequelize-typescript'
  17. import { getServerActor } from '@server/models/application/application'
  18. import { MAccount, MAccountId, MUserAccountId } from '@server/types/models'
  19. import { VideoPrivacy } from '@shared/models'
  20. import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects'
  21. import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
  22. import { VideoComment, VideoCommentAdmin } from '../../../shared/models/videos/video-comment.model'
  23. import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor'
  24. import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
  25. import { regexpCapture } from '../../helpers/regexp'
  26. import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
  27. import {
  28. MComment,
  29. MCommentAdminFormattable,
  30. MCommentAP,
  31. MCommentFormattable,
  32. MCommentId,
  33. MCommentOwner,
  34. MCommentOwnerReplyVideoLight,
  35. MCommentOwnerVideo,
  36. MCommentOwnerVideoFeed,
  37. MCommentOwnerVideoReply,
  38. MVideoImmutable
  39. } from '../../types/models/video'
  40. import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
  41. import { AccountModel } from '../account/account'
  42. import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor'
  43. import {
  44. buildBlockedAccountSQL,
  45. buildBlockedAccountSQLOptimized,
  46. buildLocalAccountIdsIn,
  47. getCommentSort,
  48. searchAttribute,
  49. throwIfNotValid
  50. } from '../utils'
  51. import { VideoModel } from './video'
  52. import { VideoChannelModel } from './video-channel'
  53. export enum ScopeNames {
  54. WITH_ACCOUNT = 'WITH_ACCOUNT',
  55. WITH_ACCOUNT_FOR_API = 'WITH_ACCOUNT_FOR_API',
  56. WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
  57. WITH_VIDEO = 'WITH_VIDEO',
  58. ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
  59. }
  60. @Scopes(() => ({
  61. [ScopeNames.ATTRIBUTES_FOR_API]: (blockerAccountIds: number[]) => {
  62. return {
  63. attributes: {
  64. include: [
  65. [
  66. Sequelize.literal(
  67. '(' +
  68. 'WITH "blocklist" AS (' + buildBlockedAccountSQL(blockerAccountIds) + ')' +
  69. 'SELECT COUNT("replies"."id") - (' +
  70. 'SELECT COUNT("replies"."id") ' +
  71. 'FROM "videoComment" AS "replies" ' +
  72. 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
  73. 'AND "accountId" IN (SELECT "id" FROM "blocklist")' +
  74. ')' +
  75. 'FROM "videoComment" AS "replies" ' +
  76. 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
  77. 'AND "accountId" NOT IN (SELECT "id" FROM "blocklist")' +
  78. ')'
  79. ),
  80. 'totalReplies'
  81. ],
  82. [
  83. Sequelize.literal(
  84. '(' +
  85. 'SELECT COUNT("replies"."id") ' +
  86. 'FROM "videoComment" AS "replies" ' +
  87. 'INNER JOIN "video" ON "video"."id" = "replies"."videoId" ' +
  88. 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
  89. 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
  90. 'AND "replies"."accountId" = "videoChannel"."accountId"' +
  91. ')'
  92. ),
  93. 'totalRepliesFromVideoAuthor'
  94. ]
  95. ]
  96. }
  97. } as FindOptions
  98. },
  99. [ScopeNames.WITH_ACCOUNT]: {
  100. include: [
  101. {
  102. model: AccountModel
  103. }
  104. ]
  105. },
  106. [ScopeNames.WITH_ACCOUNT_FOR_API]: {
  107. include: [
  108. {
  109. model: AccountModel.unscoped(),
  110. include: [
  111. {
  112. attributes: {
  113. exclude: unusedActorAttributesForAPI
  114. },
  115. model: ActorModel, // Default scope includes avatar and server
  116. required: true
  117. }
  118. ]
  119. }
  120. ]
  121. },
  122. [ScopeNames.WITH_IN_REPLY_TO]: {
  123. include: [
  124. {
  125. model: VideoCommentModel,
  126. as: 'InReplyToVideoComment'
  127. }
  128. ]
  129. },
  130. [ScopeNames.WITH_VIDEO]: {
  131. include: [
  132. {
  133. model: VideoModel,
  134. required: true,
  135. include: [
  136. {
  137. model: VideoChannelModel,
  138. required: true,
  139. include: [
  140. {
  141. model: AccountModel,
  142. required: true
  143. }
  144. ]
  145. }
  146. ]
  147. }
  148. ]
  149. }
  150. }))
  151. @Table({
  152. tableName: 'videoComment',
  153. indexes: [
  154. {
  155. fields: [ 'videoId' ]
  156. },
  157. {
  158. fields: [ 'videoId', 'originCommentId' ]
  159. },
  160. {
  161. fields: [ 'url' ],
  162. unique: true
  163. },
  164. {
  165. fields: [ 'accountId' ]
  166. },
  167. {
  168. fields: [
  169. { name: 'createdAt', order: 'DESC' }
  170. ]
  171. }
  172. ]
  173. })
  174. export class VideoCommentModel extends Model {
  175. @CreatedAt
  176. createdAt: Date
  177. @UpdatedAt
  178. updatedAt: Date
  179. @AllowNull(true)
  180. @Column(DataType.DATE)
  181. deletedAt: Date
  182. @AllowNull(false)
  183. @Is('VideoCommentUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
  184. @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
  185. url: string
  186. @AllowNull(false)
  187. @Column(DataType.TEXT)
  188. text: string
  189. @ForeignKey(() => VideoCommentModel)
  190. @Column
  191. originCommentId: number
  192. @BelongsTo(() => VideoCommentModel, {
  193. foreignKey: {
  194. name: 'originCommentId',
  195. allowNull: true
  196. },
  197. as: 'OriginVideoComment',
  198. onDelete: 'CASCADE'
  199. })
  200. OriginVideoComment: VideoCommentModel
  201. @ForeignKey(() => VideoCommentModel)
  202. @Column
  203. inReplyToCommentId: number
  204. @BelongsTo(() => VideoCommentModel, {
  205. foreignKey: {
  206. name: 'inReplyToCommentId',
  207. allowNull: true
  208. },
  209. as: 'InReplyToVideoComment',
  210. onDelete: 'CASCADE'
  211. })
  212. InReplyToVideoComment: VideoCommentModel | null
  213. @ForeignKey(() => VideoModel)
  214. @Column
  215. videoId: number
  216. @BelongsTo(() => VideoModel, {
  217. foreignKey: {
  218. allowNull: false
  219. },
  220. onDelete: 'CASCADE'
  221. })
  222. Video: VideoModel
  223. @ForeignKey(() => AccountModel)
  224. @Column
  225. accountId: number
  226. @BelongsTo(() => AccountModel, {
  227. foreignKey: {
  228. allowNull: true
  229. },
  230. onDelete: 'CASCADE'
  231. })
  232. Account: AccountModel
  233. @HasMany(() => VideoCommentAbuseModel, {
  234. foreignKey: {
  235. name: 'videoCommentId',
  236. allowNull: true
  237. },
  238. onDelete: 'set null'
  239. })
  240. CommentAbuses: VideoCommentAbuseModel[]
  241. static loadById (id: number, t?: Transaction): Promise<MComment> {
  242. const query: FindOptions = {
  243. where: {
  244. id
  245. }
  246. }
  247. if (t !== undefined) query.transaction = t
  248. return VideoCommentModel.findOne(query)
  249. }
  250. static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Transaction): Promise<MCommentOwnerVideoReply> {
  251. const query: FindOptions = {
  252. where: {
  253. id
  254. }
  255. }
  256. if (t !== undefined) query.transaction = t
  257. return VideoCommentModel
  258. .scope([ ScopeNames.WITH_VIDEO, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_IN_REPLY_TO ])
  259. .findOne(query)
  260. }
  261. static loadByUrlAndPopulateAccountAndVideo (url: string, t?: Transaction): Promise<MCommentOwnerVideo> {
  262. const query: FindOptions = {
  263. where: {
  264. url
  265. }
  266. }
  267. if (t !== undefined) query.transaction = t
  268. return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_VIDEO ]).findOne(query)
  269. }
  270. static loadByUrlAndPopulateReplyAndVideoUrlAndAccount (url: string, t?: Transaction): Promise<MCommentOwnerReplyVideoLight> {
  271. const query: FindOptions = {
  272. where: {
  273. url
  274. },
  275. include: [
  276. {
  277. attributes: [ 'id', 'url' ],
  278. model: VideoModel.unscoped()
  279. }
  280. ]
  281. }
  282. if (t !== undefined) query.transaction = t
  283. return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_ACCOUNT ]).findOne(query)
  284. }
  285. static listCommentsForApi (parameters: {
  286. start: number
  287. count: number
  288. sort: string
  289. isLocal?: boolean
  290. search?: string
  291. searchAccount?: string
  292. searchVideo?: string
  293. }) {
  294. const { start, count, sort, isLocal, search, searchAccount, searchVideo } = parameters
  295. const where: WhereOptions = {
  296. deletedAt: null
  297. }
  298. const whereAccount: WhereOptions = {}
  299. const whereActor: WhereOptions = {}
  300. const whereVideo: WhereOptions = {}
  301. if (isLocal === true) {
  302. Object.assign(whereActor, {
  303. serverId: null
  304. })
  305. } else if (isLocal === false) {
  306. Object.assign(whereActor, {
  307. serverId: {
  308. [Op.ne]: null
  309. }
  310. })
  311. }
  312. if (search) {
  313. Object.assign(where, {
  314. [Op.or]: [
  315. searchAttribute(search, 'text'),
  316. searchAttribute(search, '$Account.Actor.preferredUsername$'),
  317. searchAttribute(search, '$Account.name$'),
  318. searchAttribute(search, '$Video.name$')
  319. ]
  320. })
  321. }
  322. if (searchAccount) {
  323. Object.assign(whereActor, {
  324. [Op.or]: [
  325. searchAttribute(searchAccount, '$Account.Actor.preferredUsername$'),
  326. searchAttribute(searchAccount, '$Account.name$')
  327. ]
  328. })
  329. }
  330. if (searchVideo) {
  331. Object.assign(whereVideo, searchAttribute(searchVideo, 'name'))
  332. }
  333. const query: FindAndCountOptions = {
  334. offset: start,
  335. limit: count,
  336. order: getCommentSort(sort),
  337. where,
  338. include: [
  339. {
  340. model: AccountModel.unscoped(),
  341. required: true,
  342. where: whereAccount,
  343. include: [
  344. {
  345. attributes: {
  346. exclude: unusedActorAttributesForAPI
  347. },
  348. model: ActorModel, // Default scope includes avatar and server
  349. required: true,
  350. where: whereActor
  351. }
  352. ]
  353. },
  354. {
  355. model: VideoModel.unscoped(),
  356. required: true,
  357. where: whereVideo
  358. }
  359. ]
  360. }
  361. return VideoCommentModel
  362. .findAndCountAll(query)
  363. .then(({ rows, count }) => {
  364. return { total: count, data: rows }
  365. })
  366. }
  367. static async listThreadsForApi (parameters: {
  368. videoId: number
  369. isVideoOwned: boolean
  370. start: number
  371. count: number
  372. sort: string
  373. user?: MUserAccountId
  374. }) {
  375. const { videoId, isVideoOwned, start, count, sort, user } = parameters
  376. const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned })
  377. const query = {
  378. offset: start,
  379. limit: count,
  380. order: getCommentSort(sort),
  381. where: {
  382. [Op.and]: [
  383. {
  384. videoId
  385. },
  386. {
  387. inReplyToCommentId: null
  388. },
  389. {
  390. [Op.or]: [
  391. {
  392. accountId: {
  393. [Op.notIn]: Sequelize.literal(
  394. '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
  395. )
  396. }
  397. },
  398. {
  399. accountId: null
  400. }
  401. ]
  402. }
  403. ]
  404. }
  405. }
  406. const scopes: (string | ScopeOptions)[] = [
  407. ScopeNames.WITH_ACCOUNT_FOR_API,
  408. {
  409. method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
  410. }
  411. ]
  412. return VideoCommentModel
  413. .scope(scopes)
  414. .findAndCountAll(query)
  415. .then(({ rows, count }) => {
  416. return { total: count, data: rows }
  417. })
  418. }
  419. static async listThreadCommentsForApi (parameters: {
  420. videoId: number
  421. isVideoOwned: boolean
  422. threadId: number
  423. user?: MUserAccountId
  424. }) {
  425. const { videoId, threadId, user, isVideoOwned } = parameters
  426. const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned })
  427. const query = {
  428. order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ] as Order,
  429. where: {
  430. videoId,
  431. [Op.or]: [
  432. { id: threadId },
  433. { originCommentId: threadId }
  434. ],
  435. accountId: {
  436. [Op.notIn]: Sequelize.literal(
  437. '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
  438. )
  439. }
  440. }
  441. }
  442. const scopes: any[] = [
  443. ScopeNames.WITH_ACCOUNT_FOR_API,
  444. {
  445. method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
  446. }
  447. ]
  448. return VideoCommentModel
  449. .scope(scopes)
  450. .findAndCountAll(query)
  451. .then(({ rows, count }) => {
  452. return { total: count, data: rows }
  453. })
  454. }
  455. static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Promise<MCommentOwner[]> {
  456. const query = {
  457. order: [ [ 'createdAt', order ] ] as Order,
  458. where: {
  459. id: {
  460. [Op.in]: Sequelize.literal('(' +
  461. 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
  462. `SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` +
  463. 'UNION ' +
  464. 'SELECT "parent"."id", "parent"."inReplyToCommentId" FROM "videoComment" "parent" ' +
  465. 'INNER JOIN "children" ON "children"."inReplyToCommentId" = "parent"."id"' +
  466. ') ' +
  467. 'SELECT id FROM children' +
  468. ')'),
  469. [Op.ne]: comment.id
  470. }
  471. },
  472. transaction: t
  473. }
  474. return VideoCommentModel
  475. .scope([ ScopeNames.WITH_ACCOUNT ])
  476. .findAll(query)
  477. }
  478. static async listAndCountByVideoForAP (video: MVideoImmutable, start: number, count: number, t?: Transaction) {
  479. const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({
  480. videoId: video.id,
  481. isVideoOwned: video.isOwned()
  482. })
  483. const query = {
  484. order: [ [ 'createdAt', 'ASC' ] ] as Order,
  485. offset: start,
  486. limit: count,
  487. where: {
  488. videoId: video.id,
  489. accountId: {
  490. [Op.notIn]: Sequelize.literal(
  491. '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
  492. )
  493. }
  494. },
  495. transaction: t
  496. }
  497. return VideoCommentModel.findAndCountAll<MComment>(query)
  498. }
  499. static async listForFeed (parameters: {
  500. start: number
  501. count: number
  502. videoId?: number
  503. accountId?: number
  504. videoChannelId?: number
  505. }): Promise<MCommentOwnerVideoFeed[]> {
  506. const serverActor = await getServerActor()
  507. const { start, count, videoId, accountId, videoChannelId } = parameters
  508. const whereAnd: WhereOptions[] = buildBlockedAccountSQLOptimized(
  509. '"VideoCommentModel"."accountId"',
  510. [ serverActor.Account.id, '"Video->VideoChannel"."accountId"' ]
  511. )
  512. if (accountId) {
  513. whereAnd.push({
  514. [Op.eq]: accountId
  515. })
  516. }
  517. const accountWhere = {
  518. [Op.and]: whereAnd
  519. }
  520. const videoChannelWhere = videoChannelId ? { id: videoChannelId } : undefined
  521. const query = {
  522. order: [ [ 'createdAt', 'DESC' ] ] as Order,
  523. offset: start,
  524. limit: count,
  525. where: {
  526. deletedAt: null,
  527. accountId: accountWhere
  528. },
  529. include: [
  530. {
  531. attributes: [ 'name', 'uuid' ],
  532. model: VideoModel.unscoped(),
  533. required: true,
  534. where: {
  535. privacy: VideoPrivacy.PUBLIC
  536. },
  537. include: [
  538. {
  539. attributes: [ 'accountId' ],
  540. model: VideoChannelModel.unscoped(),
  541. required: true,
  542. where: videoChannelWhere
  543. }
  544. ]
  545. }
  546. ]
  547. }
  548. if (videoId) query.where['videoId'] = videoId
  549. return VideoCommentModel
  550. .scope([ ScopeNames.WITH_ACCOUNT ])
  551. .findAll(query)
  552. }
  553. static listForBulkDelete (ofAccount: MAccount, filter: { onVideosOfAccount?: MAccountId } = {}) {
  554. const accountWhere = filter.onVideosOfAccount
  555. ? { id: filter.onVideosOfAccount.id }
  556. : {}
  557. const query = {
  558. limit: 1000,
  559. where: {
  560. deletedAt: null,
  561. accountId: ofAccount.id
  562. },
  563. include: [
  564. {
  565. model: VideoModel,
  566. required: true,
  567. include: [
  568. {
  569. model: VideoChannelModel,
  570. required: true,
  571. include: [
  572. {
  573. model: AccountModel,
  574. required: true,
  575. where: accountWhere
  576. }
  577. ]
  578. }
  579. ]
  580. }
  581. ]
  582. }
  583. return VideoCommentModel
  584. .scope([ ScopeNames.WITH_ACCOUNT ])
  585. .findAll(query)
  586. }
  587. static async getStats () {
  588. const totalLocalVideoComments = await VideoCommentModel.count({
  589. include: [
  590. {
  591. model: AccountModel,
  592. required: true,
  593. include: [
  594. {
  595. model: ActorModel,
  596. required: true,
  597. where: {
  598. serverId: null
  599. }
  600. }
  601. ]
  602. }
  603. ]
  604. })
  605. const totalVideoComments = await VideoCommentModel.count()
  606. return {
  607. totalLocalVideoComments,
  608. totalVideoComments
  609. }
  610. }
  611. static cleanOldCommentsOf (videoId: number, beforeUpdatedAt: Date) {
  612. const query = {
  613. where: {
  614. updatedAt: {
  615. [Op.lt]: beforeUpdatedAt
  616. },
  617. videoId,
  618. accountId: {
  619. [Op.notIn]: buildLocalAccountIdsIn()
  620. },
  621. // Do not delete Tombstones
  622. deletedAt: null
  623. }
  624. }
  625. return VideoCommentModel.destroy(query)
  626. }
  627. getCommentStaticPath () {
  628. return this.Video.getWatchStaticPath() + ';threadId=' + this.getThreadId()
  629. }
  630. getThreadId (): number {
  631. return this.originCommentId || this.id
  632. }
  633. isOwned () {
  634. if (!this.Account) {
  635. return false
  636. }
  637. return this.Account.isOwned()
  638. }
  639. isDeleted () {
  640. return this.deletedAt !== null
  641. }
  642. extractMentions () {
  643. let result: string[] = []
  644. const localMention = `@(${actorNameAlphabet}+)`
  645. const remoteMention = `${localMention}@${WEBSERVER.HOST}`
  646. const mentionRegex = this.isOwned()
  647. ? '(?:(?:' + remoteMention + ')|(?:' + localMention + '))' // Include local mentions?
  648. : '(?:' + remoteMention + ')'
  649. const firstMentionRegex = new RegExp(`^${mentionRegex} `, 'g')
  650. const endMentionRegex = new RegExp(` ${mentionRegex}$`, 'g')
  651. const remoteMentionsRegex = new RegExp(' ' + remoteMention + ' ', 'g')
  652. result = result.concat(
  653. regexpCapture(this.text, firstMentionRegex)
  654. .map(([ , username1, username2 ]) => username1 || username2),
  655. regexpCapture(this.text, endMentionRegex)
  656. .map(([ , username1, username2 ]) => username1 || username2),
  657. regexpCapture(this.text, remoteMentionsRegex)
  658. .map(([ , username ]) => username)
  659. )
  660. // Include local mentions
  661. if (this.isOwned()) {
  662. const localMentionsRegex = new RegExp(' ' + localMention + ' ', 'g')
  663. result = result.concat(
  664. regexpCapture(this.text, localMentionsRegex)
  665. .map(([ , username ]) => username)
  666. )
  667. }
  668. return uniq(result)
  669. }
  670. toFormattedJSON (this: MCommentFormattable) {
  671. return {
  672. id: this.id,
  673. url: this.url,
  674. text: this.text,
  675. threadId: this.getThreadId(),
  676. inReplyToCommentId: this.inReplyToCommentId || null,
  677. videoId: this.videoId,
  678. createdAt: this.createdAt,
  679. updatedAt: this.updatedAt,
  680. deletedAt: this.deletedAt,
  681. isDeleted: this.isDeleted(),
  682. totalRepliesFromVideoAuthor: this.get('totalRepliesFromVideoAuthor') || 0,
  683. totalReplies: this.get('totalReplies') || 0,
  684. account: this.Account
  685. ? this.Account.toFormattedJSON()
  686. : null
  687. } as VideoComment
  688. }
  689. toFormattedAdminJSON (this: MCommentAdminFormattable) {
  690. return {
  691. id: this.id,
  692. url: this.url,
  693. text: this.text,
  694. threadId: this.getThreadId(),
  695. inReplyToCommentId: this.inReplyToCommentId || null,
  696. videoId: this.videoId,
  697. createdAt: this.createdAt,
  698. updatedAt: this.updatedAt,
  699. video: {
  700. id: this.Video.id,
  701. uuid: this.Video.uuid,
  702. name: this.Video.name
  703. },
  704. account: this.Account
  705. ? this.Account.toFormattedJSON()
  706. : null
  707. } as VideoCommentAdmin
  708. }
  709. toActivityPubObject (this: MCommentAP, threadParentComments: MCommentOwner[]): VideoCommentObject | ActivityTombstoneObject {
  710. let inReplyTo: string
  711. // New thread, so in AS we reply to the video
  712. if (this.inReplyToCommentId === null) {
  713. inReplyTo = this.Video.url
  714. } else {
  715. inReplyTo = this.InReplyToVideoComment.url
  716. }
  717. if (this.isDeleted()) {
  718. return {
  719. id: this.url,
  720. type: 'Tombstone',
  721. formerType: 'Note',
  722. inReplyTo,
  723. published: this.createdAt.toISOString(),
  724. updated: this.updatedAt.toISOString(),
  725. deleted: this.deletedAt.toISOString()
  726. }
  727. }
  728. const tag: ActivityTagObject[] = []
  729. for (const parentComment of threadParentComments) {
  730. if (!parentComment.Account) continue
  731. const actor = parentComment.Account.Actor
  732. tag.push({
  733. type: 'Mention',
  734. href: actor.url,
  735. name: `@${actor.preferredUsername}@${actor.getHost()}`
  736. })
  737. }
  738. return {
  739. type: 'Note' as 'Note',
  740. id: this.url,
  741. content: this.text,
  742. inReplyTo,
  743. updated: this.updatedAt.toISOString(),
  744. published: this.createdAt.toISOString(),
  745. url: this.url,
  746. attributedTo: this.Account.Actor.url,
  747. tag
  748. }
  749. }
  750. private static async buildBlockerAccountIds (options: {
  751. videoId: number
  752. isVideoOwned: boolean
  753. user?: MUserAccountId
  754. }) {
  755. const { videoId, user, isVideoOwned } = options
  756. const serverActor = await getServerActor()
  757. const blockerAccountIds = [ serverActor.Account.id ]
  758. if (user) blockerAccountIds.push(user.Account.id)
  759. if (isVideoOwned) {
  760. const videoOwnerAccount = await AccountModel.loadAccountIdFromVideo(videoId)
  761. blockerAccountIds.push(videoOwnerAccount.id)
  762. }
  763. return blockerAccountIds
  764. }
  765. }