diff --git a/server/controllers/api/videos/comment.ts b/server/controllers/api/videos/comment.ts index b69aa5d40..81c9e7d16 100644 --- a/server/controllers/api/videos/comment.ts +++ b/server/controllers/api/videos/comment.ts @@ -1,88 +1,114 @@ -// import * as express from 'express' -// import { logger, getFormattedObjects } from '../../../helpers' -// import { -// authenticate, -// ensureUserHasRight, -// videosBlacklistAddValidator, -// videosBlacklistRemoveValidator, -// paginationValidator, -// blacklistSortValidator, -// setBlacklistSort, -// setPagination, -// asyncMiddleware -// } from '../../../middlewares' -// import { BlacklistedVideo, UserRight } from '../../../../shared' -// import { VideoBlacklistModel } from '../../../models/video/video-blacklist' -// -// const videoCommentRouter = express.Router() -// -// videoCommentRouter.get('/:videoId/comment', -// authenticate, -// ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST), -// asyncMiddleware(listVideoCommentsThreadsValidator), -// asyncMiddleware(listVideoCommentsThreads) -// ) -// -// videoCommentRouter.post('/:videoId/comment', -// authenticate, -// ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST), -// asyncMiddleware(videosBlacklistAddValidator), -// asyncMiddleware(addVideoToBlacklist) -// ) -// -// videoCommentRouter.get('/blacklist', -// authenticate, -// ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST), -// paginationValidator, -// blacklistSortValidator, -// setBlacklistSort, -// setPagination, -// asyncMiddleware(listBlacklist) -// ) -// -// videoCommentRouter.delete('/:videoId/blacklist', -// authenticate, -// ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST), -// asyncMiddleware(videosBlacklistRemoveValidator), -// asyncMiddleware(removeVideoFromBlacklistController) -// ) -// -// // --------------------------------------------------------------------------- -// -// export { -// videoCommentRouter -// } -// -// // --------------------------------------------------------------------------- -// -// async function addVideoToBlacklist (req: express.Request, res: express.Response, next: express.NextFunction) { -// const videoInstance = res.locals.video -// -// const toCreate = { -// videoId: videoInstance.id -// } -// -// await VideoBlacklistModel.create(toCreate) -// return res.type('json').status(204).end() -// } -// -// async function listBlacklist (req: express.Request, res: express.Response, next: express.NextFunction) { -// const resultList = await VideoBlacklistModel.listForApi(req.query.start, req.query.count, req.query.sort) -// -// return res.json(getFormattedObjects(resultList.data, resultList.total)) -// } -// -// async function removeVideoFromBlacklistController (req: express.Request, res: express.Response, next: express.NextFunction) { -// const blacklistedVideo = res.locals.blacklistedVideo as VideoBlacklistModel -// -// try { -// await blacklistedVideo.destroy() -// -// logger.info('Video %s removed from blacklist.', res.locals.video.uuid) -// -// return res.sendStatus(204) -// } catch (err) { -// logger.error('Some error while removing video %s from blacklist.', res.locals.video.uuid, err) -// throw err -// } -// } +import * as express from 'express' +import { VideoCommentCreate } from '../../../../shared/models/videos/video-comment.model' +import { getFormattedObjects, retryTransactionWrapper } from '../../../helpers' +import { sequelizeTypescript } from '../../../initializers' +import { buildFormattedCommentTree, createVideoComment } from '../../../lib/video-comment' +import { asyncMiddleware, authenticate, paginationValidator, setPagination, setVideoCommentThreadsSort } from '../../../middlewares' +import { videoCommentThreadsSortValidator } from '../../../middlewares/validators' +import { + addVideoCommentReplyValidator, addVideoCommentThreadValidator, listVideoCommentThreadsValidator, + listVideoThreadCommentsValidator +} from '../../../middlewares/validators/video-comments' +import { VideoCommentModel } from '../../../models/video/video-comment' + +const videoCommentRouter = express.Router() + +videoCommentRouter.get('/:videoId/comment-threads', + paginationValidator, + videoCommentThreadsSortValidator, + setVideoCommentThreadsSort, + setPagination, + asyncMiddleware(listVideoCommentThreadsValidator), + asyncMiddleware(listVideoThreads) +) +videoCommentRouter.get('/:videoId/comment-threads/:threadId', + asyncMiddleware(listVideoThreadCommentsValidator), + asyncMiddleware(listVideoThreadComments) +) + +videoCommentRouter.post('/:videoId/comment-threads', + authenticate, + asyncMiddleware(addVideoCommentThreadValidator), + asyncMiddleware(addVideoCommentThreadRetryWrapper) +) +videoCommentRouter.post('/:videoId/comments/:commentId', + authenticate, + asyncMiddleware(addVideoCommentReplyValidator), + asyncMiddleware(addVideoCommentReplyRetryWrapper) +) + +// --------------------------------------------------------------------------- + +export { + videoCommentRouter +} + +// --------------------------------------------------------------------------- + +async function listVideoThreads (req: express.Request, res: express.Response, next: express.NextFunction) { + const resultList = await VideoCommentModel.listThreadsForApi(res.locals.video.id, req.query.start, req.query.count, req.query.sort) + + return res.json(getFormattedObjects(resultList.data, resultList.total)) +} + +async function listVideoThreadComments (req: express.Request, res: express.Response, next: express.NextFunction) { + const resultList = await VideoCommentModel.listThreadCommentsForApi(res.locals.video.id, res.locals.videoCommentThread.id) + + return res.json(buildFormattedCommentTree(resultList)) +} + +async function addVideoCommentThreadRetryWrapper (req: express.Request, res: express.Response, next: express.NextFunction) { + const options = { + arguments: [ req, res ], + errorMessage: 'Cannot insert the video comment thread with many retries.' + } + + const comment = await retryTransactionWrapper(addVideoCommentThread, options) + + res.json({ + comment: { + id: comment.id + } + }).end() +} + +function addVideoCommentThread (req: express.Request, res: express.Response) { + const videoCommentInfo: VideoCommentCreate = req.body + + return sequelizeTypescript.transaction(async t => { + return createVideoComment({ + text: videoCommentInfo.text, + inReplyToComment: null, + video: res.locals.video, + actorId: res.locals.oauth.token.User.Account.Actor.id + }, t) + }) +} + +async function addVideoCommentReplyRetryWrapper (req: express.Request, res: express.Response, next: express.NextFunction) { + const options = { + arguments: [ req, res ], + errorMessage: 'Cannot insert the video comment reply with many retries.' + } + + const comment = await retryTransactionWrapper(addVideoCommentReply, options) + + res.json({ + comment: { + id: comment.id + } + }).end() +} + +function addVideoCommentReply (req: express.Request, res: express.Response, next: express.NextFunction) { + const videoCommentInfo: VideoCommentCreate = req.body + + return sequelizeTypescript.transaction(async t => { + return createVideoComment({ + text: videoCommentInfo.text, + inReplyToComment: res.locals.videoComment.id, + video: res.locals.video, + actorId: res.locals.oauth.token.User.Account.Actor.id + }, t) + }) +} diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index 8283f2e4e..8e54d95ab 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts @@ -47,6 +47,7 @@ import { VideoFileModel } from '../../../models/video/video-file' import { abuseVideoRouter } from './abuse' import { blacklistRouter } from './blacklist' import { videoChannelRouter } from './channel' +import { videoCommentRouter } from './comment' import { rateVideoRouter } from './rate' const videosRouter = express.Router() @@ -78,6 +79,7 @@ videosRouter.use('/', abuseVideoRouter) videosRouter.use('/', blacklistRouter) videosRouter.use('/', rateVideoRouter) videosRouter.use('/', videoChannelRouter) +videosRouter.use('/', videoCommentRouter) videosRouter.get('/categories', listVideoCategories) videosRouter.get('/licences', listVideoLicences) diff --git a/server/helpers/custom-validators/video-comments.ts b/server/helpers/custom-validators/video-comments.ts new file mode 100644 index 000000000..2b3f66063 --- /dev/null +++ b/server/helpers/custom-validators/video-comments.ts @@ -0,0 +1,16 @@ +import 'express-validator' +import 'multer' +import * as validator from 'validator' +import { CONSTRAINTS_FIELDS } from '../../initializers' + +const VIDEO_COMMENTS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEO_COMMENTS + +function isValidVideoCommentText (value: string) { + return value === null || validator.isLength(value, VIDEO_COMMENTS_CONSTRAINTS_FIELDS.TEXT) +} + +// --------------------------------------------------------------------------- + +export { + isValidVideoCommentText +} diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index c8b21d10d..25b2dff84 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -26,6 +26,7 @@ const SORTABLE_COLUMNS = { VIDEO_ABUSES: [ 'id', 'createdAt' ], VIDEO_CHANNELS: [ 'id', 'name', 'updatedAt', 'createdAt' ], VIDEOS: [ 'name', 'duration', 'createdAt', 'views', 'likes' ], + VIDEO_COMMENT_THREADS: [ 'createdAt' ], BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ], FOLLOWERS: [ 'createdAt' ], FOLLOWING: [ 'createdAt' ] @@ -176,7 +177,8 @@ const CONSTRAINTS_FIELDS = { VIDEO_EVENTS: { COUNT: { min: 0 } }, - COMMENT: { + VIDEO_COMMENTS: { + TEXT: { min: 2, max: 3000 }, // Length URL: { min: 3, max: 2000 } // Length } } diff --git a/server/lib/activitypub/url.ts b/server/lib/activitypub/url.ts index bb2d4d11e..729bb8dda 100644 --- a/server/lib/activitypub/url.ts +++ b/server/lib/activitypub/url.ts @@ -3,17 +3,18 @@ import { ActorModel } from '../../models/activitypub/actor' import { ActorFollowModel } from '../../models/activitypub/actor-follow' import { VideoModel } from '../../models/video/video' import { VideoAbuseModel } from '../../models/video/video-abuse' +import { VideoCommentModel } from '../../models/video/video-comment' function getVideoActivityPubUrl (video: VideoModel) { return CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid } -function getVideoChannelActivityPubUrl (videoChannelUUID: string) { - return CONFIG.WEBSERVER.URL + '/video-channels/' + videoChannelUUID +function getVideoCommentActivityPubUrl (video: VideoModel, videoComment: VideoCommentModel) { + return CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid + '#comment-' + videoComment.id } -function getApplicationActivityPubUrl () { - return CONFIG.WEBSERVER.URL + '/application/peertube' +function getVideoChannelActivityPubUrl (videoChannelUUID: string) { + return CONFIG.WEBSERVER.URL + '/video-channels/' + videoChannelUUID } function getAccountActivityPubUrl (accountName: string) { @@ -63,7 +64,6 @@ function getUndoActivityPubUrl (originalUrl: string) { } export { - getApplicationActivityPubUrl, getVideoActivityPubUrl, getVideoChannelActivityPubUrl, getAccountActivityPubUrl, @@ -75,5 +75,6 @@ export { getUndoActivityPubUrl, getVideoViewActivityPubUrl, getVideoLikeActivityPubUrl, - getVideoDislikeActivityPubUrl + getVideoDislikeActivityPubUrl, + getVideoCommentActivityPubUrl } diff --git a/server/lib/video-comment.ts b/server/lib/video-comment.ts new file mode 100644 index 000000000..edb72d4e2 --- /dev/null +++ b/server/lib/video-comment.ts @@ -0,0 +1,74 @@ +import * as Sequelize from 'sequelize' +import { ResultList } from '../../shared/models' +import { VideoCommentThread } from '../../shared/models/videos/video-comment.model' +import { VideoModel } from '../models/video/video' +import { VideoCommentModel } from '../models/video/video-comment' +import { getVideoCommentActivityPubUrl } from './activitypub' + +async function createVideoComment (obj: { + text: string, + inReplyToComment: number, + video: VideoModel + actorId: number +}, t: Sequelize.Transaction) { + let originCommentId: number = null + if (obj.inReplyToComment) { + const repliedComment = await VideoCommentModel.loadById(obj.inReplyToComment) + if (!repliedComment) throw new Error('Unknown replied comment.') + + originCommentId = repliedComment.originCommentId || repliedComment.id + } + + const comment = await VideoCommentModel.create({ + text: obj.text, + originCommentId, + inReplyToComment: obj.inReplyToComment, + videoId: obj.video.id, + actorId: obj.actorId + }, { transaction: t }) + + comment.set('url', getVideoCommentActivityPubUrl(obj.video, comment)) + + return comment.save({ transaction: t }) +} + +function buildFormattedCommentTree (resultList: ResultList): VideoCommentThread { + // Comments are sorted by id ASC + const comments = resultList.data + + const comment = comments.shift() + const thread: VideoCommentThread = { + comment: comment.toFormattedJSON(), + children: [] + } + const idx = { + [comment.id]: thread + } + + while (comments.length !== 0) { + const childComment = comments.shift() + + const childCommentThread: VideoCommentThread = { + comment: childComment.toFormattedJSON(), + children: [] + } + + const parentCommentThread = idx[childComment.inReplyToCommentId] + if (!parentCommentThread) { + const msg = `Cannot format video thread tree, parent ${childComment.inReplyToCommentId} not found for child ${childComment.id}` + throw new Error(msg) + } + + parentCommentThread.children.push(childCommentThread) + idx[childComment.id] = childCommentThread + } + + return thread +} + +// --------------------------------------------------------------------------- + +export { + createVideoComment, + buildFormattedCommentTree +} diff --git a/server/middlewares/sort.ts b/server/middlewares/sort.ts index 5d2a43acc..0eb50db89 100644 --- a/server/middlewares/sort.ts +++ b/server/middlewares/sort.ts @@ -32,6 +32,12 @@ function setVideosSort (req: express.Request, res: express.Response, next: expre return next() } +function setVideoCommentThreadsSort (req: express.Request, res: express.Response, next: express.NextFunction) { + if (!req.query.sort) req.query.sort = '-createdAt' + + return next() +} + function setFollowersSort (req: express.Request, res: express.Response, next: express.NextFunction) { if (!req.query.sort) req.query.sort = '-createdAt' @@ -75,5 +81,6 @@ export { setBlacklistSort, setFollowersSort, setFollowingSort, - setJobsSort + setJobsSort, + setVideoCommentThreadsSort } diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts index 38184fefa..56855bda0 100644 --- a/server/middlewares/validators/sort.ts +++ b/server/middlewares/validators/sort.ts @@ -9,6 +9,7 @@ const SORTABLE_USERS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USERS) const SORTABLE_JOBS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.JOBS) const SORTABLE_VIDEO_ABUSES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_ABUSES) const SORTABLE_VIDEOS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS) +const SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS) const SORTABLE_BLACKLISTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.BLACKLISTS) const SORTABLE_VIDEO_CHANNELS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS) const SORTABLE_FOLLOWERS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.FOLLOWERS) @@ -18,6 +19,7 @@ const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS) const jobsSortValidator = checkSort(SORTABLE_JOBS_COLUMNS) const videoAbusesSortValidator = checkSort(SORTABLE_VIDEO_ABUSES_COLUMNS) const videosSortValidator = checkSort(SORTABLE_VIDEOS_COLUMNS) +const videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS) const blacklistSortValidator = checkSort(SORTABLE_BLACKLISTS_COLUMNS) const videoChannelsSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_COLUMNS) const followersSortValidator = checkSort(SORTABLE_FOLLOWERS_COLUMNS) @@ -33,7 +35,8 @@ export { blacklistSortValidator, followersSortValidator, followingSortValidator, - jobsSortValidator + jobsSortValidator, + videoCommentThreadsSortValidator } // --------------------------------------------------------------------------- diff --git a/server/middlewares/validators/video-comments.ts b/server/middlewares/validators/video-comments.ts new file mode 100644 index 000000000..5e1be00f2 --- /dev/null +++ b/server/middlewares/validators/video-comments.ts @@ -0,0 +1,131 @@ +import * as express from 'express' +import { body, param } from 'express-validator/check' +import { logger } from '../../helpers' +import { isIdOrUUIDValid, isIdValid } from '../../helpers/custom-validators/misc' +import { isValidVideoCommentText } from '../../helpers/custom-validators/video-comments' +import { isVideoExist } from '../../helpers/custom-validators/videos' +import { VideoCommentModel } from '../../models/video/video-comment' +import { areValidationErrors } from './utils' + +const listVideoCommentThreadsValidator = [ + param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking blacklistRemove parameters.', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await isVideoExist(req.params.videoId, res)) return + + return next() + } +] + +const listVideoThreadCommentsValidator = [ + param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), + param('threadId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid threadId'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking blacklistRemove parameters.', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await isVideoExist(req.params.videoId, res)) return + if (!await isVideoCommentThreadExist(req.params.threadId, req.params.videoId, res)) return + + return next() + } +] + +const addVideoCommentThreadValidator = [ + param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), + body('text').custom(isValidVideoCommentText).not().isEmpty().withMessage('Should have a valid comment text'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking blacklistRemove parameters.', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await isVideoExist(req.params.videoId, res)) return + + return next() + } +] + +const addVideoCommentReplyValidator = [ + param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), + param('commentId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid commentId'), + body('text').custom(isValidVideoCommentText).not().isEmpty().withMessage('Should have a valid comment text'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking blacklistRemove parameters.', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await isVideoExist(req.params.videoId, res)) return + if (!await isVideoCommentExist(req.params.commentId, req.params.videoId, res)) return + + return next() + } +] + +// --------------------------------------------------------------------------- + +export { + listVideoCommentThreadsValidator, + listVideoThreadCommentsValidator, + addVideoCommentThreadValidator, + addVideoCommentReplyValidator +} + +// --------------------------------------------------------------------------- + +async function isVideoCommentThreadExist (id: number, videoId: number, res: express.Response) { + const videoComment = await VideoCommentModel.loadById(id) + + if (!videoComment) { + res.status(404) + .json({ error: 'Video comment thread not found' }) + .end() + + return false + } + + if (videoComment.videoId !== videoId) { + res.status(400) + .json({ error: 'Video comment is associated to this video.' }) + .end() + + return false + } + + if (videoComment.inReplyToCommentId !== null) { + res.status(400) + .json({ error: 'Video comment is not a thread.' }) + .end() + + return false + } + + res.locals.videoCommentThread = videoComment + return true +} + +async function isVideoCommentExist (id: number, videoId: number, res: express.Response) { + const videoComment = await VideoCommentModel.loadById(id) + + if (!videoComment) { + res.status(404) + .json({ error: 'Video comment thread not found' }) + .end() + + return false + } + + if (videoComment.videoId !== videoId) { + res.status(400) + .json({ error: 'Video comment is associated to this video.' }) + .end() + + return false + } + + res.locals.videoComment = videoComment + return true +} diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts index 92c0c6112..d66f933ee 100644 --- a/server/models/video/video-comment.ts +++ b/server/models/video/video-comment.ts @@ -1,19 +1,34 @@ import * as Sequelize from 'sequelize' import { - AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, IFindOptions, Is, IsUUID, Model, Table, + AfterDestroy, AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, IFindOptions, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' +import { VideoComment } from '../../../shared/models/videos/video-comment.model' import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub' import { CONSTRAINTS_FIELDS } from '../../initializers' import { ActorModel } from '../activitypub/actor' -import { throwIfNotValid } from '../utils' +import { getSort, throwIfNotValid } from '../utils' import { VideoModel } from './video' +enum ScopeNames { + WITH_ACTOR = 'WITH_ACTOR' +} + +@Scopes({ + [ScopeNames.WITH_ACTOR]: { + include: [ + () => ActorModel + ] + } +}) @Table({ tableName: 'videoComment', indexes: [ { fields: [ 'videoId' ] + }, + { + fields: [ 'videoId', 'originCommentId' ] } ] }) @@ -81,6 +96,24 @@ export class VideoCommentModel extends Model { }) Actor: ActorModel + @AfterDestroy + static sendDeleteIfOwned (instance: VideoCommentModel) { + // TODO + return undefined + } + + static loadById (id: number, t?: Sequelize.Transaction) { + const query: IFindOptions = { + where: { + id + } + } + + if (t !== undefined) query.transaction = t + + return VideoCommentModel.findOne(query) + } + static loadByUrl (url: string, t?: Sequelize.Transaction) { const query: IFindOptions = { where: { @@ -92,4 +125,55 @@ export class VideoCommentModel extends Model { return VideoCommentModel.findOne(query) } + + static listThreadsForApi (videoId: number, start: number, count: number, sort: string) { + const query = { + offset: start, + limit: count, + order: [ getSort(sort) ], + where: { + videoId + } + } + + return VideoCommentModel + .scope([ ScopeNames.WITH_ACTOR ]) + .findAndCountAll(query) + .then(({ rows, count }) => { + return { total: count, data: rows } + }) + } + + static listThreadCommentsForApi (videoId: number, threadId: number) { + const query = { + order: [ 'id', 'ASC' ], + where: { + videoId, + [ Sequelize.Op.or ]: [ + { id: threadId }, + { originCommentId: threadId } + ] + } + } + + return VideoCommentModel + .scope([ ScopeNames.WITH_ACTOR ]) + .findAndCountAll(query) + .then(({ rows, count }) => { + return { total: count, data: rows } + }) + } + + toFormattedJSON () { + return { + id: this.id, + url: this.url, + text: this.text, + threadId: this.originCommentId || this.id, + inReplyToCommentId: this.inReplyToCommentId, + videoId: this.videoId, + createdAt: this.createdAt, + updatedAt: this.updatedAt + } as VideoComment + } } diff --git a/shared/models/videos/video-comment.model.ts b/shared/models/videos/video-comment.model.ts new file mode 100644 index 000000000..bdeb30d28 --- /dev/null +++ b/shared/models/videos/video-comment.model.ts @@ -0,0 +1,19 @@ +export interface VideoComment { + id: number + url: string + text: string + threadId: number + inReplyToCommentId: number + videoId: number + createdAt: Date | string + updatedAt: Date | string +} + +export interface VideoCommentThread { + comment: VideoComment + children: VideoCommentThread[] +} + +export interface VideoCommentCreate { + text: string +}