From bb08ec56cebda250da5b4805f74da28b350a5997 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Wed, 24 Aug 2022 21:17:34 -0700 Subject: [PATCH] Finish Forums + Likes & Notifications Finish implementing the basic forum features: * Pinned threads (admin or board owner only) * Edit Thread settings when you edit the top-most comment. * NoReply threads remove all the reply buttons. * Explicit forums and threads are filtered out unless opted-in (admins always see them). * Count the unique members who participated in each forum. * Get the most recently updated thread to show on forum list page. * Contact/Report page: handle receiving a comment ID to report on. Implement Likes & Notifications * Like buttons added to Photos and Profile Pages. Implemented via simple vanilla JS (likes.js) to make ajax requests to back-end to like/unlike. * Notifications: for your photo or profile being liked. If you unlike, the existing notifications about the like are revoked. * The notifications appear as an alert number in the nav bar and are read on the User Dashboard. Click to mark a notification as "read" or click the "mark all as read" button. Update DeleteUser to scrub likes, notifications, threads, and comments. --- pkg/config/page_sizes.go | 25 +-- pkg/controller/account/dashboard.go | 39 +++- pkg/controller/account/profile.go | 4 + pkg/controller/admin/feedback.go | 14 ++ pkg/controller/api/json_layer.go | 55 +++++ pkg/controller/api/likes.go | 124 +++++++++++ pkg/controller/api/read_notification.go | 76 +++++++ pkg/controller/forum/forum.go | 11 + pkg/controller/forum/forums.go | 2 +- pkg/controller/forum/new_post.go | 48 ++++- pkg/controller/index/contact.go | 9 + pkg/controller/photo/site_gallery.go | 8 + pkg/controller/photo/user_gallery.go | 8 + pkg/middleware/csrf.go | 6 + pkg/models/deletion/delete_user.go | 78 +++++++ pkg/models/forum.go | 7 +- pkg/models/forum_stats.go | 272 ++++++++++++++---------- pkg/models/like.go | 145 +++++++++++++ pkg/models/models.go | 2 + pkg/models/notification.go | 175 +++++++++++++++ pkg/models/photo.go | 15 ++ pkg/models/thread.go | 55 ++++- pkg/router/router.go | 2 + pkg/templates/template_vars.go | 33 ++- web/static/js/likes.js | 56 +++++ web/templates/account/dashboard.html | 143 ++++++++++++- web/templates/account/profile.html | 21 +- web/templates/admin/user_actions.html | 12 -- web/templates/base.html | 23 +- web/templates/forum/board_index.html | 39 +++- web/templates/forum/index.html | 32 ++- web/templates/forum/new_post.html | 20 +- web/templates/forum/thread.html | 72 ++++++- web/templates/partials/like_button.html | 2 + web/templates/photo/gallery.html | 32 +++ 35 files changed, 1471 insertions(+), 194 deletions(-) create mode 100644 pkg/controller/api/json_layer.go create mode 100644 pkg/controller/api/likes.go create mode 100644 pkg/controller/api/read_notification.go create mode 100644 pkg/models/like.go create mode 100644 pkg/models/notification.go create mode 100644 web/static/js/likes.js create mode 100644 web/templates/partials/like_button.html diff --git a/pkg/config/page_sizes.go b/pkg/config/page_sizes.go index 9c07eec..9ea120d 100644 --- a/pkg/config/page_sizes.go +++ b/pkg/config/page_sizes.go @@ -2,16 +2,17 @@ package config // Pagination sizes per page. var ( - PageSizeMemberSearch = 60 - PageSizeFriends = 12 - PageSizeBlockList = 12 - PageSizeAdminCertification = 20 - PageSizeAdminFeedback = 20 - PageSizeSiteGallery = 18 - PageSizeUserGallery = 18 - PageSizeInboxList = 20 // sidebar list - PageSizeInboxThread = 20 // conversation view - PageSizeForums = 100 // TODO: for main category index view - PageSizeThreadList = 20 - PageSizeForumAdmin = 20 + PageSizeMemberSearch = 60 + PageSizeFriends = 12 + PageSizeBlockList = 12 + PageSizeAdminCertification = 20 + PageSizeAdminFeedback = 20 + PageSizeSiteGallery = 18 + PageSizeUserGallery = 18 + PageSizeInboxList = 20 // sidebar list + PageSizeInboxThread = 20 // conversation view + PageSizeForums = 100 // TODO: for main category index view + PageSizeThreadList = 20 // 20 threads per board, 20 posts per thread + PageSizeForumAdmin = 20 + PageSizeDashboardNotifications = 50 ) diff --git a/pkg/controller/account/dashboard.go b/pkg/controller/account/dashboard.go index d529ac9..a2d8592 100644 --- a/pkg/controller/account/dashboard.go +++ b/pkg/controller/account/dashboard.go @@ -3,6 +3,9 @@ package account import ( "net/http" + "git.kirsle.net/apps/gosocial/pkg/config" + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" "git.kirsle.net/apps/gosocial/pkg/templates" ) @@ -10,7 +13,41 @@ import ( func Dashboard() http.HandlerFunc { tmpl := templates.Must("account/dashboard.html") return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if err := tmpl.Execute(w, r, nil); err != nil { + currentUser, err := session.CurrentUser(r) + if err != nil { + http.Error(w, "Couldn't get currentUser", http.StatusInternalServerError) + return + } + + // Mark all notifications read? + if r.FormValue("intent") == "read-notifications" { + models.MarkNotificationsRead(currentUser) + session.Flash(w, r, "All of your notifications have been marked as 'read!'") + templates.Redirect(w, "/me") + return + } + + // Get our notifications. + pager := &models.Pagination{ + Page: 1, + PerPage: config.PageSizeDashboardNotifications, + Sort: "created_at desc", + } + pager.ParsePage(r) + notifs, err := models.PaginateNotifications(currentUser, pager) + if err != nil { + session.FlashError(w, r, "Couldn't get your notifications: %s", err) + } + + // Map our notifications. + notifMap := models.MapNotifications(notifs) + + var vars = map[string]interface{}{ + "Notifications": notifs, + "NotifMap": notifMap, + "Pager": pager, + } + if err := tmpl.Execute(w, r, vars); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } diff --git a/pkg/controller/account/profile.go b/pkg/controller/account/profile.go index 0cb52d9..3082b99 100644 --- a/pkg/controller/account/profile.go +++ b/pkg/controller/account/profile.go @@ -57,8 +57,12 @@ func Profile() http.HandlerFunc { isPrivate = !currentUser.IsAdmin && !isSelf && user.Visibility == models.UserVisibilityPrivate && isFriend != "approved" ) + // Get Likes for this profile. + likeMap := models.MapLikes(currentUser, "users", []uint64{user.ID}) + vars := map[string]interface{}{ "User": user, + "LikeMap": likeMap, "IsFriend": isFriend, "IsPrivate": isPrivate, } diff --git a/pkg/controller/admin/feedback.go b/pkg/controller/admin/feedback.go index f0fe354..b1b5604 100644 --- a/pkg/controller/admin/feedback.go +++ b/pkg/controller/admin/feedback.go @@ -89,6 +89,20 @@ func Feedback() http.HandlerFunc { return } } + case "comments": + // Get this comment. + comment, err := models.GetComment(fb.TableID) + if err != nil { + session.FlashError(w, r, "Couldn't get comment ID %d: %s", fb.TableID, err) + } else { + // What was the comment on? + switch comment.TableName { + case "threads": + // Visit the thread. + templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", comment.TableID)) + return + } + } default: session.FlashError(w, r, "Couldn't visit TableID %s/%d: not a supported TableName", fb.TableName, fb.TableID) } diff --git a/pkg/controller/api/json_layer.go b/pkg/controller/api/json_layer.go new file mode 100644 index 0000000..ca65423 --- /dev/null +++ b/pkg/controller/api/json_layer.go @@ -0,0 +1,55 @@ +package api + +import ( + "encoding/json" + "errors" + "io/ioutil" + "net/http" + + "git.kirsle.net/apps/gosocial/pkg/log" +) + +// Envelope is the standard JSON response envelope. +type Envelope struct { + Data interface{} `json:"data"` + StatusCode int +} + +// ParseJSON request body. +func ParseJSON(r *http.Request, v interface{}) error { + if r.Header.Get("Content-Type") != "application/json" { + return errors.New("request Content-Type must be application/json") + } + + // Parse request body. + body, err := ioutil.ReadAll(r.Body) + if err != nil { + return err + } + + log.Error("body: %+v", body) + + // Parse params from JSON. + if err := json.Unmarshal(body, v); err != nil { + return err + } + + return nil +} + +// SendJSON response. +func SendJSON(w http.ResponseWriter, statusCode int, v interface{}) { + buf, err := json.Marshal(Envelope{ + Data: v, + StatusCode: statusCode, + }) + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + w.Write(buf) +} diff --git a/pkg/controller/api/likes.go b/pkg/controller/api/likes.go new file mode 100644 index 0000000..c7333b0 --- /dev/null +++ b/pkg/controller/api/likes.go @@ -0,0 +1,124 @@ +package api + +import ( + "fmt" + "net/http" + + "git.kirsle.net/apps/gosocial/pkg/log" + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" +) + +// Likes API. +func Likes() http.HandlerFunc { + // Request JSON schema. + type Request struct { + TableName string `json:"name"` + TableID uint64 `json:"id"` + Unlike bool `json:"unlike,omitempty"` + } + + // Response JSON schema. + type Response struct { + OK bool `json:"OK"` + Error string `json:"error,omitempty"` + Likes int64 `json:"likes"` + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + SendJSON(w, http.StatusNotAcceptable, Response{ + Error: "POST method only", + }) + return + } + + // Get the current user. + currentUser, err := session.CurrentUser(r) + if err != nil { + SendJSON(w, http.StatusBadRequest, Response{ + Error: "Couldn't get current user!", + }) + return + } + + // Parse request payload. + var req Request + if err := ParseJSON(r, &req); err != nil { + SendJSON(w, http.StatusBadRequest, Response{ + Error: fmt.Sprintf("Error with request payload: %s", err), + }) + return + } + + // Who do we notify about this like? + var targetUser *models.User + switch req.TableName { + case "photos": + if photo, err := models.GetPhoto(req.TableID); err == nil { + if user, err := models.GetUser(photo.UserID); err == nil { + targetUser = user + } + } else { + log.Error("For like on photos table: didn't find photo %d: %s", req.TableID, err) + } + case "users": + log.Error("subject is users, find %d", req.TableID) + if user, err := models.GetUser(req.TableID); err == nil { + targetUser = user + log.Warn("found user %s", targetUser.Username) + } else { + log.Error("For like on users table: didn't find user %d: %s", req.TableID, err) + } + } + + // Is the table likeable? + if _, ok := models.LikeableTables[req.TableName]; !ok { + SendJSON(w, http.StatusBadRequest, Response{ + Error: fmt.Sprintf("Can't like table %s: not allowed.", req.TableName), + }) + return + } + + // Put in a like. + if req.Unlike { + if err := models.Unlike(currentUser, req.TableName, req.TableID); err != nil { + SendJSON(w, http.StatusBadRequest, Response{ + Error: fmt.Sprintf("Error unliking: %s", err), + }) + return + } + + // Remove the target's notification about this like. + models.RemoveNotification(req.TableName, req.TableID) + } else { + if err := models.AddLike(currentUser, req.TableName, req.TableID); err != nil { + SendJSON(w, http.StatusBadRequest, Response{ + Error: fmt.Sprintf("Error liking: %s", err), + }) + return + } + + // Notify the recipient of the like. + log.Info("Added like on %s:%d, notifying owner %+v", req.TableName, req.TableID, targetUser) + if targetUser != nil { + notif := &models.Notification{ + UserID: targetUser.ID, + User: *currentUser, + Type: models.NotificationLike, + TableName: req.TableName, + TableID: req.TableID, + } + if err := models.CreateNotification(notif); err != nil { + log.Error("Couldn't create Likes notification: %s", err) + } + } + } + + // Send success response. + SendJSON(w, http.StatusOK, Response{ + OK: true, + Likes: models.CountLikes(req.TableName, req.TableID), + }) + }) +} diff --git a/pkg/controller/api/read_notification.go b/pkg/controller/api/read_notification.go new file mode 100644 index 0000000..3a34edc --- /dev/null +++ b/pkg/controller/api/read_notification.go @@ -0,0 +1,76 @@ +package api + +import ( + "fmt" + "net/http" + + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" +) + +// ReadNotification API to mark a notif ID as "read." +func ReadNotification() http.HandlerFunc { + // Request JSON schema. + type Request struct { + ID uint64 `json:"id"` + } + + // Response JSON schema. + type Response struct { + OK bool `json:"OK"` + Error string `json:"error,omitempty"` + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + SendJSON(w, http.StatusNotAcceptable, Response{ + Error: "POST method only", + }) + return + } + + // Get the current user. + currentUser, err := session.CurrentUser(r) + if err != nil { + SendJSON(w, http.StatusBadRequest, Response{ + Error: "Couldn't get current user!", + }) + return + } + + // Parse request payload. + var req Request + if err := ParseJSON(r, &req); err != nil { + SendJSON(w, http.StatusBadRequest, Response{ + Error: fmt.Sprintf("Error with request payload: %s", err), + }) + return + } + + // Get this notification. + notif, err := models.GetNotification(req.ID) + if err != nil { + SendJSON(w, http.StatusInternalServerError, Response{ + Error: err.Error(), + }) + return + } + + // Ensure it's ours to read. + if notif.UserID != currentUser.ID { + SendJSON(w, http.StatusForbidden, Response{ + Error: "That is not your notification.", + }) + return + } + + // Mark it read. + notif.Read = true + notif.Save() + + // Send success response. + SendJSON(w, http.StatusOK, Response{ + OK: true, + }) + }) +} diff --git a/pkg/controller/forum/forum.go b/pkg/controller/forum/forum.go index 91049ea..f77cd16 100644 --- a/pkg/controller/forum/forum.go +++ b/pkg/controller/forum/forum.go @@ -41,6 +41,14 @@ func Forum() http.HandlerFunc { return } + // Get the pinned threads. + pinned, err := models.PinnedThreads(forum) + if err != nil { + session.FlashError(w, r, "Couldn't get pinned threads: %s", err) + templates.Redirect(w, "/") + return + } + // Get all the categorized index forums. // XXX: we get a large page size to get ALL official forums var pager = &models.Pagination{ @@ -57,6 +65,9 @@ func Forum() http.HandlerFunc { return } + // Inject pinned threads on top. + threads = append(pinned, threads...) + // Map the statistics (replies, views) of these threads. threadMap := models.MapThreadStatistics(threads) diff --git a/pkg/controller/forum/forums.go b/pkg/controller/forum/forums.go index 51ff5fb..2987040 100644 --- a/pkg/controller/forum/forums.go +++ b/pkg/controller/forum/forums.go @@ -51,7 +51,7 @@ func Landing() http.HandlerFunc { } pager.ParsePage(r) - forums, err := models.PaginateForums(currentUser.ID, config.ForumCategories, pager) + forums, err := models.PaginateForums(currentUser, config.ForumCategories, pager) if err != nil { session.FlashError(w, r, "Couldn't paginate forums: %s", err) templates.Redirect(w, "/") diff --git a/pkg/controller/forum/new_post.go b/pkg/controller/forum/new_post.go index c67e92e..9589a82 100644 --- a/pkg/controller/forum/new_post.go +++ b/pkg/controller/forum/new_post.go @@ -24,12 +24,18 @@ func NewPost() http.HandlerFunc { intent = r.FormValue("intent") // preview or submit title = r.FormValue("title") // for new forum post only message = r.PostFormValue("message") // comment body + isPinned = r.PostFormValue("pinned") == "true" // owners or admins only isExplicit = r.PostFormValue("explicit") == "true" // for thread only isNoReply = r.PostFormValue("noreply") == "true" // for thread only isDelete = r.FormValue("delete") == "true" // delete comment (along with edit=$id) forum *models.Forum thread *models.Thread // if replying to a thread comment *models.Comment // if editing a comment + + // If we are modifying a comment (post) and it's the OG post of the + // thread, we show and accept the thread settings to be updated as + // well (pinned, explicit, noreply) + isOriginalComment bool ) // Get the current user. @@ -88,6 +94,18 @@ func NewPost() http.HandlerFunc { message = comment.Message } + // Is this the OG thread of the post? + if thread.CommentID == comment.ID { + isOriginalComment = true + + // Restore the checkbox option form values from thread settings. + if r.Method == http.MethodGet { + isPinned = thread.Pinned + isExplicit = thread.Explicit + isNoReply = thread.NoReply + } + } + // Are we DELETING this comment? if isDelete { if err := thread.DeleteReply(comment); err != nil { @@ -116,6 +134,17 @@ func NewPost() http.HandlerFunc { // Are we modifying an existing comment? if comment != nil { comment.Message = message + + // Can we update the thread props? + if isOriginalComment { + thread.Pinned = isPinned + thread.Explicit = isExplicit + thread.NoReply = isNoReply + if err := thread.Save(); err != nil { + session.FlashError(w, r, "Couldn't save thread properties: %s", err) + } + } + if err := comment.Save(); err != nil { session.FlashError(w, r, "Couldn't save comment: %s", err) } else { @@ -142,6 +171,7 @@ func NewPost() http.HandlerFunc { forum.ID, title, message, + isPinned, isExplicit, isNoReply, ); err != nil { @@ -155,12 +185,18 @@ func NewPost() http.HandlerFunc { } var vars = map[string]interface{}{ - "Forum": forum, - "Thread": thread, - "Intent": intent, - "PostTitle": title, - "EditCommentID": editCommentID, - "Message": message, + "Forum": forum, + "Thread": thread, + "Intent": intent, + "PostTitle": title, + "EditCommentID": editCommentID, + "EditThreadSettings": isOriginalComment, + "Message": message, + + // Thread settings (for editing the original comment esp.) + "IsPinned": isPinned, + "IsExplicit": isExplicit, + "IsNoReply": isNoReply, } if err := tmpl.Execute(w, r, vars); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/pkg/controller/index/contact.go b/pkg/controller/index/contact.go index e13ca24..1769ff0 100644 --- a/pkg/controller/index/contact.go +++ b/pkg/controller/index/contact.go @@ -65,6 +65,15 @@ func Contact() http.HandlerFunc { case "report.message": tableName = "messages" tableLabel = "Direct Message conversation" + case "report.comment": + tableName = "comments" + + // Find this comment. + if comment, err := models.GetComment(uint64(tableID)); err == nil { + tableLabel = fmt.Sprintf(`A comment written by "%s"`, comment.User.Username) + } else { + log.Error("/contact: couldn't produce table label for comment %d: %s", tableID, err) + } } } diff --git a/pkg/controller/photo/site_gallery.go b/pkg/controller/photo/site_gallery.go index 8593627..c99e0e5 100644 --- a/pkg/controller/photo/site_gallery.go +++ b/pkg/controller/photo/site_gallery.go @@ -46,10 +46,18 @@ func SiteGallery() http.HandlerFunc { session.FlashError(w, r, "Failed to MapUsers: %s", err) } + // Get Likes information about these photos. + var photoIDs = []uint64{} + for _, p := range photos { + photoIDs = append(photoIDs, p.ID) + } + likeMap := models.MapLikes(currentUser, "photos", photoIDs) + var vars = map[string]interface{}{ "IsSiteGallery": true, "Photos": photos, "UserMap": userMap, + "LikeMap": likeMap, "Pager": pager, "ViewStyle": viewStyle, } diff --git a/pkg/controller/photo/user_gallery.go b/pkg/controller/photo/user_gallery.go index 5e261e3..3738454 100644 --- a/pkg/controller/photo/user_gallery.go +++ b/pkg/controller/photo/user_gallery.go @@ -91,11 +91,19 @@ func UserPhotos() http.HandlerFunc { explicitCount, _ = models.CountExplicitPhotos(user.ID, visibility) } + // Get Likes information about these photos. + var photoIDs = []uint64{} + for _, p := range photos { + photoIDs = append(photoIDs, p.ID) + } + likeMap := models.MapLikes(currentUser, "photos", photoIDs) + var vars = map[string]interface{}{ "IsOwnPhotos": currentUser.ID == user.ID, "User": user, "Photos": photos, "Pager": pager, + "LikeMap": likeMap, "ViewStyle": viewStyle, "ExplicitCount": explicitCount, } diff --git a/pkg/middleware/csrf.go b/pkg/middleware/csrf.go index ef26bf3..7f6a680 100644 --- a/pkg/middleware/csrf.go +++ b/pkg/middleware/csrf.go @@ -18,6 +18,12 @@ func CSRF(handler http.Handler) http.Handler { token := MakeCSRFCookie(r, w) ctx := context.WithValue(r.Context(), session.CSRFKey, token) + // If it's a JSON post, allow it thru. + if r.Header.Get("Content-Type") == "application/json" { + handler.ServeHTTP(w, r.WithContext(ctx)) + return + } + // If we are running a POST request, validate the CSRF form value. if r.Method != http.MethodGet { r.ParseMultipartForm(config.MultipartMaxMemory) diff --git a/pkg/models/deletion/delete_user.go b/pkg/models/deletion/delete_user.go index 10dd7a4..a08ad3f 100644 --- a/pkg/models/deletion/delete_user.go +++ b/pkg/models/deletion/delete_user.go @@ -19,6 +19,10 @@ func DeleteUser(user *models.User) error { } var todo = []remover{ + {"Notifications", DeleteNotifications}, + {"Likes", DeleteLikes}, + {"Threads", DeleteForumThreads}, + {"Comments", DeleteComments}, {"Photos", DeleteUserPhotos}, {"Certification Photo", DeleteCertification}, {"Messages", DeleteUserMessages}, @@ -112,6 +116,26 @@ func DeleteFriends(userID uint64) error { return result.Error } +// DeleteNotifications scrubs all notifications about a user. +func DeleteNotifications(userID uint64) error { + log.Error("DeleteUser: DeleteNotifications(%d)", userID) + result := models.DB.Where( + "user_id = ? OR about_user_id = ?", + userID, userID, + ).Delete(&models.Notification{}) + return result.Error +} + +// DeleteLikes scrubs all Likes about a user. +func DeleteLikes(userID uint64) error { + log.Error("DeleteUser: DeleteLikes(%d)", userID) + result := models.DB.Where( + "user_id = ? OR (table_name='users' AND table_id=?)", + userID, userID, + ).Delete(&models.Like{}) + return result.Error +} + // DeleteProfile scrubs data for deleting a user. func DeleteProfile(userID uint64) error { log.Error("DeleteUser: DeleteProfile(%d)", userID) @@ -121,3 +145,57 @@ func DeleteProfile(userID uint64) error { ).Delete(&models.ProfileField{}) return result.Error } + +// DeleteForumThreads scrubs all forum threads started by the user. +func DeleteForumThreads(userID uint64) error { + log.Error("DeleteUser: DeleteForumThreads(%d)", userID) + + var threadIDs = []uint64{} + result := models.DB.Table( + "threads", + ).Joins( + "JOIN comments ON (threads.comment_id = comments.id)", + ).Select( + "distinct(threads.id) as id", + ).Where( + "comments.user_id = ?", + userID, + ).Scan(&threadIDs) + + if result.Error != nil { + return fmt.Errorf("Couldn't list thread IDs created by user: %s", result.Error) + } + + log.Warn("thread IDs to wipe: %+v", threadIDs) + + // Wipe all these threads and their comments. + if len(threadIDs) > 0 { + result = models.DB.Where( + "table_name = ? AND table_id IN ?", + "threads", threadIDs, + ).Delete(&models.Comment{}) + if result.Error != nil { + return fmt.Errorf("Couldn't wipe threads of comments: %s", result.Error) + } + + // And finish the threads off too. + result = models.DB.Where( + "id IN ?", + threadIDs, + ).Delete(&models.Thread{}) + return result.Error + } + + return nil +} + +// DeleteComments deletes all comments by the user. +func DeleteComments(userID uint64) error { + log.Error("DeleteUser: DeleteComments(%d)", userID) + + result := models.DB.Where( + "user_id = ?", + userID, + ).Delete(&models.Comment{}) + return result.Error +} diff --git a/pkg/models/forum.go b/pkg/models/forum.go index 44a6b8d..cd44c46 100644 --- a/pkg/models/forum.go +++ b/pkg/models/forum.go @@ -62,7 +62,7 @@ Parameters: - categories: optional, filter within categories - pager */ -func PaginateForums(userID uint64, categories []string, pager *Pagination) ([]*Forum, error) { +func PaginateForums(user *User, categories []string, pager *Pagination) ([]*Forum, error) { var ( fs = []*Forum{} query = (&Forum{}).Preload() @@ -75,6 +75,11 @@ func PaginateForums(userID uint64, categories []string, pager *Pagination) ([]*F placeholders = append(placeholders, categories) } + // Hide explicit forum if user hasn't opted into it. + if !user.Explicit && !user.IsAdmin { + wheres = append(wheres, "explicit = false") + } + // Filters? if len(wheres) > 0 { query = query.Where( diff --git a/pkg/models/forum_stats.go b/pkg/models/forum_stats.go index 86aeb0f..8985ecc 100644 --- a/pkg/models/forum_stats.go +++ b/pkg/models/forum_stats.go @@ -25,119 +25,11 @@ func MapForumStatistics(forums []*Forum) ForumStatsMap { result[forum.ID] = &ForumStatistics{} } - // FIRST: count the threads in each forum. - { - // Hold the result of the count/group by query. - type group struct { - ID uint64 - Threads uint64 - } - var groups = []group{} - - // Count comments grouped by thread IDs. - err := DB.Table( - "threads", - ).Select( - "forum_id AS id, count(id) AS threads", - ).Where( - "forum_id IN ?", - IDs, - ).Group("forum_id").Scan(&groups) - - if err.Error != nil { - log.Error("MapForumStatistics: SQL error: %s", err.Error) - } - - // Map the results in. - for _, row := range groups { - log.Error("Got row: %+v", row) - if stats, ok := result[row.ID]; ok { - stats.Threads = row.Threads - } - } - } - - // THEN: count all posts in all those threads. - { - type group struct { - ID uint64 - Posts uint64 - } - var groups = []group{} - - err := DB.Table( - "comments", - ).Joins( - "JOIN threads ON (table_name = 'threads' AND table_id = threads.id)", - ).Joins( - "JOIN forums ON (threads.forum_id = forums.id)", - ).Select( - "forums.id AS id, count(comments.id) AS posts", - ).Where( - `table_name = 'threads' AND EXISTS ( - SELECT 1 - FROM threads - WHERE table_id = threads.id - AND threads.forum_id IN ? - )`, - IDs, - ).Group("forums.id").Scan(&groups) - - if err.Error != nil { - log.Error("SQL error collecting posts for forum: %s", err.Error) - } - - // Map the results in. - for _, row := range groups { - log.Error("Got row2: %+v", row) - if stats, ok := result[row.ID]; ok { - stats.Posts = row.Posts - } - } - } - - // THEN: count all distinct users in those threads. - // TODO: hairy - // { - // type group struct { - // ID uint64 - // Users uint64 - // } - // var groups = []group{} - - // err := DB.Table( - // "comments", - // ).Joins( - // "JOIN threads ON (table_name = 'threads' AND table_id = threads.id)", - // ).Joins( - // "JOIN forums ON (threads.forum_id = forums.id)", - // ).Select( - // "forums.id AS forum_id, count(distinct(comments.user_id)) AS users", - // ).Where( - // // `table_name = 'threads' AND EXISTS ( - // // SELECT 1 - // // FROM threads - // // WHERE table_id = threads.id - // // AND threads.forum_id IN ? - // // )`, - // "forums.id IN ?", - // IDs, - // ).Group("forums.id").Scan(&groups) - - // if err.Error != nil { - // log.Error("SQL error collecting users for forum: %s", err.Error) - // } - - // // Map the results in. - // for _, row := range groups { - // log.Error("Got row2: %+v", row) - // if stats, ok := result[row.ID]; ok { - // stats.Users = row.Users - // } - // } - // } - - // Get THE most recent thread on this forum. + // Gather all the statistics. + result.generateThreadCount(IDs) + result.generatePostCount(IDs) + result.generateUserCount(IDs) + result.generateRecentThreads(IDs) return result } @@ -155,3 +47,157 @@ func (ts ForumStatsMap) Get(threadID uint64) *ForumStatistics { } return nil } + +// Compute the count of threads in each of the forum 'IDs'. +func (ts ForumStatsMap) generateThreadCount(IDs []uint64) { + // Hold the result of the count/group by query. + type group struct { + ID uint64 + Threads uint64 + } + var groups = []group{} + + // Count comments grouped by thread IDs. + err := DB.Table( + "threads", + ).Select( + "forum_id AS id, count(id) AS threads", + ).Where( + "forum_id IN ?", + IDs, + ).Group("forum_id").Scan(&groups) + + if err.Error != nil { + log.Error("MapForumStatistics: SQL error: %s", err.Error) + } + + // Map the results in. + for _, row := range groups { + if stats, ok := ts[row.ID]; ok { + stats.Threads = row.Threads + } + } +} + +// Compute the count of all posts in each of the forum 'IDs'. +func (ts ForumStatsMap) generatePostCount(IDs []uint64) { + type group struct { + ID uint64 + Posts uint64 + } + var groups = []group{} + + err := DB.Table( + "comments", + ).Joins( + "JOIN threads ON (table_name = 'threads' AND table_id = threads.id)", + ).Joins( + "JOIN forums ON (threads.forum_id = forums.id)", + ).Select( + "forums.id AS id, count(comments.id) AS posts", + ).Where( + `table_name = 'threads' AND EXISTS ( + SELECT 1 + FROM threads + WHERE table_id = threads.id + AND threads.forum_id IN ? + )`, + IDs, + ).Group("forums.id").Scan(&groups) + + if err.Error != nil { + log.Error("SQL error collecting posts for forum: %s", err.Error) + } + + // Map the results in. + for _, row := range groups { + if stats, ok := ts[row.ID]; ok { + stats.Posts = row.Posts + } + } +} + +// Compute the count of all users in each of the forum 'IDs'. +func (ts ForumStatsMap) generateUserCount(IDs []uint64) { + type group struct { + ForumID uint64 + Users uint64 + } + var groups = []group{} + + err := DB.Table( + "comments", + ).Joins( + "JOIN threads ON (table_name = 'threads' AND table_id = threads.id)", + ).Joins( + "JOIN forums ON (threads.forum_id = forums.id)", + ).Select( + "forums.id AS forum_id, count(distinct(comments.user_id)) AS users", + ).Where( + "forums.id IN ?", + IDs, + ).Group("forums.id").Scan(&groups) + + if err.Error != nil { + log.Error("SQL error collecting users for forum: %s", err.Error) + } + + // Map the results in. + for _, row := range groups { + if stats, ok := ts[row.ForumID]; ok { + stats.Users = row.Users + } + } +} + +// Compute the recent threads for each of the forum 'IDs'. +func (ts ForumStatsMap) generateRecentThreads(IDs []uint64) { + var threadIDs = []map[string]interface{}{} + err := DB.Table( + "threads", + ).Select( + "forum_id, id AS thread_id, updated_at", + ).Where( + `updated_at = (SELECT MAX(updated_at) + FROM threads t2 + WHERE threads.forum_id = t2.forum_id) + AND threads.forum_id IN ?`, + IDs, + ).Order( + "updated_at desc", + ).Scan(&threadIDs) + + if err.Error != nil { + log.Error("Getting most recent thread IDs: %s", err.Error) + } + + // Map them easier. + var ( + threadForumMap = map[uint64]uint64{} + allThreadIDs = []uint64{} + ) + for _, row := range threadIDs { + if row["thread_id"] == nil || row["forum_id"] == nil { + continue + } + + var ( + threadID = uint64(row["thread_id"].(int64)) + forumID = uint64(row["forum_id"].(int64)) + ) + + allThreadIDs = append(allThreadIDs, threadID) + threadForumMap[threadID] = forumID + } + + // Select and map these threads in. + if threadMap, err := GetThreads(allThreadIDs); err == nil { + for threadID, thread := range threadMap { + if forumID, ok := threadForumMap[threadID]; ok { + if stats, ok := ts[forumID]; ok { + stats.RecentThread = thread + } + } + } + } +} diff --git a/pkg/models/like.go b/pkg/models/like.go new file mode 100644 index 0000000..b9a6323 --- /dev/null +++ b/pkg/models/like.go @@ -0,0 +1,145 @@ +package models + +import ( + "time" + + "git.kirsle.net/apps/gosocial/pkg/log" +) + +// Like table. +type Like struct { + ID uint64 `gorm:"primaryKey"` + UserID uint64 `gorm:"index"` // who it belongs to + TableName string + TableID uint64 + CreatedAt time.Time + UpdatedAt time.Time +} + +// LikeableTables are the set of table names that allow likes (used by the JSON API). +var LikeableTables = map[string]interface{}{ + "photos": nil, + "users": nil, +} + +// AddLike to something. +func AddLike(user *User, tableName string, tableID uint64) error { + // Already has a like? + var like = &Like{} + exist := DB.Model(like).Where( + "user_id = ? AND table_name = ? AND table_id = ?", + user.ID, tableName, tableID, + ).First(&like) + if exist.Error == nil { + return nil + } + + // Create it. + like = &Like{ + UserID: user.ID, + TableName: tableName, + TableID: tableID, + } + return DB.Create(like).Error +} + +// Unlike something. +func Unlike(user *User, tableName string, tableID uint64) error { + result := DB.Where( + "user_id = ? AND table_name = ? AND table_id = ?", + user.ID, tableName, tableID, + ).Delete(&Like{}) + return result.Error +} + +// CountLikes on something. +func CountLikes(tableName string, tableID uint64) int64 { + var count int64 + DB.Model(&Like{}).Where( + "table_name = ? AND table_id = ?", + tableName, tableID, + ).Count(&count) + return count +} + +// 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{} + if r := DB.Table( + "likes", + ).Select( + "table_id", + ).Where( + "user_id = ? AND table_name = ? AND table_id IN ?", + user.ID, tableName, tableIDs, + ).Scan(&result); r.Error != nil { + return result, r.Error + } + + return result, nil +} + +// LikeMap maps table IDs to Likes metadata. +type LikeMap map[uint64]*LikeStats + +// Get like stats from the map. +func (lm LikeMap) Get(id uint64) *LikeStats { + if stats, ok := lm[id]; ok { + return stats + } + return &LikeStats{} +} + +// LikeStats holds mapped statistics about liked objects. +type LikeStats struct { + Count int64 // how many total + UserLikes bool // current user likes it +} + +// MapLikes over a set of table IDs. +func MapLikes(user *User, tableName string, tableIDs []uint64) LikeMap { + var result = LikeMap{} + + // Initialize the result set. + for _, id := range tableIDs { + result[id] = &LikeStats{} + } + + // Hold the result of the grouped count query. + type group struct { + ID uint64 + Likes int64 + } + var groups = []group{} + + // Map the counts of likes to each of these IDs. + if res := DB.Table( + "likes", + ).Select( + "table_id AS id, count(id) AS likes", + ).Where( + "table_name = ? AND table_id IN ?", + tableName, tableIDs, + ).Group("table_id").Scan(&groups); res.Error != nil { + log.Error("MapLikes: count query: %s", res.Error) + } + + // Map the counts back in. + for _, row := range groups { + if stats, ok := result[row.ID]; ok { + stats.Count = row.Likes + } + } + + // Does the CURRENT USER like any of these IDs? + if likedIDs, err := LikedIDs(user, tableName, tableIDs); err == nil { + log.Error("USER LIKES IDS: %+v", likedIDs) + for _, id := range likedIDs { + if stats, ok := result[id]; ok { + stats.UserLikes = true + } + } + } + + return result +} diff --git a/pkg/models/models.go b/pkg/models/models.go index d8d07df..651547c 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -19,4 +19,6 @@ func AutoMigrate() { DB.AutoMigrate(&Forum{}) DB.AutoMigrate(&Thread{}) DB.AutoMigrate(&Comment{}) + DB.AutoMigrate(&Like{}) + DB.AutoMigrate(&Notification{}) } diff --git a/pkg/models/notification.go b/pkg/models/notification.go new file mode 100644 index 0000000..cf34d72 --- /dev/null +++ b/pkg/models/notification.go @@ -0,0 +1,175 @@ +package models + +import ( + "time" + + "git.kirsle.net/apps/gosocial/pkg/log" + "gorm.io/gorm" +) + +// Notification table. +type Notification struct { + ID uint64 `gorm:"primaryKey"` + UserID uint64 `gorm:"index"` // who it belongs to + AboutUserID *uint64 `form:"index"` // the other party of this notification + User User `gorm:"foreignKey:about_user_id"` + Type NotificationType // like, comment, ... + Read bool `gorm:"index"` + TableName string // on which of your tables (photos, comments, ...) + TableID uint64 + Message string // text associated, e.g. copy of comment added + CreatedAt time.Time + UpdatedAt time.Time +} + +// Preload related tables for the forum (classmethod). +func (n *Notification) Preload() *gorm.DB { + return DB.Preload("User.ProfilePhoto") +} + +type NotificationType string + +const ( + NotificationLike NotificationType = "like" + NotificationComment = "comment" + NotificationCustom = "custom" // custom message pushed +) + +// CreateNotification +func CreateNotification(n *Notification) error { + return DB.Create(n).Error +} + +// GetNotification by ID. +func GetNotification(id uint64) (*Notification, error) { + var n *Notification + result := DB.Model(n).First(&n, id) + return n, result.Error +} + +// RemoveNotification about a table ID, e.g. when removing a like. +func RemoveNotification(tableName string, tableID uint64) error { + result := DB.Where( + "table_name = ? AND table_id = ?", + tableName, tableID, + ).Delete(&Notification{}) + return result.Error +} + +// MarkNotificationsRead sets all a user's notifications to read. +func MarkNotificationsRead(user *User) error { + return DB.Model(&Notification{}).Where( + "user_id = ? AND read IS NOT TRUE", + user.ID, + ).Update("read", true).Error +} + +// CountUnreadNotifications gets the count of unread Notifications for a user. +func CountUnreadNotifications(userID uint64) (int64, error) { + query := DB.Where( + "user_id = ? AND read = ?", + userID, + false, + ) + + var count int64 + result := query.Model(&Notification{}).Count(&count) + return count, result.Error +} + +// PaginateNotifications returns the user's notifications. +func PaginateNotifications(user *User, pager *Pagination) ([]*Notification, error) { + var ns = []*Notification{} + + query := (&Notification{}).Preload().Where( + "user_id = ?", + user.ID, + ).Order( + pager.Sort, + ) + + query.Model(&Notification{}).Count(&pager.Total) + result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&ns) + return ns, result.Error +} + +// Save a notification. +func (n *Notification) Save() error { + return DB.Save(n).Error +} + +// NotificationBody can store remote tables mapped. +type NotificationBody struct { + PhotoID uint64 + Photo *Photo +} + +type NotificationMap map[uint64]*NotificationBody + +// Get a notification's body from the map. +func (m NotificationMap) Get(id uint64) *NotificationBody { + if body, ok := m[id]; ok { + return body + } + return &NotificationBody{} +} + +// MapNotifications loads associated assets, like Photos, mapped to their notification ID. +func MapNotifications(ns []*Notification) NotificationMap { + var ( + IDs = []uint64{} + result = NotificationMap{} + ) + + // Collect notification IDs. + for _, row := range ns { + IDs = append(IDs, row.ID) + result[row.ID] = &NotificationBody{} + } + + type scanner struct { + PhotoID uint64 + NotificationID uint64 + } + var scan []scanner + + // Load all of these that have photos. + err := DB.Table( + "notifications", + ).Joins( + "JOIN photos ON (notifications.table_name='photos' AND notifications.table_id=photos.id)", + ).Select( + "photos.id AS photo_id", + "notifications.id AS notification_id", + ).Where( + "notifications.id IN ?", + IDs, + ).Scan(&scan) + if err != nil { + log.Error("Couldn't select photo IDs for notifications: %s", err) + } + + // Collect and load all the photos by ID. + var photoIDs = []uint64{} + for _, row := range scan { + // Store the photo ID in the result now. + result[row.NotificationID].PhotoID = row.PhotoID + photoIDs = append(photoIDs, row.PhotoID) + } + + // Load the photos. + if len(photoIDs) > 0 { + if photos, err := GetPhotos(photoIDs); err != nil { + log.Error("Couldn't load photo IDs for notifications: %s", err) + } else { + // Marry them to their notification IDs. + for _, body := range result { + if photo, ok := photos[body.PhotoID]; ok { + body.Photo = photo + } + } + } + } + + return result +} diff --git a/pkg/models/photo.go b/pkg/models/photo.go index 576d841..3d4a10e 100644 --- a/pkg/models/photo.go +++ b/pkg/models/photo.go @@ -76,6 +76,21 @@ func GetPhoto(id uint64) (*Photo, error) { return p, result.Error } +// GetPhotos by an array of IDs, mapped to their IDs. +func GetPhotos(IDs []uint64) (map[uint64]*Photo, error) { + var ( + mp = map[uint64]*Photo{} + ps = []*Photo{} + ) + + result := DB.Model(&Photo{}).Where("id IN ?", IDs).Find(&ps) + for _, row := range ps { + mp[row.ID] = row + } + + return mp, result.Error +} + /* PaginateUserPhotos gets a page of photos belonging to a user ID. */ diff --git a/pkg/models/thread.go b/pkg/models/thread.go index 07972c3..f78c346 100644 --- a/pkg/models/thread.go +++ b/pkg/models/thread.go @@ -3,6 +3,7 @@ package models import ( "errors" "fmt" + "strings" "time" "git.kirsle.net/apps/gosocial/pkg/log" @@ -14,6 +15,7 @@ type Thread struct { ID uint64 `gorm:"primaryKey"` ForumID uint64 `gorm:"index"` Forum Forum + Pinned bool `gorm:"index"` Explicit bool `gorm:"index"` NoReply bool Title string @@ -36,11 +38,27 @@ func GetThread(id uint64) (*Thread, error) { return t, result.Error } +// GetThreads queries a set of thread IDs and returns them mapped. +func GetThreads(IDs []uint64) (map[uint64]*Thread, error) { + var ( + mt = map[uint64]*Thread{} + ts = []*Thread{} + ) + + result := (&Thread{}).Preload().Where("id IN ?", IDs).Find(&ts) + for _, row := range ts { + mt[row.ID] = row + } + + return mt, result.Error +} + // CreateThread creates a new thread with proper Comment structure. -func CreateThread(user *User, forumID uint64, title, message string, explicit, noReply bool) (*Thread, error) { +func CreateThread(user *User, forumID uint64, title, message string, pinned, explicit, noReply bool) (*Thread, error) { thread := &Thread{ ForumID: forumID, Title: title, + Pinned: pinned, Explicit: explicit, NoReply: noReply && user.IsAdmin, Comment: Comment{ @@ -92,16 +110,41 @@ func (t *Thread) DeleteReply(comment *Comment) error { return comment.Delete() } -// PaginateThreads provides a forum index view of posts. -func PaginateThreads(user *User, forum *Forum, pager *Pagination) ([]*Thread, error) { +// PinnedThreads returns all pinned threads in a forum (there should generally be few of these). +func PinnedThreads(forum *Forum) ([]*Thread, error) { var ( ts = []*Thread{} - query = (&Thread{}).Preload() + query = (&Thread{}).Preload().Where( + "forum_id = ? AND pinned IS TRUE", + forum.ID, + ).Order("updated_at desc") ) + result := query.Find(&ts) + return ts, result.Error +} + +// PaginateThreads provides a forum index view of posts, minus pinned posts. +func PaginateThreads(user *User, forum *Forum, pager *Pagination) ([]*Thread, error) { + var ( + ts = []*Thread{} + query = (&Thread{}).Preload() + wheres = []string{} + placeholders = []interface{}{} + ) + + // Always filters. + wheres = append(wheres, "forum_id = ? AND pinned IS NOT TRUE") + placeholders = append(placeholders, forum.ID) + + // If the user hasn't opted in for Explicit, hide NSFW threads. + if !user.Explicit && !user.IsAdmin { + wheres = append(wheres, "explicit IS NOT TRUE") + } + query = query.Where( - "forum_id = ?", - forum.ID, + strings.Join(wheres, " AND "), + placeholders..., ).Order(pager.Sort) query.Model(&Thread{}).Count(&pager.Total) diff --git a/pkg/router/router.go b/pkg/router/router.go index fbfc73a..2d87c5f 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -72,6 +72,8 @@ func New() http.Handler { // JSON API endpoints. mux.HandleFunc("/v1/version", api.Version()) mux.HandleFunc("/v1/users/me", api.LoginOK()) + mux.Handle("/v1/likes", middleware.LoginRequired(api.Likes())) + mux.Handle("/v1/notifications/read", middleware.LoginRequired(api.ReadNotification())) // Static files. mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(config.StaticPath)))) diff --git a/pkg/templates/template_vars.go b/pkg/templates/template_vars.go index 16f57bb..9bf0bb3 100644 --- a/pkg/templates/template_vars.go +++ b/pkg/templates/template_vars.go @@ -13,6 +13,8 @@ import ( // MergeVars mixes in globally available template variables. The http.Request is optional. func MergeVars(r *http.Request, m map[string]interface{}) { m["Title"] = config.Title + m["BuildHash"] = config.RuntimeBuild + m["BuildDate"] = config.RuntimeBuildDate m["Subtitle"] = config.Subtitle m["YYYY"] = time.Now().Year() @@ -31,9 +33,10 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) { m["SessionImpersonated"] = false // User notification counts for nav bar. - m["NavUnreadMessages"] = 0 // New messages - m["NavFriendRequests"] = 0 // Friend requests - m["NavTotalNotifications"] = 0 // Total of above + m["NavUnreadMessages"] = 0 // New messages + m["NavFriendRequests"] = 0 // Friend requests + m["NavUnreadNotifications"] = 0 // general notifications + m["NavTotalNotifications"] = 0 // Total of above // Admin notification counts for nav bar. m["NavCertificationPhotos"] = 0 // Cert. photos needing approval @@ -50,11 +53,21 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) { m["LoggedIn"] = true m["CurrentUser"] = user + // Get user recent notifications. + /*notifPager := &models.Pagination{ + Page: 1, + PerPage: 10, + } + if notifs, err := models.PaginateNotifications(user, notifPager); err == nil { + m["Notifications"] = notifs + }*/ + // Collect notification counts. var ( // For users - countMessages int64 - countFriendReqs int64 + countMessages int64 + countFriendReqs int64 + countNotifications int64 // For admins countCertPhotos int64 @@ -77,6 +90,14 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) { log.Error("MergeUserVars: couldn't CountFriendRequests for %d: %s", user.ID, err) } + // Count other notifications. + if count, err := models.CountUnreadNotifications(user.ID); err == nil { + m["NavUnreadNotifications"] = count + countNotifications = count + } else { + log.Error("MergeUserVars: couldn't CountFriendRequests for %d: %s", user.ID, err) + } + // Are we admin? if user.IsAdmin { // Any pending certification photos or feedback? @@ -90,6 +111,6 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) { } // Total count for user notifications. - m["NavTotalNotifications"] = countMessages + countFriendReqs + countCertPhotos + countFeedback + m["NavTotalNotifications"] = countMessages + countFriendReqs + countNotifications + countCertPhotos + countFeedback } } diff --git a/web/static/js/likes.js b/web/static/js/likes.js new file mode 100644 index 0000000..4f5ae26 --- /dev/null +++ b/web/static/js/likes.js @@ -0,0 +1,56 @@ +// Like button handler. +document.addEventListener('DOMContentLoaded', () => { + const red = "has-text-danger"; + let busy = false; + + // Bind to the like buttons. + (document.querySelectorAll(".nonshy-like-button") || []).forEach(node => { + node.addEventListener("click", (e) => { + if (busy) return; + + let $icon = node.querySelector(".icon"), + $label = node.querySelector(".nonshy-likes"), + tableName = node.dataset.tableName, + tableID = node.dataset.tableId, + liking = false; + + // Toggle the color of the heart. + if ($icon.classList.contains(red)) { + $icon.classList.remove(red); + } else { + liking = true; + $icon.classList.add(red); + } + + // Ajax request to backend. + busy = true; + return fetch("/v1/likes", { + method: "POST", + mode: "same-origin", + cache: "no-cache", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + "name": tableName, // TODO + "id": parseInt(tableID), + "unlike": !liking, + }), + }) + .then((response) => response.json()) + .then((data) => { + let likes = data.data.likes; + if (likes === 0) { + $label.innerHTML = "Like"; + } else { + $label.innerHTML = `Like (${likes})`; + } + }).catch(resp => { + window.alert(resp); + }).finally(() => { + busy = false; + }) + }); + }); +}); \ No newline at end of file diff --git a/web/templates/account/dashboard.html b/web/templates/account/dashboard.html index 4a5b4fc..e55d47f 100644 --- a/web/templates/account/dashboard.html +++ b/web/templates/account/dashboard.html @@ -136,18 +136,157 @@ + {{$Root := .}} +
-
+

