diff --git a/docs/docs/configuration/client.rst b/docs/docs/configuration/client.rst index 09e4e0f..fec0c36 100644 --- a/docs/docs/configuration/client.rst +++ b/docs/docs/configuration/client.rst @@ -10,6 +10,9 @@ preferably in the script tag which embeds the JS: data-isso-css="true" data-isso-lang="ru" data-isso-reply-to-self="false" + data-isso-max-comments-top="10" + data-isso-max-comments-nested="5" + data-isso-reveal-on-click="5" data-avatar-bg="#f0f0f0" data-avatar-fg="#9abf88 #5698c4 #e279a3 #9163b6 ..." src="/prefix/js/embed.js"> @@ -53,6 +56,19 @@ data-isso-reply-to-self Set to `true` when spam guard is configured with `reply-to-self = true`. +data-isso-max-comments-top and data-isso-max-comments-nested +------------------------------------------------------------ + +Number of top level (or nested) comments to show by default. If some +comments are not shown, an "X Hidden" link is shown. + +Set to `"inf"` to show all, or `"0"` to hide all. + +data-isso-reveal-on-click +------------------------- + +Number of comments to reveal on clicking the "X Hidden" link. + data-isso-avatar-bg ------------------- diff --git a/isso/db/comments.py b/isso/db/comments.py index 8c97121..350a3c9 100644 --- a/isso/db/comments.py +++ b/isso/db/comments.py @@ -97,15 +97,31 @@ class Comments: return None - def fetch(self, uri, mode=5): + def fetch(self, uri, mode=5, after=0, parent='any', order_by='id', limit=None): """ Return comments for :param:`uri` with :param:`mode`. """ - rv = self.db.execute([ - 'SELECT comments.* FROM comments INNER JOIN threads ON', - ' threads.uri=? AND comments.tid=threads.id AND (? | comments.mode) = ?' - 'ORDER BY id ASC;'], (uri, mode, mode)).fetchall() + sql = [ 'SELECT comments.* FROM comments INNER JOIN threads ON', + ' threads.uri=? AND comments.tid=threads.id AND (? | comments.mode) = ?', + ' AND comments.created>?'] + sql_args = [uri, mode, mode, after] + + if parent != 'any': + if parent is None: + sql.append('AND comments.parent IS NULL') + else: + sql.append('AND comments.parent=?') + sql_args.append(parent) + + sql.append('ORDER BY ? ASC') + sql_args.append(order_by) + + if limit: + sql.append('LIMIT ?') + sql_args.append(limit) + + rv = self.db.execute(sql, sql_args).fetchall() for item in rv: yield dict(zip(Comments.fields, item)) @@ -181,6 +197,17 @@ class Comments: return {'likes': likes + 1, 'dislikes': dislikes} return {'likes': likes, 'dislikes': dislikes + 1} + def reply_count(self, url, after=0): + """ + Return comment count for main thread and all reply threads for one url. + """ + + sql = [ 'SELECT comments.parent,count(*) FROM comments INNER JOIN threads ON', + ' threads.uri=? AND comments.tid=threads.id', + ' AND comments.mode = 1 AND comments.created>? GROUP BY comments.parent;'] + + return dict(self.db.execute(sql, [url, after]).fetchall()) + def count(self, *urls): """ Return comment count for one ore more urls.. diff --git a/isso/js/app/api.js b/isso/js/app/api.js index 73c2a88..1108c0a 100644 --- a/isso/js/app/api.js +++ b/isso/js/app/api.js @@ -78,7 +78,8 @@ define(["app/lib/promise", "app/globals"], function(Q, globals) { var qs = function(params) { var rv = ""; for (var key in params) { - if (params.hasOwnProperty(key) && params[key]) { + if (params.hasOwnProperty(key) && + params[key] !== null && typeof(params[key]) !== "undefined") { rv += key + "=" + encodeURIComponent(params[key]) + "&"; } } @@ -128,17 +129,31 @@ define(["app/lib/promise", "app/globals"], function(Q, globals) { return deferred.promise; }; - var fetch = function(tid) { + var fetch = function(tid, limit, nested_limit, parent, lastcreated) { + if (typeof(limit) === 'undefined') { limit = "inf"; } + if (typeof(nested_limit) === 'undefined') { nested_limit = "inf"; } + if (typeof(parent) === 'undefined') { parent = null; } + + var query_dict = {uri: tid || location, after: lastcreated, parent: parent}; + + if(limit !== "inf") { + query_dict['limit'] = limit; + } + if(nested_limit !== "inf"){ + query_dict['nested_limit'] = nested_limit; + } + var deferred = Q.defer(); - curl("GET", endpoint + "/?" + qs({uri: tid || location}), null, function(rv) { - if (rv.status === 200) { - deferred.resolve(JSON.parse(rv.body)); - } else if (rv.status === 404) { - deferred.resolve([]); - } else { - deferred.reject(rv.body); - } - }); + curl("GET", endpoint + "/?" + + qs(query_dict), null, function(rv) { + if (rv.status === 200) { + deferred.resolve(JSON.parse(rv.body)); + } else if (rv.status === 404) { + deferred.resolve({total_replies: 0}); + } else { + deferred.reject(rv.body); + } + }); return deferred.promise; }; diff --git a/isso/js/app/config.js b/isso/js/app/config.js index 3ebc5c7..aa092ed 100644 --- a/isso/js/app/config.js +++ b/isso/js/app/config.js @@ -5,6 +5,9 @@ define(function() { "css": true, "lang": (navigator.language || navigator.userLanguage).split("-")[0], "reply-to-self": false, + "max-comments-top": 10, + "max-comments-nested": 5, + "reveal-on-click": 5, "avatar-bg": "#f0f0f0", "avatar-fg": ["#9abf88", "#5698c4", "#e279a3", "#9163b6", "#be5168", "#f19670", "#e4bf80", "#447c69"].join(" ") diff --git a/isso/js/app/i18n/de.js b/isso/js/app/i18n/de.js index dcdbac3..98c4d61 100644 --- a/isso/js/app/i18n/de.js +++ b/isso/js/app/i18n/de.js @@ -15,6 +15,7 @@ define({ "comment-deleted": "Kommentar gelöscht.", "comment-queued": "Kommentar muss noch freigeschaltet werden.", "comment-anonymous": "Anonym", + "comment-hidden": "{{ hidden_replies }} versteckt", "date-now": "eben jetzt", "date-minute": "vor einer Minute\nvor {{ n }} Minuten", "date-hour": "vor einer Stunde\nvor {{ n }} Stunden", diff --git a/isso/js/app/i18n/en.js b/isso/js/app/i18n/en.js index 40b5bc9..6cc1d45 100644 --- a/isso/js/app/i18n/en.js +++ b/isso/js/app/i18n/en.js @@ -17,6 +17,7 @@ define({ "comment-deleted": "Comment deleted.", "comment-queued": "Comment in queue for moderation.", "comment-anonymous": "Anonymous", + "comment-hidden": "{{ hidden_replies }} Hidden", "date-now": "right now", "date-minute": "a minute ago\n{{ n }} minutes ago", diff --git a/isso/js/app/i18n/fr.js b/isso/js/app/i18n/fr.js index db72936..19d71a6 100644 --- a/isso/js/app/i18n/fr.js +++ b/isso/js/app/i18n/fr.js @@ -15,6 +15,7 @@ define({ "comment-deleted": "Commentaire supprimé.", "comment-queued": "Commentaire en attente de modération.", "comment-anonymous": "Anonyme", + "comment-hidden": "1 caché\n{{ hidden_replies }} cachés", "date-now": "À l'instant'", "date-minute": "Il y a une minute \n{{ n }} minutes", "date-hour": "Il y a une heure\n{{ n }} heures ", diff --git a/isso/js/app/isso.js b/isso/js/app/isso.js index e903b6a..da78ea0 100644 --- a/isso/js/app/isso.js +++ b/isso/js/app/isso.js @@ -84,8 +84,54 @@ define(["app/text/html", "app/dom", "app/utils", "app/config", "app/api", "app/m return el; }; - var insert = function(comment, scrollIntoView) { + var insert_loader = function(commentWrapper, lastcreated) { + var entrypoint; + if (commentWrapper.id === null) { + entrypoint = $("#isso-root"); + commentWrapper.name = 'null'; + } else { + entrypoint = $("#isso-" + commentWrapper.id + " > .text-wrapper > .isso-follow-up"); + commentWrapper.name = commentWrapper.id; + } + var el = $.htmlify(Mark.up(templates["comment_loader"], commentWrapper)); + entrypoint.append(el); + + $("a.load_hidden", el).toggle("click", + function() { + el.remove(); + api.fetch($("#isso-thread").getAttribute("data-isso-id"), + config["reveal-on-click"], config["max-comments-nested"], + commentWrapper.id, + lastcreated).then( + function(rv) { + if (rv.total_replies === 0) { + return; + } + + var lastcreated = 0; + rv.replies.forEach(function(commentObject) { + insert(commentObject, false); + if(commentObject.created > lastcreated) { + lastcreated = commentObject.created; + } + }); + + if(rv.hidden_replies > 0) { + insert_loader(rv, lastcreated); + } + + if (window.location.hash.length > 0) { + $(window.location.hash).scrollIntoView(); + } + }, + function(err) { + console.log(err); + }); + }); + }; + + var insert = function(comment, scrollIntoView) { var el = $.htmlify(Mark.up(templates["comment"], comment)); // update datetime every 60 seconds @@ -126,13 +172,13 @@ define(["app/text/html", "app/dom", "app/utils", "app/config", "app/api", "app/m $("a.reply", footer).textContent = msgs["comment-close"]; }, function() { - form.remove() + form.remove(); $("a.reply", footer).textContent = msgs["comment-reply"]; } ); // update vote counter, but hide if votes sum to 0 - var votes = function(value) { + var votes = function(value) { var span = $("span.votes", footer); if (span === null && value !== 0) { footer.prepend($.new("span.votes", value)); @@ -261,10 +307,27 @@ define(["app/text/html", "app/dom", "app/utils", "app/config", "app/api", "app/m if (! config["reply-to-self"] && utils.cookie("isso-" + comment.id)) { show($("a.reply", footer).detach()); } + + if(comment.hasOwnProperty('replies')) { + var lastcreated = 0; + comment.replies.forEach(function(replyObject) { + insert(replyObject, false); + if(replyObject.created > lastcreated) { + lastcreated = replyObject.created; + } + + }); + if(comment.hidden_replies > 0) { + insert_loader(comment, lastcreated); + } + + } + }; return { insert: insert, + insert_loader: insert_loader, Postbox: Postbox }; }); diff --git a/isso/js/app/text/comment-loader.html b/isso/js/app/text/comment-loader.html new file mode 100644 index 0000000..2e37e4b --- /dev/null +++ b/isso/js/app/text/comment-loader.html @@ -0,0 +1,3 @@ +
+ {{ i18n-comment-hidden | pluralize : `hidden_replies` }} +
\ No newline at end of file diff --git a/isso/js/app/text/html.js b/isso/js/app/text/html.js index eed9515..cf2c865 100644 --- a/isso/js/app/text/html.js +++ b/isso/js/app/text/html.js @@ -1,6 +1,7 @@ -define(["text!./postbox.html", "text!./comment.html"], function (postbox, comment) { +define(["text!./postbox.html", "text!./comment.html", "text!./comment-loader.html"], function (postbox, comment, comment_loader) { return { postbox: postbox, - comment: comment + comment: comment, + comment_loader: comment_loader }; }); diff --git a/isso/js/embed.js b/isso/js/embed.js index 6c6fbfc..6e6d8de 100644 --- a/isso/js/embed.js +++ b/isso/js/embed.js @@ -26,16 +26,28 @@ require(["app/lib/ready", "app/config", "app/api", "app/isso", "app/count", "app $("#isso-thread").append(new isso.Postbox(null)); $("#isso-thread").append('
'); - api.fetch($("#isso-thread").getAttribute("data-isso-id")).then( + api.fetch($("#isso-thread").getAttribute("data-isso-id"), + config["max-comments-top"], + config["max-comments-nested"]).then( function(rv) { - if (! rv.length) { + if (rv.total_replies === 0) { $("#isso-thread > h4").textContent = Mark.up("{{ i18n-no-comments }}"); return; } - $("#isso-thread > h4").textContent = Mark.up("{{ i18n-num-comments | pluralize : `n` }}", {n: rv.length}); - for (var i=0; i < rv.length; i++) { - isso.insert(rv[i], false); + var lastcreated = 0; + var total_count = rv.total_replies; + rv.replies.forEach(function(commentObject) { + isso.insert(commentObject, false); + if(commentObject.created > lastcreated) { + lastcreated = commentObject.created; + } + total_count = total_count + commentObject.total_replies; + }); + $("#isso-thread > h4").textContent = Mark.up("{{ i18n-num-comments | pluralize : `n` }}", {n: total_count}); + + if(rv.hidden_replies > 0) { + isso.insert_loader(rv, lastcreated); } if (window.location.hash.length > 0) { diff --git a/isso/tests/test_comments.py b/isso/tests/test_comments.py index 5124743..17a8b79 100644 --- a/isso/tests/test_comments.py +++ b/isso/tests/test_comments.py @@ -103,7 +103,7 @@ class TestComments(unittest.TestCase): self.assertEqual(r.status_code, 200) rv = loads(r.data) - self.assertEqual(len(rv), 20) + self.assertEqual(len(rv['replies']), 20) def testCreateInvalidParent(self): @@ -138,6 +138,40 @@ class TestComments(unittest.TestCase): self.assertEqual(self.get('/?uri=%2Fpath%2Fspam%2F&id=123').status_code, 404) self.assertEqual(self.get('/?uri=?uri=%foo%2F').status_code, 404) + def testGetLimited(self): + + for i in range(20): + self.post('/new?uri=test', data=json.dumps({'text': '...'})) + + r = self.get('/?uri=test&limit=10') + self.assertEqual(r.status_code, 200) + + rv = loads(r.data) + self.assertEqual(len(rv['replies']), 10) + + def testGetNested(self): + + self.post('/new?uri=test', data=json.dumps({'text': '...'})) + self.post('/new?uri=test', data=json.dumps({'text': '...', 'parent': 1})) + + r = self.get('/?uri=test&parent=1') + self.assertEqual(r.status_code, 200) + + rv = loads(r.data) + self.assertEqual(len(rv['replies']), 1) + + def testGetLimitedNested(self): + + self.post('/new?uri=test', data=json.dumps({'text': '...'})) + for i in range(20): + self.post('/new?uri=test', data=json.dumps({'text': '...', 'parent': 1})) + + r = self.get('/?uri=test&parent=1&limit=10') + self.assertEqual(r.status_code, 200) + + rv = loads(r.data) + self.assertEqual(len(rv['replies']), 10) + def testUpdate(self): self.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Lorem ipsum ...'})) diff --git a/isso/views/comments.py b/isso/views/comments.py index eac16ff..207c5c0 100644 --- a/isso/views/comments.py +++ b/isso/views/comments.py @@ -320,11 +320,81 @@ class API(object): @requires(str, 'uri') def fetch(self, environ, request, uri): - rv = list(self.comments.fetch(uri)) - if not rv: - raise NotFound + args = { + 'uri': uri, + 'after': request.args.get('after', 0) + } - for item in rv: + try: + args['limit'] = int(request.args.get('limit')) + except TypeError: + args['limit'] = None + except ValueError: + return BadRequest("limit should be integer") + + if request.args.get('parent') is not None: + try: + args['parent'] = int(request.args.get('parent')) + root_id = args['parent'] + except ValueError: + return BadRequest("parent should be integer") + else: + args['parent'] = None + root_id = None + + plain = request.args.get('plain', '0') == '0' + + reply_counts = self.comments.reply_count(uri, args['after']) + + if args['limit'] == 0: + root_list = [] + else: + root_list = list(self.comments.fetch(**args)) + if not root_list: + raise NotFound + + if root_id not in reply_counts: + reply_counts[root_id] = 0 + + try: + nested_limit = int(request.args.get('nested_limit')) + except TypeError: + nested_limit = None + except ValueError: + return BadRequest("nested_limit should be integer") + + rv = { + 'id' : root_id, + 'total_replies' : reply_counts[root_id], + 'hidden_replies' : reply_counts[root_id] - len(root_list), + 'replies' : self._process_fetched_list(root_list, plain) + } + # We are only checking for one level deep comments + if root_id is None: + for comment in rv['replies']: + if comment['id'] in reply_counts: + comment['total_replies'] = reply_counts[comment['id']] + if nested_limit is not None: + if nested_limit > 0: + args['parent'] = comment['id'] + args['limit'] = nested_limit + replies = list(self.comments.fetch(**args)) + else: + replies = [] + else: + args['parent'] = comment['id'] + replies = list(self.comments.fetch(**args)) + else: + comment['total_replies'] = 0 + replies = [] + + comment['hidden_replies'] = comment['total_replies'] - len(replies) + comment['replies'] = self._process_fetched_list(replies, plain) + + return JSON(rv, 200) + + def _process_fetched_list(self, fetched_list, plain=False): + for item in fetched_list: key = item['email'] or item['remote_addr'] val = self.cache.get('hash', key.encode('utf-8')) @@ -338,11 +408,11 @@ class API(object): for key in set(item.keys()) - API.FIELDS: item.pop(key) - if request.args.get('plain', '0') == '0': - for item in rv: + if plain: + for item in fetched_list: item['text'] = self.isso.render(item['text']) - return JSON(rv, 200) + return fetched_list @xhr def like(self, environ, request, id):