mirror of https://github.com/jlelse/GoBlog
Reactions
parent
ddd097f809
commit
1e03474539
@ -0,0 +1,7 @@
|
||||
create table reactions (
|
||||
path text not null,
|
||||
reaction text not null,
|
||||
count integer default 0,
|
||||
primary key (path, reaction),
|
||||
foreign key (path) references posts(path) on update cascade on delete cascade
|
||||
);
|
@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/samber/lo"
|
||||
"go.goblog.app/app/pkgs/bufferpool"
|
||||
"go.goblog.app/app/pkgs/contenttype"
|
||||
)
|
||||
|
||||
// Hardcoded for now
|
||||
var allowedReactions = []string{
|
||||
"❤️",
|
||||
"👍",
|
||||
"🎉",
|
||||
"😂",
|
||||
"😱",
|
||||
}
|
||||
|
||||
func (a *goBlog) reactionsEnabled() bool {
|
||||
return a.cfg.Reactions != nil && a.cfg.Reactions.Enabled
|
||||
}
|
||||
|
||||
const reactionsPostParam = "reactions"
|
||||
|
||||
func (a *goBlog) reactionsEnabledForPost(post *post) bool {
|
||||
return a.reactionsEnabled() && post != nil && post.firstParameter(reactionsPostParam) != "false"
|
||||
}
|
||||
|
||||
func (a *goBlog) postReaction(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.FormValue("path")
|
||||
reaction := r.FormValue("reaction")
|
||||
if path == "" || reaction == "" {
|
||||
a.serveError(w, r, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
err := a.saveReaction(reaction, path)
|
||||
if err != nil {
|
||||
a.serveError(w, r, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (a *goBlog) saveReaction(reaction, path string) error {
|
||||
// Check if reaction is allowed
|
||||
if !lo.Contains(allowedReactions, reaction) {
|
||||
return errors.New("reaction not allowed")
|
||||
}
|
||||
// Insert reaction
|
||||
_, err := a.db.exec("insert into reactions (path, reaction, count) values (?, ?, 1) on conflict (path, reaction) do update set count=count+1", path, reaction)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *goBlog) getReactions(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.FormValue("path")
|
||||
reactions, err := a.getReactionsFromDatabase(path)
|
||||
if err != nil {
|
||||
a.serveError(w, r, "", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
buf := bufferpool.Get()
|
||||
defer bufferpool.Put(buf)
|
||||
err = json.NewEncoder(buf).Encode(reactions)
|
||||
if err != nil {
|
||||
a.serveError(w, r, "", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set(contentType, contenttype.JSONUTF8)
|
||||
_ = a.min.Get().Minify(contenttype.JSON, w, buf)
|
||||
}
|
||||
|
||||
func (a *goBlog) getReactionsFromDatabase(path string) (map[string]int, error) {
|
||||
sqlBuf := bufferpool.Get()
|
||||
defer bufferpool.Put(sqlBuf)
|
||||
sqlArgs := []any{}
|
||||
sqlBuf.WriteString("select reaction, count from reactions where path=? and reaction in (")
|
||||
sqlArgs = append(sqlArgs, path)
|
||||
for i, reaction := range allowedReactions {
|
||||
if i > 0 {
|
||||
sqlBuf.WriteString(",")
|
||||
}
|
||||
sqlBuf.WriteString("?")
|
||||
sqlArgs = append(sqlArgs, reaction)
|
||||
}
|
||||
sqlBuf.WriteString(") and path not in (select path from post_parameters where parameter=? and value=?)")
|
||||
sqlArgs = append(sqlArgs, reactionsPostParam, "false")
|
||||
rows, err := a.db.query(sqlBuf.String(), sqlArgs...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
reactions := map[string]int{}
|
||||
for rows.Next() {
|
||||
var reaction string
|
||||
var count int
|
||||
err = rows.Scan(&reaction, &count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reactions[reaction] = count
|
||||
}
|
||||
return reactions, nil
|
||||
}
|
@ -0,0 +1,148 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_reactionsLowLevel(t *testing.T) {
|
||||
app := &goBlog{
|
||||
cfg: createDefaultTestConfig(t),
|
||||
}
|
||||
_ = app.initConfig()
|
||||
_ = app.initDatabase(false)
|
||||
defer app.db.close()
|
||||
app.initComponents(false)
|
||||
|
||||
err := app.saveReaction("🖕", "/testpost")
|
||||
assert.ErrorContains(t, err, "not allowed")
|
||||
|
||||
err = app.saveReaction("❤️", "/testpost")
|
||||
assert.ErrorContains(t, err, "constraint failed")
|
||||
|
||||
// Create a post
|
||||
err = app.createPost(&post{
|
||||
Path: "/testpost",
|
||||
Content: "test",
|
||||
Status: statusPublished,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create 4 reactions
|
||||
for i := 0; i < 4; i++ {
|
||||
err = app.saveReaction("❤️", "/testpost")
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// Check if reaction count is 4
|
||||
reacts, err := app.getReactionsFromDatabase("/testpost")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, len(reacts))
|
||||
assert.Equal(t, 4, reacts["❤️"])
|
||||
|
||||
// Change post path
|
||||
err = app.replacePost(&post{
|
||||
Path: "/newpost",
|
||||
Content: "test",
|
||||
Status: statusPublished,
|
||||
}, "/testpost", statusPublished)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check if reaction count is 4
|
||||
reacts, err = app.getReactionsFromDatabase("/newpost")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, len(reacts))
|
||||
assert.Equal(t, 4, reacts["❤️"])
|
||||
|
||||
// Delete post
|
||||
err = app.deletePost("/newpost")
|
||||
require.NoError(t, err)
|
||||
err = app.deletePost("/newpost")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check if reaction count is 0
|
||||
reacts, err = app.getReactionsFromDatabase("/newpost")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, len(reacts))
|
||||
|
||||
// Create a post with disabled reactions
|
||||
err = app.createPost(&post{
|
||||
Path: "/testpost2",
|
||||
Content: "test",
|
||||
Status: statusPublished,
|
||||
Parameters: map[string][]string{
|
||||
"reactions": {"false"},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create reaction
|
||||
err = app.saveReaction("❤️", "/testpost2")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check if reaction count is 0
|
||||
reacts, err = app.getReactionsFromDatabase("/testpost2")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, len(reacts))
|
||||
|
||||
}
|
||||
|
||||
func Test_reactionsHighLevel(t *testing.T) {
|
||||
app := &goBlog{
|
||||
cfg: createDefaultTestConfig(t),
|
||||
}
|
||||
_ = app.initConfig()
|
||||
_ = app.initDatabase(false)
|
||||
defer app.db.close()
|
||||
app.initComponents(false)
|
||||
|
||||
// Send unsuccessful reaction
|
||||
form := url.Values{
|
||||
"reaction": {"❤️"},
|
||||
"path": {"/testpost"},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
app.postReaction(rec, req)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
|
||||
// Create a post
|
||||
err := app.createPost(&post{
|
||||
Path: "/testpost",
|
||||
Content: "test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Send successful reaction
|
||||
form = url.Values{
|
||||
"reaction": {"❤️"},
|
||||
"path": {"/testpost"},
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec = httptest.NewRecorder()
|
||||
app.postReaction(rec, req)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
// Check if reaction count is 1
|
||||
req = httptest.NewRequest(http.MethodGet, "/?path=/testpost", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
app.getReactions(rec, req)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, `{"❤️":1}`, rec.Body.String())
|
||||
|
||||
// Get reactions for a non-existing post
|
||||
req = httptest.NewRequest(http.MethodGet, "/?path=/non-existing-post", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
app.getReactions(rec, req)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, `{}`, rec.Body.String())
|
||||
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
(function () {
|
||||
|
||||
// Get reactions element
|
||||
let reactions = document.querySelector('#reactions')
|
||||
|
||||
// Get post path
|
||||
let path = reactions.dataset.path
|
||||
|
||||
// Define update counts function
|
||||
let updateCounts = function () {
|
||||
// Fetch reactions json
|
||||
fetch('/-/reactions?path=' + encodeURI(path))
|
||||
.then(response => response.json())
|
||||
.then(json => {
|
||||
// For every reaction
|
||||
for (let reaction in json) {
|
||||
// Get reaction buttons
|
||||
let button = document.querySelector('#reactions button[data-reaction="' + reaction + '"]')
|
||||
// Set reaction count
|
||||
button.innerText = reaction + ' ' + json[reaction]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Get allowed reactions
|
||||
let allowed = reactions.dataset.allowed.split(',')
|
||||
allowed.forEach(allowedReaction => {
|
||||
|
||||
// Create reaction button
|
||||
let button = document.createElement('button')
|
||||
button.dataset.reaction = allowedReaction
|
||||
|
||||
// Set click event
|
||||
button.addEventListener('click', function () {
|
||||
// Send reaction to server
|
||||
let data = new FormData()
|
||||
data.append('path', path)
|
||||
data.append('reaction', allowedReaction)
|
||||
fetch('/-/reactions', { method: 'POST', body: data })
|
||||
.then(updateCounts)
|
||||
})
|
||||
|
||||
// Set reaction text
|
||||
button.innerText = allowedReaction
|
||||
|
||||
// Add button to reactions element
|
||||
reactions.appendChild(button)
|
||||
|
||||
})
|
||||
|
||||
// Update reaction counts
|
||||
updateCounts()
|
||||
|
||||
})()
|
Loading…
Reference in New Issue