Notifications

- TBD. +
+
+ {{if gt .NavUnreadNotifications 0}} + {{.NavUnreadNotifications}} unread notification{{Pluralize64 .NavUnreadNotifications}}. + {{else}} + No unread notifications. + {{end}} +
+ +
+ + + + {{range .Notifications}} + {{$Body := $Root.NotifMap.Get .ID}} + + + + {{end}} + +
+
+
+ {{if not .Read}} +
+ NEW! +
+ {{end}} + +
+ {{if .User.ProfilePhoto.ID}} + + {{else}} + + {{end}} +
+
+
+
+
+ {{if eq .Type "like"}} + + + {{.User.Username}} + liked your + {{if eq .TableName "photos"}} + photo. + {{else if eq .TableName "users"}} + profile page. + {{else}} + {{.TableName}}. + {{end}} + + {{else}} + {{.User.Username}} {{.Type}} {{.TableName}} {{.TableID}} + {{end}} +
+ + + {{if $Body.Photo}} +
+ {{or $Body.Photo.Caption "No caption."}} +
+ {{end}} + +
+ + {{SincePrettyCoarse .CreatedAt}} ago + +
+ + + {{if $Body.PhotoID}} +
+ + + {{if $Body.Photo.Caption}} + {{$Body.Photo.Caption}} + {{else}} + No caption. + {{end}} +
+ {{end}} +
+ +
+ + {{if .Pager.HasNext}} + + {{end}}
+ + {{end}} \ No newline at end of file diff --git a/web/templates/account/profile.html b/web/templates/account/profile.html index 1e046fa..f162679 100644 --- a/web/templates/account/profile.html +++ b/web/templates/account/profile.html @@ -121,16 +121,21 @@ - + {{$Like := .LikeMap.Get .User.ID}} +
+ -
--> +
diff --git a/web/templates/admin/user_actions.html b/web/templates/admin/user_actions.html index 0961aec..d22b2db 100644 --- a/web/templates/admin/user_actions.html +++ b/web/templates/admin/user_actions.html @@ -151,16 +151,4 @@
- - {{end}} \ No newline at end of file diff --git a/web/templates/base.html b/web/templates/base.html index 1854c7f..3d80f33 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -108,7 +108,7 @@
+ {{if or .CurrentUser.IsAdmin (not .Forum.Privileged) (eq .Forum.OwnerID .CurrentUser.ID)}} New Thread + {{end}}
@@ -41,6 +45,13 @@ (page {{.Pager.Page}} of {{.Pager.Pages}}).

