Thanks button: MVP

FREEZE-blogpost-2021-01-20
Felix 2021-01-20 17:11:37 +01:00
parent 940df38858
commit 0f59b1b485
13 changed files with 310 additions and 5 deletions

View File

@ -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;
}

View File

@ -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:

View File

@ -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}

View File

@ -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']))

View File

@ -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,

View File

@ -18,6 +18,7 @@ define(function() {
"#be5168", "#f19670", "#e4bf80", "#447c69"].join(" "),
"vote": true,
"vote-levels": null,
"reactions": true,
"feed": false
};

View File

@ -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
};
});

View File

@ -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;
}
};
});
});

View File

@ -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

View File

@ -0,0 +1,6 @@
div(id='isso-reactions')
a(id='thanks' href='#' title='Click me!')
!= svg['heart']
span(id='thanks-counter')
= count
span= " Thanks"

View File

@ -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
};
});

View File

@ -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>');
}

View File

@ -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