See who has "Liked" something

This commit is contained in:
Noah Petherbridge 2023-09-13 21:28:38 -07:00
parent 3543dd3e42
commit de30f5e952
14 changed files with 443 additions and 6 deletions

View File

@ -23,4 +23,5 @@ var (
PageSizeThreadList = 20 // 20 threads per board, 20 posts per thread
PageSizeForumAdmin = 20
PageSizeDashboardNotifications = 50
PageSizeLikeList = 12 // number of likes to show in popup modal
)

View File

@ -5,6 +5,7 @@ import (
"net/url"
"regexp"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/middleware"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session"
@ -105,12 +106,24 @@ func Profile() http.HandlerFunc {
// Get Likes for this profile.
likeMap := models.MapLikes(currentUser, "users", []uint64{user.ID})
// Get the summary of WHO liked this picture.
likeExample, likeRemainder, err := models.WhoLikes("users", user.ID)
if err != nil {
log.Error("WhoLikes(user %d): %s", user.ID, err)
}
vars := map[string]interface{}{
"User": user,
"LikeMap": likeMap,
"IsFriend": isFriend,
"IsPrivate": isPrivate,
"PhotoCount": models.CountPhotosICanSee(user, currentUser),
// Details on who likes the photo.
"LikeExample": likeExample,
"LikeRemainder": likeRemainder,
"LikeTableName": "users",
"LikeTableID": user.ID,
}
if err := tmpl.Execute(w, r, vars); err != nil {

View File

@ -3,13 +3,15 @@ package api
import (
"fmt"
"net/http"
"strconv"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session"
)
// Likes API.
// Likes API posts a new like on something.
func Likes() http.HandlerFunc {
// Request JSON schema.
type Request struct {
@ -70,11 +72,11 @@ func Likes() http.HandlerFunc {
if user, err := models.GetUser(photo.UserID); err == nil {
// Admin safety check: in case the admin clicked 'Like' on a friends-only or private
// picture they shouldn't have been expected to see, do not log a like.
if currentUser.IsAdmin {
if currentUser.IsAdmin && currentUser.ID != user.ID {
if (photo.Visibility == models.PhotoFriends && !models.AreFriends(user.ID, currentUser.ID)) ||
(photo.Visibility == models.PhotoPrivate && !models.IsPrivateUnlocked(user.ID, currentUser.ID)) {
SendJSON(w, http.StatusForbidden, Response{
Error: fmt.Sprintf("You are not allowed to like that photo."),
Error: "You are not allowed to like that photo.",
})
return
}
@ -155,3 +157,92 @@ func Likes() http.HandlerFunc {
})
})
}
// WhoLikes API checks who liked something.
func WhoLikes() http.HandlerFunc {
// Response JSON schema.
type Liker struct {
Username string `json:"username"`
Avatar string `json:"avatar"`
Relationship models.UserRelationship `json:"relationship"`
}
type Response struct {
OK bool `json:"OK"`
Error string `json:"error,omitempty"`
Likes []Liker `json:"likes,omitempty"`
Pager *models.Pagination `json:"pager,omitempty"`
Pages int `json:"pages,omitempty"`
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
SendJSON(w, http.StatusNotAcceptable, Response{
Error: "GET method only",
})
return
}
// Parse request parameters.
var (
tableName = r.FormValue("table_name")
tableID, _ = strconv.Atoi(r.FormValue("table_id"))
page, _ = strconv.Atoi(r.FormValue("page"))
)
if tableName == "" {
SendJSON(w, http.StatusBadRequest, Response{
Error: "Missing required table_name",
})
return
} else if tableID == 0 {
SendJSON(w, http.StatusBadRequest, Response{
Error: "Missing required table_id",
})
return
}
if page < 1 {
page = 1
}
// Get the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
SendJSON(w, http.StatusBadRequest, Response{
Error: "Couldn't get current user!",
})
return
}
// Get a page of users who've liked this.
var pager = &models.Pagination{
Page: page,
PerPage: config.PageSizeLikeList,
Sort: "created_at desc",
}
users, err := models.PaginateLikes(currentUser, tableName, uint64(tableID), pager)
if err != nil {
SendJSON(w, http.StatusInternalServerError, Response{
Error: fmt.Sprintf("Error getting likes: %s", err),
})
return
}
// Map user data to just the essentials for front-end.
var result = []Liker{}
for _, user := range users {
result = append(result, Liker{
Username: user.Username,
Avatar: user.VisibleAvatarURL(currentUser),
Relationship: user.UserRelationship,
})
}
// Send success response.
SendJSON(w, http.StatusOK, Response{
OK: true,
Likes: result,
Pager: pager,
Pages: pager.Pages(),
})
})
}

View File

@ -96,6 +96,12 @@ func View() http.HandlerFunc {
}
commentLikeMap := models.MapLikes(currentUser, "comments", commentIDs)
// Get the summary of WHO liked this picture.
likeExample, likeRemainder, err := models.WhoLikes("photos", photo.ID)
if err != nil {
log.Error("WhoLikes(photo %d): %s", photo.ID, err)
}
// Populate the user relationships in these comments.
models.SetUserRelationshipsInComments(currentUser, comments)
@ -111,6 +117,12 @@ func View() http.HandlerFunc {
"Comments": comments,
"CommentLikeMap": commentLikeMap,
"IsSubscribed": isSubscribed,
// Details on who likes the photo.
"LikeExample": likeExample,
"LikeRemainder": likeRemainder,
"LikeTableName": "photos",
"LikeTableID": photo.ID,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -63,6 +63,68 @@ func CountLikes(tableName string, tableID uint64) int64 {
return count
}
// WhoLikes something. Returns the first couple users and a count of the remainder.
func WhoLikes(tableName string, tableID uint64) ([]*User, int64, error) {
var (
userIDs = []uint64{}
likes = []*Like{}
res = DB.Model(&Like{}).Where(
"table_name = ? AND table_id = ?",
tableName, tableID,
).Order("created_at DESC").Limit(2).Scan(&likes)
total = CountLikes(tableName, tableID)
remainder = total - int64(len(likes))
)
if res.Error != nil {
return nil, 0, res.Error
}
// Collect the user IDs to look up.
for _, row := range likes {
userIDs = append(userIDs, row.UserID)
}
// Look up the users and return the remainder.
users, err := GetUsers(nil, userIDs)
if err != nil {
return nil, 0, err
}
return users, remainder, nil
}
// PaginateLikes returns a paged view of users who've liked something.
func PaginateLikes(currentUser *User, tableName string, tableID uint64, pager *Pagination) ([]*User, error) {
var (
l = []*Like{}
userIDs = []uint64{}
)
query := DB.Where(
"table_name = ? AND table_id = ?",
tableName, tableID,
).Order(
pager.Sort,
)
// Get the total count.
query.Model(&Like{}).Count(&pager.Total)
// Get the page of likes.
result := query.Offset(
pager.GetOffset(),
).Limit(pager.PerPage).Find(&l)
if result.Error != nil {
return nil, result.Error
}
// Map the user IDs in.
for _, like := range l {
userIDs = append(userIDs, like.UserID)
}
return GetUsers(currentUser, userIDs)
}
// LikedIDs filters a set of table IDs to ones the user likes.
func LikedIDs(user *User, tableName string, tableIDs []uint64) ([]uint64, error) {
var result = []uint64{}

View File

@ -452,6 +452,23 @@ func (u *User) NameOrUsername() string {
}
}
// VisibleAvatarURL returns a URL to the user's avatar taking into account
// their relationship with the current user. For example, if the avatar is
// friends-only and the current user can't see it, returns the path to the
// yellow placeholder avatar instead.
//
// Expects that UserRelationships are available on the user.
func (u *User) VisibleAvatarURL(currentUser *User) string {
if u.ProfilePhoto.Visibility == PhotoPrivate && !u.UserRelationship.IsPrivateGranted {
return "/static/img/shy-private.png"
} else if u.ProfilePhoto.Visibility == PhotoFriends && !u.UserRelationship.IsFriend {
return "/static/img/shy-friends.png"
} else if u.ProfilePhoto.CroppedFilename != "" {
return config.PhotoWebPath + "/" + u.ProfilePhoto.CroppedFilename
}
return "/static/img/shy.png"
}
// HashPassword sets the user's hashed (bcrypt) password.
func (u *User) HashPassword(password string) error {
passwd, err := bcrypt.GenerateFromPassword([]byte(password), config.BcryptCost)

View File

@ -97,6 +97,7 @@ func New() http.Handler {
mux.HandleFunc("/v1/users/me", api.LoginOK())
mux.HandleFunc("/v1/users/check-username", api.UsernameCheck())
mux.Handle("/v1/likes", middleware.LoginRequired(api.Likes()))
mux.Handle("/v1/likes/users", middleware.LoginRequired(api.WhoLikes()))
mux.Handle("/v1/notifications/read", middleware.LoginRequired(api.ReadNotification()))
mux.Handle("/v1/notifications/delete", middleware.LoginRequired(api.ClearNotification()))
mux.Handle("/v1/comment-photos/remove-orphaned", api.RemoveOrphanedCommentPhotos())

View File

@ -119,6 +119,7 @@ func (t *Template) Reload() error {
var baseTemplates = []string{
config.TemplatePath + "/base.html",
config.TemplatePath + "/partials/user_avatar.html",
config.TemplatePath + "/partials/like_modal.html",
}
// templates returns a template chain with the base templates preceding yours.

View File

@ -85,6 +85,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Common event handlers for bulma modals.
(document.querySelectorAll(".modal-background, .modal-close, .photo-modal") || []).forEach(node => {
const target = node.closest(".modal");
if (target.classList.contains("vue-managed")) return;
node.addEventListener("click", () => {
target.classList.remove("is-active");
});
@ -126,4 +127,4 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
})
});
});

View File

@ -256,7 +256,7 @@
</div>
{{else}}
<div class="block p-4">
<div class="tabs is-boxed">
<div class="tabs is-boxed mb-0">
<ul>
<li class="is-active">
<a>
@ -280,7 +280,12 @@
</ul>
</div>
<div class="columns">
<!-- Show who likes this user -->
{{if not .CurrentUser.IsShy}}
{{template "like-example" .}}
{{end}}
<div class="columns mt-1">
<div class="column is-two-thirds">
<div class="card block">

View File

@ -334,6 +334,9 @@
<script type="text/javascript" src="/static/js/vue-3.2.45.js"></script>
{{template "scripts" .}}
<!-- Likes modal -->
{{template "like-modal"}}
</body>
</html>
{{end}}

View File

@ -251,6 +251,15 @@
</a>
</div>
<div class="column is-narrow">
<!-- Button to inspect the likes -->
<a href="#" class="has-text-dark"
onclick="ShowLikeModal('comments', {{.ID}}); return false">
<span class="icon"><i class="fa fa-eye"></i></span>
<span>Likes</span>
</a>
</div>
<div class="column is-narrow">
<a href="/contact?intent=report&subject=report.comment&id={{.ID}}" class="has-text-dark">
<span class="icon"><i class="fa fa-flag"></i></span>

View File

@ -0,0 +1,209 @@
<!-- "Likes" modal to see who liked a thing -->
<!-- Reusable "Liked by Alice, Bob and 5 others" widget that invokes
the Like Modal. Requirements: pass in a context providing the
following variables:
- LikeExample: slice of users
- LikeRemainder: integer
- LikeTableName: like 'photos'
- LikeTableID: integer
Call this like: {{template "like-example" .}}
-->
{{define "like-example"}}
{{$Outer := .}}
{{if .LikeExample}}
<div class="mt-4 mb-2 has-text-centered">
Liked by
<!-- User list -->
{{range $i, $User := .LikeExample}}
<!-- Avatar -->
<figure class="image is-16x16 is-inline-block">
<a href="/u/{{$User.Username}}" class="has-text-dark">
{{if $User.ProfilePhoto.ID}}
{{if and (eq $User.ProfilePhoto.Visibility "private") (not $User.UserRelationship.IsPrivateGranted)}}
<img class="is-rounded" src="/static/img/shy-private.png">
{{else if and (eq $User.ProfilePhoto.Visibility "friends") (not $User.UserRelationship.IsFriend)}}
<img class="is-rounded" src="/static/img/shy-friends.png">
{{else}}
<img class="is-rounded" src="{{PhotoURL $User.ProfilePhoto.CroppedFilename}}">
{{end}}
{{else}}
<img class="is-rounded" src="/static/img/shy.png">
{{end}}
</a>
</figure>
<!-- Username plus a comma if multiple -->
<a href="/u/{{$User.Username}}">
{{- $User.Username -}}
</a>{{if and (eq (len $Outer.LikeExample) 2) (not $Outer.LikeRemainder)}}
{{if eq $i 0}}
and
{{end}}
{{- else if gt (len $Outer.LikeExample) 1}},{{end}}
{{end}}
<!-- Others -->
{{if .LikeRemainder}}
and
<a
href="#"
onclick="ShowLikeModal('{{.LikeTableName}}', {{.LikeTableID}}); return false"
>
{{.LikeRemainder}} other{{Pluralize64 .LikeRemainder}}
</a>.
{{end}}
</div>
{{end}}
{{end}}
{{define "like-modal"}}
<div id="like-modal-app">
<div class="modal vue-managed" :class="{'is-active': visible}">
<div class="modal-background" @click="visible=false"></div>
<div class="modal-content">
<div class="card">
<div class="card-header has-background-info">
<p class="card-header-title has-text-light">
<i class="fa fa-heart mr-2"></i> [[title]]
</p>
</div>
<div class="card-content">
<div v-if="busy">
<i class="fa fa-spinner fa-spin"></i> Loading likes...
</div>
<div v-else>
<p class="block">
Found [[ total ]] like[[ total === 1 ? '' : 's' ]]
(page [[ page ]] of [[ pages ]]).
</p>
<div class="columns is-multiline">
<div class="column is-one-third"
v-for="row in result">
<!-- Avatar -->
<figure class="image is-16x16 is-inline-block mr-2">
<a :href="'/u/'+row.username" class="has-text-dark">
<img class="is-rounded" :src="row.avatar">
</a>
</figure>
<!-- Username link -->
<a :href="'/u/'+row.username" target="_blank">[[ row.username ]]</a>
</div>
</div>
<!-- Pager buttons -->
<div class="columns is-mobile">
<div class="column is-narrow">
<button type="button" class="button"
@click="prevPage()"
:disabled="page <= 1">
Previous
</button>
</div>
<div class="column is-narrow">
<button type="button" class="button"
@click="nextPage()"
:disabled="page >= pages">
Next page
</button>
</div>
<div class="column has-text-right">
<button type="button" class="button is-primary"
@click="visible=false">
Close
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script type="text/javascript">
// Implemented in Vue widget.
var ShowLikeModal = function(tableName, tableID) {};
const likeModalApp = Vue.createApp({
delimiters: ['[[', ']]'],
data() {
return {
visible: false,
title: "Likes",
busy: false,
tableName: "photos",
tableID: 0,
page: 1,
pages: 0,
total: 0,
result: [],
}
},
mounted() {
ShowLikeModal = this.ShowLikeModal;
},
methods: {
ShowLikeModal(tableName, tableID) {
this.tableName = tableName;
this.tableID = tableID;
this.visible = true;
this.page = 1;
this.get();
},
prevPage() {
this.page--;
if (this.page <= 1) {
this.page = 1;
}
this.get();
},
nextPage() {
this.page++;
this.get();
},
get() {
this.busy = true;
return fetch("/v1/likes/users?" + new URLSearchParams({
table_name: this.tableName,
table_id: this.tableID,
page: this.page,
}), {
method: "GET",
mode: "same-origin",
cache: "no-cache",
credentials: "same-origin",
})
.then((response) => response.json())
.then((data) => {
if (data.StatusCode !== 200) {
window.alert(data.data.error);
return;
}
let likes = data.data.likes;
this.pages = data.data.pages;
this.total = data.data.pager.Total;
this.result = likes;
}).catch(resp => {
window.alert(resp);
}).finally(() => {
this.busy = false;
});
},
},
});
likeModalApp.mount("#like-modal-app");
</script>
{{end}}

View File

@ -84,6 +84,9 @@
{{.Photo.Caption}}
{{else}}<em>No caption</em>{{end}}
<!-- Who likes this photo? -->
{{template "like-example" .}}
<!-- Like & Comments buttons -->
<div class="mt-4 mb-2 columns is-centered is-mobile is-gapless">
<div class="column is-narrow mr-2">
@ -249,6 +252,15 @@
</a>
</div>
<div class="column is-narrow">
<!-- Button to inspect the likes -->
<a href="#" class="has-text-dark"
onclick="ShowLikeModal('comments', {{.ID}}); return false">
<span class="icon"><i class="fa fa-eye"></i></span>
<span>Likes</span>
</a>
</div>
<div class="column is-narrow">
<a href="/contact?intent=report&subject=report.comment&id={{.ID}}" class="has-text-dark">
<span class="icon"><i class="fa fa-flag"></i></span>