+{{if .Forum.Privileged}} +
+ + Only moderators may create new threads on this forum. You may be able to reply to threads here. +
+{{end}} +
diff --git a/web/templates/forum/index.html b/web/templates/forum/index.html index 662b952..a8a5aec 100644 --- a/web/templates/forum/index.html +++ b/web/templates/forum/index.html @@ -33,7 +33,10 @@

{{.Category}}

{{if eq (len .Forums) 0}} - There are no forums under this category. + + There are no forums under this category. + {{if not $Root.CurrentUser.Explicit}}Your content filters (non-explicit) may be hiding some forums.{{end}} + {{else}} {{range .Forums}} {{$Stats := $Root.ForumMap.Get .ID}} @@ -63,7 +66,14 @@ {{if .Explicit}} - Explicit + Explicit + + {{end}} + + {{if .Privileged}} + + + Privileged {{end}}
@@ -71,8 +81,18 @@
-

Latest Post

- Hello world! +

Latest Post

+ {{if $Stats.RecentThread}} +
+ {{$Stats.RecentThread.Title}} + + by {{$Stats.RecentThread.Comment.User.Username}} +
+ Last updated {{SincePrettyCoarse $Stats.RecentThread.UpdatedAt}} ago +
+ {{else}} + No posts found. + {{end}}
@@ -97,7 +117,7 @@ {{end}}
- + diff --git a/web/templates/forum/new_post.html b/web/templates/forum/new_post.html index f732c8e..3425f50 100644 --- a/web/templates/forum/new_post.html +++ b/web/templates/forum/new_post.html @@ -81,19 +81,31 @@

