From de30f5e952f9a3cb6eededdc3b723b478a979ebf Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Wed, 13 Sep 2023 21:28:38 -0700 Subject: [PATCH] See who has "Liked" something --- pkg/config/page_sizes.go | 1 + pkg/controller/account/profile.go | 13 ++ pkg/controller/api/likes.go | 97 +++++++++++- pkg/controller/photo/view.go | 12 ++ pkg/models/like.go | 62 ++++++++ pkg/models/user.go | 17 ++ pkg/router/router.go | 1 + pkg/templates/templates.go | 1 + web/static/js/bulma.js | 3 +- web/templates/account/profile.html | 9 +- web/templates/base.html | 3 + web/templates/forum/thread.html | 9 ++ web/templates/partials/like_modal.html | 209 +++++++++++++++++++++++++ web/templates/photo/permalink.html | 12 ++ 14 files changed, 443 insertions(+), 6 deletions(-) create mode 100644 web/templates/partials/like_modal.html diff --git a/pkg/config/page_sizes.go b/pkg/config/page_sizes.go index b1306db..9601f6b 100644 --- a/pkg/config/page_sizes.go +++ b/pkg/config/page_sizes.go @@ -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 ) diff --git a/pkg/controller/account/profile.go b/pkg/controller/account/profile.go index ffda661..8066652 100644 --- a/pkg/controller/account/profile.go +++ b/pkg/controller/account/profile.go @@ -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 { diff --git a/pkg/controller/api/likes.go b/pkg/controller/api/likes.go index e957af3..dc38019 100644 --- a/pkg/controller/api/likes.go +++ b/pkg/controller/api/likes.go @@ -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(), + }) + }) +} diff --git a/pkg/controller/photo/view.go b/pkg/controller/photo/view.go index 60df70e..cea7308 100644 --- a/pkg/controller/photo/view.go +++ b/pkg/controller/photo/view.go @@ -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) diff --git a/pkg/models/like.go b/pkg/models/like.go index 851be16..f144b97 100644 --- a/pkg/models/like.go +++ b/pkg/models/like.go @@ -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{} diff --git a/pkg/models/user.go b/pkg/models/user.go index c144eca..14b5d17 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -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) diff --git a/pkg/router/router.go b/pkg/router/router.go index 50b770a..0b89c8f 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -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()) diff --git a/pkg/templates/templates.go b/pkg/templates/templates.go index 3a244af..64131e8 100644 --- a/pkg/templates/templates.go +++ b/pkg/templates/templates.go @@ -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. diff --git a/web/static/js/bulma.js b/web/static/js/bulma.js index 842626a..c4d80d5 100644 --- a/web/static/js/bulma.js +++ b/web/static/js/bulma.js @@ -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', () => { } }); }) -}); \ No newline at end of file +}); diff --git a/web/templates/account/profile.html b/web/templates/account/profile.html index be72a4f..bb4414c 100644 --- a/web/templates/account/profile.html +++ b/web/templates/account/profile.html @@ -256,7 +256,7 @@ {{else}}
-
+ -
+ + {{if not .CurrentUser.IsShy}} + {{template "like-example" .}} + {{end}} + +
+ +
diff --git a/web/templates/partials/like_modal.html b/web/templates/partials/like_modal.html new file mode 100644 index 0000000..2f3f17d --- /dev/null +++ b/web/templates/partials/like_modal.html @@ -0,0 +1,209 @@ + + + +{{define "like-example"}} + {{$Outer := .}} + {{if .LikeExample}} + + {{end}} +{{end}} + +{{define "like-modal"}} +
+ +
+ + +{{end}} diff --git a/web/templates/photo/permalink.html b/web/templates/photo/permalink.html index 166191b..a6eb11e 100644 --- a/web/templates/photo/permalink.html +++ b/web/templates/photo/permalink.html @@ -84,6 +84,9 @@ {{.Photo.Caption}} {{else}}No caption{{end}} + + {{template "like-example" .}} +
@@ -249,6 +252,15 @@
+ +