Thanks button: MVP
parent
940df38858
commit
0f59b1b485
|
@ -311,3 +311,43 @@
|
|||
font-size: 1.2em;
|
||||
}
|
||||
}
|
||||
|
||||
#isso-thread {
|
||||
position: relative;
|
||||
}
|
||||
#isso-reactions {
|
||||
float: right;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
#thanks-counter {
|
||||
}
|
||||
#thanks svg:hover {
|
||||
transform: scale(1.2);
|
||||
transition: transform 0.5s;
|
||||
opacity: 1;
|
||||
}
|
||||
#thanks svg {
|
||||
vertical-align: middle;
|
||||
/* margin-bottom: 0.3em; */
|
||||
margin-bottom: 0;
|
||||
border: 1px solid #CCC;
|
||||
background-color: #DDD;
|
||||
cursor: pointer;
|
||||
outline: 0;
|
||||
line-height: 1.4em;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 50%;
|
||||
/* width: 2em; */
|
||||
/* height: 2em; */
|
||||
/* padding: 0; */
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 3px;
|
||||
fill: #df2424; /* red */
|
||||
}
|
||||
.isso-not-reacted {
|
||||
/* transform: scale(0.8); */
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ from collections import defaultdict
|
|||
logger = logging.getLogger("isso")
|
||||
|
||||
from isso.db.comments import Comments
|
||||
from isso.db.reactions import Reactions
|
||||
from isso.db.threads import Threads
|
||||
from isso.db.spam import Guard
|
||||
from isso.db.preferences import Preferences
|
||||
|
@ -37,6 +38,7 @@ class SQLite3:
|
|||
self.preferences = Preferences(self)
|
||||
self.threads = Threads(self)
|
||||
self.comments = Comments(self)
|
||||
self.reactions = Reactions(self)
|
||||
self.guard = Guard(self)
|
||||
|
||||
if rv is None:
|
||||
|
|
|
@ -0,0 +1,139 @@
|
|||
# -*- encoding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from isso.utils import Bloomfilter
|
||||
|
||||
|
||||
logger = logging.getLogger("isso")
|
||||
|
||||
|
||||
# Separate reaction table is used because voters field in comments table is
|
||||
# per-comment, also people should be able to upvote comments and also react to
|
||||
# posts independently
|
||||
#
|
||||
# I _think_ this functionality could also live inside the threads table...
|
||||
#
|
||||
# Why not one voters blob for all reactions? Such as:
|
||||
# | tid | likes | dislikes | thanks | [...] | voters |
|
||||
# Because then we would deprive users of the possibility of giving more than
|
||||
# one reaction (thanks _and_ kudos) per thread
|
||||
|
||||
|
||||
class Reactions:
|
||||
"""Scheme overview:
|
||||
|
||||
| tid (thread id) | id (reaction id) | reactiontype | votes | voters |
|
||||
+-----------------+---------------------------------|-------+--------+
|
||||
| 1 | 1 | 1 | 2 | BLOB |
|
||||
| 8 | 2 | 1 | 7 | BLOB |
|
||||
+-----------------+---------------------------------|-------+--------+
|
||||
|
||||
The tuple (tid, reactiontype) is unique and thus primary key.
|
||||
"""
|
||||
|
||||
fields = ['tid', 'id',
|
||||
'reactiontype', # type of reaction: currently 1 = thanks
|
||||
'created', 'modified', 'votes', 'voters']
|
||||
|
||||
def __init__(self, db):
|
||||
|
||||
self.db = db
|
||||
self.db.execute([
|
||||
'CREATE TABLE IF NOT EXISTS reactions (',
|
||||
' tid REFERENCES threads(id), id INTEGER PRIMARY KEY,',
|
||||
' reactiontype INTEGER, created FLOAT NOT NULL, modified FLOAT,',
|
||||
' votes INTEGER, voters BLOB NOT NULL);'])
|
||||
|
||||
def _increase(self, uri, reactiontype, remote_addr):
|
||||
rv = self.db.execute([
|
||||
'SELECT reactions.id, reactions.votes, reactions.voters',
|
||||
' FROM reactions',
|
||||
' INNER JOIN threads ON threads.uri=?',
|
||||
' AND reactions.tid=threads.id;'],
|
||||
(uri,)).fetchone()
|
||||
|
||||
# FIXME: Be overly cautious until this is properly tested
|
||||
if rv is None:
|
||||
return None
|
||||
|
||||
id, votes, voters = rv
|
||||
|
||||
if id is None:
|
||||
return None
|
||||
|
||||
bf = Bloomfilter(bytearray(voters), votes)
|
||||
if remote_addr in bf:
|
||||
message = '{} denied, remote address: {}'.format("'Thanks'", remote_addr)
|
||||
logger.warn('Reactions.thanks(reactiontype=%d): %s', reactiontype, message)
|
||||
return {'id': id, 'count': votes, 'increased': False}
|
||||
|
||||
bf.add(remote_addr)
|
||||
self.db.execute([
|
||||
'UPDATE reactions SET',
|
||||
' votes = votes + 1,',
|
||||
' voters = ?'
|
||||
'WHERE id=?;'], (memoryview(bf.array), id))
|
||||
|
||||
# FIXME: double-check shouldn't be necessary, just emit count+1 without
|
||||
# triggering another sql query
|
||||
cnt = self.db.execute([
|
||||
'SELECT votes FROM reactions',
|
||||
' WHERE id=?;'],
|
||||
(id,)).fetchone()[0]
|
||||
|
||||
return {'id': id, 'count': cnt, 'increased': False}
|
||||
|
||||
|
||||
def add(self, uri, r):
|
||||
"""
|
||||
Add new reaction to DB and return a mapping of :attribute:`fields` and
|
||||
database values.
|
||||
"""
|
||||
|
||||
rv = self._increase(uri, r.get('reaction'), r.get('remote_addr'))
|
||||
if rv:
|
||||
#rv.pop('id') # TODO: Only return API fields
|
||||
return rv
|
||||
|
||||
self.db.execute([
|
||||
'INSERT INTO reactions (',
|
||||
' tid, reactiontype, created, modified, votes, voters)',
|
||||
'SELECT',
|
||||
' threads.id, ?, ?, ?, ?, ?',
|
||||
'FROM threads WHERE threads.uri = ?;'], (
|
||||
r.get('reaction'),
|
||||
r.get('created') or time.time(), None,
|
||||
1, # votes start at 1
|
||||
memoryview(
|
||||
Bloomfilter(iterable=[r['remote_addr']]).array),
|
||||
uri)
|
||||
)
|
||||
|
||||
rv = self.db.execute([
|
||||
'SELECT reactions.* FROM reactions',
|
||||
' INNER JOIN threads ON threads.uri=?',
|
||||
' AND reactions.tid=threads.id;'],
|
||||
(uri,)).fetchone()
|
||||
|
||||
rv = dict(zip(Reactions.fields, rv))
|
||||
|
||||
return {"id": rv.get("id"), "count": rv.get("votes"), 'increased': True}
|
||||
|
||||
|
||||
def count(self, uri):
|
||||
"""
|
||||
Return comment count for one ore more urls..
|
||||
"""
|
||||
|
||||
rv = self.db.execute([
|
||||
'SELECT reactions.votes FROM reactions',
|
||||
' INNER JOIN threads ON threads.uri=?',
|
||||
' AND reactions.tid=threads.id;'],
|
||||
(uri,)).fetchone()
|
||||
|
||||
try:
|
||||
return {"count": rv[0]}
|
||||
except (IndexError, TypeError):
|
||||
return {"count": 0}
|
|
@ -284,6 +284,9 @@ class Stdout(object):
|
|||
yield "comments.delete", self._delete_comment
|
||||
yield "comments.activate", self._activate_comment
|
||||
|
||||
yield "reactions.new:new-thread", self._new_thread
|
||||
yield "reactions.new:finish", self._new_reaction
|
||||
|
||||
def _new_thread(self, thread):
|
||||
logger.info("new thread %(id)s: %(title)s" % thread)
|
||||
|
||||
|
@ -299,3 +302,7 @@ class Stdout(object):
|
|||
|
||||
def _activate_comment(self, thread, comment):
|
||||
logger.info("comment %(id)s activated" % thread)
|
||||
|
||||
def _new_reaction(self, thread, rv):
|
||||
logger.info("new reaction id: %d (%d times) for thread: %s" % (
|
||||
rv['id'], rv['count'], thread['uri']))
|
||||
|
|
|
@ -177,6 +177,20 @@ define(["app/lib/promise", "app/globals"], function(Q, globals) {
|
|||
return deferred.promise;
|
||||
};
|
||||
|
||||
var react = function(tid, data) {
|
||||
var deferred = Q.defer();
|
||||
if (data == null) {
|
||||
curl("GET", endpoint + "/reactions?" + qs({uri: tid || location()}),
|
||||
JSON.stringify(data), function(rv) { deferred.resolve(JSON.parse(rv.body)); }
|
||||
);
|
||||
} else {
|
||||
curl("POST", endpoint + "/thanks?" + qs({uri: tid || location()}),
|
||||
JSON.stringify(data),
|
||||
function(rv) { deferred.resolve(JSON.parse(rv.body)); });
|
||||
}
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
var like = function(id) {
|
||||
var deferred = Q.defer();
|
||||
curl("POST", endpoint + "/id/" + id + "/like", null,
|
||||
|
@ -219,6 +233,7 @@ define(["app/lib/promise", "app/globals"], function(Q, globals) {
|
|||
view: view,
|
||||
fetch: fetch,
|
||||
count: count,
|
||||
react: react,
|
||||
like: like,
|
||||
dislike: dislike,
|
||||
feed: feed,
|
||||
|
|
|
@ -18,6 +18,7 @@ define(function() {
|
|||
"#be5168", "#f19670", "#e4bf80", "#447c69"].join(" "),
|
||||
"vote": true,
|
||||
"vote-levels": null,
|
||||
"reactions": true,
|
||||
"feed": false
|
||||
};
|
||||
|
||||
|
|
|
@ -393,9 +393,50 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
|
|||
|
||||
};
|
||||
|
||||
if (config.reactions) {
|
||||
var Reactions = function(parent) {
|
||||
|
||||
// Fetch data async first
|
||||
api.react(null, null).then(function (rv) {
|
||||
reactions(rv.count);
|
||||
});
|
||||
|
||||
// Render template next
|
||||
var el = $.htmlify(jade.render("reactions", {"count": 0}));
|
||||
|
||||
|
||||
// Update reaction counters
|
||||
var reactions = function (value) {
|
||||
var cnter = $('#thanks-counter');
|
||||
var btn = $('#thanks');
|
||||
btn.classList.add('isso-not-reacted');
|
||||
if (value) {
|
||||
cnter.textContent = value;
|
||||
} else {
|
||||
// Should not happen
|
||||
}
|
||||
};
|
||||
|
||||
// Hook up listener
|
||||
$("[id='thanks']", el).on("click", function () {
|
||||
var title = $("#isso-thread").getAttribute("data-title") || null;
|
||||
var data = { "reaction": 1, "title": title };
|
||||
api.react($("#isso-thread").getAttribute("data-isso-id"), data).then(function (rv) {
|
||||
reactions(rv.count);
|
||||
// how do I get `this`?
|
||||
var btn = $('#thanks');
|
||||
btn.classList.remove('isso-not-reacted');
|
||||
});
|
||||
});
|
||||
|
||||
return el;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
insert: insert,
|
||||
insert_loader: insert_loader,
|
||||
Postbox: Postbox
|
||||
Postbox: Postbox,
|
||||
Reactions: Reactions
|
||||
};
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
define(["libjs-jade-runtime", "app/utils", "jade!app/text/postbox", "jade!app/text/comment", "jade!app/text/comment-loader"], function(runtime, utils, tt_postbox, tt_comment, tt_comment_loader) {
|
||||
define(["libjs-jade-runtime", "app/utils", "jade!app/text/postbox", "jade!app/text/comment", "jade!app/text/comment-loader", "jade!app/text/reactions"], function(runtime, utils, tt_postbox, tt_comment, tt_comment_loader, tt_reactions) {
|
||||
"use strict";
|
||||
|
||||
var globals = {},
|
||||
|
@ -22,6 +22,7 @@ define(["libjs-jade-runtime", "app/utils", "jade!app/text/postbox", "jade!app/te
|
|||
load("postbox", tt_postbox);
|
||||
load("comment", tt_comment);
|
||||
load("comment-loader", tt_comment_loader);
|
||||
load("reactions", tt_reactions);
|
||||
|
||||
set("bool", function(arg) { return arg ? true : false; });
|
||||
set("humanize", function(date) {
|
||||
|
@ -74,4 +75,4 @@ define(["libjs-jade-runtime", "app/utils", "jade!app/text/postbox", "jade!app/te
|
|||
return rv;
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.78 7.77L10 18.78l8.39-8.4a5.5 5.5 0 0 0-7.78-7.77l-.61.61z"></path></svg>
|
After Width: | Height: | Size: 176 B |
|
@ -0,0 +1,6 @@
|
|||
div(id='isso-reactions')
|
||||
a(id='thanks' href='#' title='Click me!')
|
||||
!= svg['heart']
|
||||
span(id='thanks-counter')
|
||||
= count
|
||||
span= " Thanks"
|
|
@ -1,6 +1,7 @@
|
|||
define(["text!./arrow-down.svg", "text!./arrow-up.svg"], function (arrdown, arrup) {
|
||||
define(["text!./arrow-down.svg", "text!./arrow-up.svg", "text!./heart.svg"], function (arrdown, arrup, heart) {
|
||||
return {
|
||||
"arrow-down": arrdown,
|
||||
"arrow-up": arrup
|
||||
"arrow-up": arrup,
|
||||
"heart": heart
|
||||
};
|
||||
});
|
||||
|
|
|
@ -41,6 +41,7 @@ require(["app/lib/ready", "app/config", "app/i18n", "app/api", "app/isso", "app/
|
|||
isso_thread.append(feedLinkWrapper);
|
||||
}
|
||||
isso_thread.append(heading);
|
||||
isso_thread.append(new isso.Reactions(null));
|
||||
isso_thread.append(new isso.Postbox(null));
|
||||
isso_thread.append('<div id="isso-root"></div>');
|
||||
}
|
||||
|
|
|
@ -111,6 +111,8 @@ class API(object):
|
|||
('unsubscribe', ('GET', '/id/<int:id>/unsubscribe/<string:email>/<string:key>')),
|
||||
('moderate', ('GET', '/id/<int:id>/<any(edit,activate,delete):action>/<string:key>')),
|
||||
('moderate', ('POST', '/id/<int:id>/<any(edit,activate,delete):action>/<string:key>')),
|
||||
('thanks', ('POST', '/thanks')),
|
||||
('reactcount', ('GET', '/reactions')),
|
||||
('like', ('POST', '/id/<int:id>/like')),
|
||||
('dislike', ('POST', '/id/<int:id>/dislike')),
|
||||
('demo', ('GET', '/demo')),
|
||||
|
@ -141,6 +143,7 @@ class API(object):
|
|||
self.guard = isso.db.guard
|
||||
self.threads = isso.db.threads
|
||||
self.comments = isso.db.comments
|
||||
self.reactions = isso.db.reactions
|
||||
|
||||
for (view, (method, path)) in self.VIEWS:
|
||||
isso.urls.add(
|
||||
|
@ -880,6 +883,53 @@ class API(object):
|
|||
|
||||
return fetched_list
|
||||
|
||||
|
||||
@xhr
|
||||
@requires(str, 'uri')
|
||||
def thanks(self, environ, request, uri):
|
||||
|
||||
data = request.get_json()
|
||||
data['remote_addr'] = self._remote_addr(request)
|
||||
# for debugging so localhost isn't blocked after one try
|
||||
#from random import randint
|
||||
#data['remote_addr'] = "127.0.0.%d" % randint(1, 255)
|
||||
|
||||
with self.isso.lock:
|
||||
# TODO: unify this with comments add() code
|
||||
if uri not in self.threads:
|
||||
if 'title' not in data:
|
||||
with http.curl('GET', local("origin"), uri) as resp:
|
||||
if resp and resp.status == 200:
|
||||
uri, title = parse.thread(resp.read(), id=uri)
|
||||
else:
|
||||
return NotFound('URI does not exist %s')
|
||||
else:
|
||||
title = data['title']
|
||||
|
||||
thread = self.threads.new(uri, title)
|
||||
self.signal("reactions.new:new-thread", thread)
|
||||
else:
|
||||
thread = self.threads[uri]
|
||||
with self.isso.lock:
|
||||
rv = self.reactions.add(uri, data)
|
||||
|
||||
# Notify extensions
|
||||
if rv.get('increased'):
|
||||
self.signal("reactions.new:finish", thread, rv)
|
||||
|
||||
# No need to fiddle with cookies, reactions are irrevocable and
|
||||
# remote_addr is saved for spam protection
|
||||
resp = JSON(rv, 201)
|
||||
return resp
|
||||
|
||||
@xhr
|
||||
@requires(str, 'uri')
|
||||
def reactcount(self, environ, request, uri):
|
||||
rv = self.reactions.count(uri)
|
||||
resp = JSON(rv, 201)
|
||||
return resp
|
||||
|
||||
|
||||
"""
|
||||
@apiDefine likeResponse
|
||||
@apiSuccess {number} likes
|
||||
|
|
Loading…
Reference in New Issue