- {{if not .Thread}} + {{if or (not .Thread) .EditThreadSettings}}
-
Options
+ {{if or .CurrentUser.IsAdmin (and .Forum (eq .Forum.OwnerID .CurrentUser.ID))}} +
+ +
+ {{end}} + {{if or .CurrentUser.Explicit .IsExplicit}}
+ {{end}} {{if .CurrentUser.IsAdmin}}
@@ -101,7 +113,7 @@ + {{if .IsNoReply}}checked{{end}}> No replies allowed
diff --git a/web/templates/forum/thread.html b/web/templates/forum/thread.html index 05837f6..7dd921a 100644 --- a/web/templates/forum/thread.html +++ b/web/templates/forum/thread.html @@ -5,8 +5,10 @@

- - {{.Forum.Title}} + + + {{.Forum.Title}} +

@@ -29,15 +31,45 @@
+ {{if not .Thread.NoReply}} Add Reply + {{end}}
-

{{or .Thread.Title "Untitled Thread"}}

+

+ {{if .Thread.Pinned}}{{end}} + {{or .Thread.Title "Untitled Thread"}} +

+ +
+ {{if .Thread.Pinned}} + + + Pinned + + {{end}} + + {{if .Thread.Explicit}} + + + NSFW + + {{end}} + + {{if .Thread.NoReply}} + + + No Reply + + {{end}} + + Updated {{SincePrettyCoarse .Thread.UpdatedAt}} ago +

