diff --git a/.eslintrc.js b/.eslintrc.js index dbbbfc0a7..051624ad8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -736,8 +736,8 @@ module.exports = { 'no-underscore-dangle': [2, { 'allow': [ '_id', '__get__', '__set__', '__RewireAPI__', '__Rewire__', '__ResetDependency__', '__GetDependency__', ] }], - // Max assertions is 10 and warning rather than error. - 'jest/max-expects': [1, { 'max': 10 }], + // Max assertions is 20 and warning rather than error. + 'jest/max-expects': [1, { 'max': 20 }], // We are not using TypeScript 'jest/no-untyped-mock-factory': 0, }, diff --git a/controllers/__tests__/comment.test.js b/controllers/__tests__/comment.test.js new file mode 100644 index 000000000..23c25a7f6 --- /dev/null +++ b/controllers/__tests__/comment.test.js @@ -0,0 +1,121 @@ +/** + * Copyright: The PastVu contributors. + * GNU Affero General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/agpl.txt) + */ + +import { CommentN } from '../../models/Comment'; +import admin from '../admin'; +import comment from '../comment'; +import testHelpers from '../../tests/testHelpers'; + +describe('comment', () => { + beforeEach(async () => { + // Mock non-registerd user handshake. + admin.handshake = { 'usObj': { 'isAdmin': true } }; + }); + + afterEach(() => { + // Delete handshake. + delete admin.handshake; + }); + + describe('create for news', () => { + let news; + + beforeEach(async () => { + const data = { pdate: new Date(), 'title': 'Test news', 'txt': 'Test news content' }; + + ({ news } = await admin.saveOrCreateNews(data)); + + const user = await testHelpers.createUser({ login: 'user1', pass: 'pass1' }); + + // Mock non-registered user handshake. + comment.handshake = { 'usObj': { 'isAdmin': true, 'registered': true, user } }; + }); + + afterEach(() => { + // Delete handshake. + delete comment.handshake; + }); + + it('create', async () => { + expect.assertions(3); + + const data = { txt: 'news comment', type: 'news', obj: news.cid }; + + // Create two comments. + const result = await comment.create(data); + + expect(result.comment.txt).toMatch(data.txt); + expect(result.comment.user).toMatch('user1'); + + await expect(CommentN.count({ obj: news })).resolves.toBe(1); + }); + }); + + describe('retrive', () => { + let news; + + beforeEach(async () => { + const data = { pdate: new Date(), 'title': 'Test news', 'txt': 'Test news content' }; + + ({ news } = await admin.saveOrCreateNews(data)); + + const user = await testHelpers.createUser({ login: 'user1', pass: 'pass1' }); + + // Mock non-registered user handshake. + comment.handshake = { 'usObj': { 'isAdmin': true, 'registered': true, user } }; + }); + + afterEach(() => { + // Delete handshake. + delete comment.handshake; + }); + + it('give news comments for user', async () => { + expect.assertions(17); + + const data = { txt: 'news comment', type: 'news', obj: news.cid }; + + // Create 4 comments. + const comment0 = await comment.create(data); + const comment1 = await comment.create(data); + + data.parent = comment1.comment.cid; + data.level = comment1.comment.level + 1; + + const comment2 = await comment.create(data); + + data.parent = comment2.comment.cid; + data.level = comment2.comment.level + 1; + + const comment3 = await comment.create(data); + + // Sanity check. + await expect(CommentN.count({ obj: news })).resolves.toBe(4); + + const comments = await comment.giveForUser({ login: 'user1', type: 'news' }); + + expect(comments.type).toMatch('news'); + expect(comments.countActive).toBe(4); + expect(comments.objs[news.cid].cid).toStrictEqual(news.cid); + expect(comments.objs[news.cid].ccount).toBe(4); + // Comment 0 - no child, waits answer. + expect(comments.comments[3].cid).toStrictEqual(comment0.comment.cid); + expect(comments.comments[3].hasChild).toBeFalsy(); + expect(comments.comments[3].waitsAnswer).toBeTruthy(); + // Comment 1 - has child, does not wait answer. + expect(comments.comments[2].cid).toStrictEqual(comment1.comment.cid); + expect(comments.comments[2].hasChild).toBeTruthy(); + expect(comments.comments[2].waitsAnswer).toBeFalsy(); + // Comment 2 - has child, does not wait answer. + expect(comments.comments[1].cid).toStrictEqual(comment2.comment.cid); + expect(comments.comments[1].hasChild).toBeTruthy(); + expect(comments.comments[1].waitsAnswer).toBeFalsy(); + // Comment 3 - no child, waits answer. + expect(comments.comments[0].cid).toStrictEqual(comment3.comment.cid); + expect(comments.comments[0].hasChild).toBeFalsy(); + expect(comments.comments[0].waitsAnswer).toBeTruthy(); + }); + }); +}); diff --git a/controllers/comment.js b/controllers/comment.js index 7bc151bba..cd0e01ecb 100644 --- a/controllers/comment.js +++ b/controllers/comment.js @@ -722,9 +722,36 @@ async function giveForUser({ login, page = 1, type = 'photo', active = true, del } const fields = { _id: 0, lastChanged: 1, cid: 1, obj: 1, stamp: 1, txt: 1, 'del.origin': 1 }; - const options = { lean: true, sort: { stamp: -1 }, skip: page * commentsUserPerPage, limit: commentsUserPerPage }; + const options = { sort: { stamp: -1 }, skip: page * commentsUserPerPage, limit: commentsUserPerPage }; - comments = await commentModel.find(query, fields, options).exec(); + if (!iAm.registered) { + comments = await commentModel.find(query, fields, options).lean().exec(); + } else { + fields.hasChild = 1; + comments = await commentModel.aggregate([ + { + '$match': query, + }, + { + '$lookup': { + 'from': commentModel.collection.collectionName, + 'localField': 'cid', + 'foreignField': 'parent', + 'as': 'children', + }, + }, + { + '$addFields': { + 'hasChild': { + $gt: [{ $size: '$children' }, 0], + }, + }, + }, + { + '$project': fields, + }, + ]).sort(options.sort).skip(options.skip).limit(options.limit).exec(); + } } if (_.isEmpty(comments)) { @@ -757,18 +784,6 @@ async function giveForUser({ login, page = 1, type = 'photo', active = true, del if (type === 'photo' && iAm.registered) { await this.call('photo.fillPhotosProtection', { photos: objs, setMyFlag: true }); - - for (const obj of objs) { - objFormattedHashCid[obj.cid] = objFormattedHashId[obj._id] = obj; - obj._id = undefined; - obj.user = undefined; - obj.mime = undefined; - } - } else { - for (const obj of objs) { - objFormattedHashCid[obj.cid] = objFormattedHashId[obj._id] = obj; - obj._id = undefined; - } } for (const obj of objs) { @@ -780,6 +795,9 @@ async function giveForUser({ login, page = 1, type = 'photo', active = true, del // For each comment check object exists and assign to comment its cid for (const comment of comments) { + // Mark those awaiting response. + comment.waitsAnswer = comment.hasChild !== undefined && !comment.hasChild; + const obj = objFormattedHashId[comment.obj]; if (obj !== undefined) { @@ -926,7 +944,7 @@ async function create(data) { throw obj.nocomments ? new NoticeError(constantsError.COMMENT_NOT_ALLOWED) : new AuthorizationError(); } - if (data.parent && (!parent || parent.del || parent.level >= 9 || data.level !== (parent.level || 0) + 1)) { + if (data.parent && (!parent || parent.del || parent.level >= 9 || data.level !== parent.level + 1)) { throw new NoticeError(constantsError.COMMENT_WRONG_PARENT); } @@ -952,9 +970,11 @@ async function create(data) { } } + comment.level = data.level ?? 0; + if (data.parent) { comment.parent = data.parent; - comment.level = data.level; + comment.level = data.level ?? parent.level + 1; } if (fragAdded) { @@ -998,10 +1018,6 @@ async function create(data) { comment.obj = objCid; comment.can = {}; - if (comment.level === undefined) { - comment.level = 0; - } - session.emitUser({ usObj: iAm, excludeSocket: socket }); subscrController.commentAdded(obj._id, iAm.user, stamp); diff --git a/package-lock.json b/package-lock.json index 31e70fd1d..f58221673 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "PastVu", - "version": "2.0.41", + "version": "2.0.42", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "PastVu", - "version": "2.0.41", + "version": "2.0.42", "dependencies": { "@mapbox/geojson-area": "0.2.2", "@mapbox/geojson-rewind": "0.5.0", @@ -15555,9 +15555,9 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/msgpackr": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.9.5.tgz", - "integrity": "sha512-/IJ3cFSN6Ci3eG2wLhbFEL6GT63yEaoN/R5My2QkV6zro+OJaVRLPlwvxY7EtHYSmDlQpk8stvOQTL2qJFkDRg==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.10.1.tgz", + "integrity": "sha512-r5VRLv9qouXuLiIBrLpl2d5ZvPt8svdQTl5/vMvE4nzDMyEX4sgW5yWhuBBj5UmgwOTWj8CIdSXn5sAfsHAWIQ==", "optionalDependencies": { "msgpackr-extract": "^3.0.2" } @@ -31512,9 +31512,9 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "msgpackr": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.9.5.tgz", - "integrity": "sha512-/IJ3cFSN6Ci3eG2wLhbFEL6GT63yEaoN/R5My2QkV6zro+OJaVRLPlwvxY7EtHYSmDlQpk8stvOQTL2qJFkDRg==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.10.1.tgz", + "integrity": "sha512-r5VRLv9qouXuLiIBrLpl2d5ZvPt8svdQTl5/vMvE4nzDMyEX4sgW5yWhuBBj5UmgwOTWj8CIdSXn5sAfsHAWIQ==", "requires": { "msgpackr-extract": "^3.0.2" } diff --git a/package.json b/package.json index 5bc099103..ae37ccd0b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "PastVu", - "version": "2.0.41", + "version": "2.0.42", "description": "Retrospective topography of media content", "homepage": "https://github.com/pastvu/pastvu", "keywords": [ diff --git a/public/js/lib/leaflet/extends/L.Google.js b/public/js/lib/leaflet/extends/L.Google.js index 8468840c2..58e1b7e7f 100644 --- a/public/js/lib/leaflet/extends/L.Google.js +++ b/public/js/lib/leaflet/extends/L.Google.js @@ -4,15 +4,17 @@ */ // Google layer plugin wrapper. Loads Google Maps library with API key included. -define(['Params', 'leaflet', 'leaflet-plugins/lru', 'leaflet-plugins/Leaflet.GoogleMutant'], function (P, L) { +define(['Params', 'leaflet', 'leaflet-plugins/lru', 'leaflet-plugins/Leaflet.GoogleMutant'], function (P, L, lru) { const keyParam = P.settings.publicApiKeys.googleMaps.length ? '&key=' + P.settings.publicApiKeys.googleMaps : ''; const url = 'https://maps.googleapis.com/maps/api/js?v=weekly®ion=RU' + keyParam; // Load Google Maps API library asynchronously. - require(['async!' + url]); - + L.GridLayer.GoogleMutant.addInitHook(function() { + require(['async!' + url]); + }); return function (options) { options = options || {}; + options.lru = new lru.LRUMap(100); // Tile LRU cache. return new L.GridLayer.GoogleMutant(options); }; diff --git a/public/js/lib/leaflet/plugins/Leaflet.GoogleMutant.js b/public/js/lib/leaflet/plugins/Leaflet.GoogleMutant.js index bff6ddc18..8e2104b6e 100644 --- a/public/js/lib/leaflet/plugins/Leaflet.GoogleMutant.js +++ b/public/js/lib/leaflet/plugins/Leaflet.GoogleMutant.js @@ -11,8 +11,7 @@ this stuff is worth it, you can buy me a beer in return. */ //import { LRUMap } from "./lru_map.js"; -// We have to use requirejs to load module, ES6 syntax won't work here. -const { LRUMap } = require('leaflet-plugins/lru'); +// LRU is passed in options. function waitForAPI(callback, context) { let checkCounter = 0, @@ -47,7 +46,7 @@ L.GridLayer.GoogleMutant = L.GridLayer.extend({ // Couple data structures indexed by tile key this._tileCallbacks = {}; // Callbacks for promises for tiles that are expected - this._lru = new LRUMap(100); // Tile LRU cache + this._lru = options.lru; // Tile LRU cache this._imagesPerTile = this.options.type === "hybrid" ? 2 : 1; diff --git a/public/style/comment/comments.less b/public/style/comment/comments.less index c15e43c04..eecb5321c 100644 --- a/public/style/comment/comments.less +++ b/public/style/comment/comments.less @@ -1,7 +1,6 @@ @import '../_vars.less'; @import '../bs/variables.less'; @import '../bs/mixins.less'; -@import '../bs/badges.less'; @headColor: #677A8F; @headColorHover: @MainBlueColor; @@ -136,13 +135,6 @@ .levelLooping(0, 58); - .badge { - font-size: 11px; - font-weight: normal; - color: #f2f2f2; - background-color: rgba(85, 85, 85, 80%); - } - &.isnew { padding-left: 5px; border-width: 0 0 0 1px; diff --git a/public/style/common.less b/public/style/common.less index 933d91bda..9a065caef 100755 --- a/public/style/common.less +++ b/public/style/common.less @@ -1,6 +1,7 @@ @import '_vars.less'; @import 'fonts/fontU.less'; @import 'bs/bootstrap.less'; +@import 'bs/badges.less'; @-webkit-keyframes fadeIn { 0% { opacity: 0; } @@ -480,6 +481,14 @@ body { } } +// Badges +.badge-latest { + font-size: 11px; + font-weight: normal; + color: #f2f2f2; + background-color: rgba(85, 85, 85, 80%); +} + // Tooltip .tltp { position: absolute; diff --git a/views/module/user/comments.pug b/views/module/user/comments.pug index 9090f58bf..b8e347a31 100644 --- a/views/module/user/comments.pug +++ b/views/module/user/comments.pug @@ -45,6 +45,10 @@ .dotDelimeter · .commentChanged(title="Показать историю изменений", data-bind="text: ($data.del ? 'Удален ' : 'Изменен ') + moment($data.lastChanged).calendar().toLowerCase(), click: function () {$parent.showHistory($data.obj.cid, $data.cid)}") // /ko + //ko if: $data.waitsAnswer + .dotDelimeter · + .badge.badge-latest Ждёт ответа + // /ko a.commentText(data-bind="attr: {href: $data.link}, html: $data.txt") | @@ -58,6 +62,10 @@ .dotDelimeter · .commentChanged(title="Показать историю изменений", data-bind="text: ($data.del ? 'Удален ' : 'Изменен ') + moment($data.lastChanged).calendar().toLowerCase(), click: function () {$parent.showHistory($data.obj.cid, $data.cid)}") // /ko + //ko if: $data.waitsAnswer + .dotDelimeter · + .badge.badge-latest Ждёт ответа + // /ko a.commentText(style="margin-left:29px", data-bind="attr: {href: $data.link}, html: $data.txt") | @@ -70,4 +78,4 @@ // /ko li.edge(data-bind="css: {disabled: !pageHasNext()}"): a(data-bind="attr: {href: pageUrl() + '/' + (page() + 1) + pageQuery()}", title="Следующая страница") » li.edge(data-bind="css: {disabled: page() === pageLast()}"): a(data-bind="attr: {href: pageUrl() + '/' + pageLast() + pageQuery()}", title="Последняя страница") »» - | \ No newline at end of file + |