Found {{.Pager.Total}} post{{Pluralize64 .Pager.Total}} on this thread @@ -97,18 +129,21 @@


-
+
{{SincePrettyCoarse .CreatedAt}} ago
+ + + {{if not $Root.Thread.NoReply}} + {{end}} {{if or $Root.CurrentUser.IsAdmin (eq $Root.CurrentUser.ID .User.ID)}}
@@ -137,17 +173,33 @@
{{end}}
+ + {{if $Root.CurrentUser.IsAdmin}} +
+ + + ID: {{.ID}} + +
+ {{end}}
{{end}} -
- - - Add Reply - -
+{{if .Thread.NoReply}} +
+ + This thread is not accepting any new replies. +
+{{else}} +
+ + + Add Reply + +
+{{end}} {{end}} \ No newline at end of file diff --git a/web/templates/partials/like_button.html b/web/templates/partials/like_button.html new file mode 100644 index 0000000..3d74e92 --- /dev/null +++ b/web/templates/partials/like_button.html @@ -0,0 +1,2 @@ +{{define "like-button"}} +{{end}} \ No newline at end of file diff --git a/web/templates/photo/gallery.html b/web/templates/photo/gallery.html index 7b85a81..4073079 100644 --- a/web/templates/photo/gallery.html +++ b/web/templates/photo/gallery.html @@ -259,6 +259,22 @@ {{else}}No caption{{end}} {{template "card-body" .}} + + +
+ {{$Like := $Root.LikeMap.Get .ID}} + +