From 9570129bba5f6bf24540cdad2e6a91686282f38c Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Tue, 20 Aug 2024 21:26:53 -0700 Subject: [PATCH] Forum Memberships & My List * Add "Browse" tab to the forums to view them all. * Text search * Show all, official, community, or "My List" forums. * Add a Follow/Unfollow button into the header bar of forums to add it to "My List" * On the Categories page, a special "My List" category appears at the top if the user follows categories, with their follows in alphabetical order. * On the Categories & Browse pages: forums you follow will have a green bookmark icon by their name. Permissions: * The forum owner is able to Delete comments by others, but not Edit. Notes: * Currently a max limit of 100 follow forums (no pagination yet). --- pkg/config/page_sizes.go | 5 +- pkg/controller/forum/browse.go | 104 +++++++++++++++++++++ pkg/controller/forum/forum.go | 9 +- pkg/controller/forum/forums.go | 18 +++- pkg/controller/forum/new_post.go | 7 +- pkg/controller/forum/subscribe.go | 67 ++++++++++++++ pkg/controller/forum/thread.go | 8 ++ pkg/models/forum.go | 38 +++++++- pkg/models/forum_membership.go | 121 +++++++++++++++++++++++++ pkg/models/models.go | 1 + pkg/router/router.go | 2 + web/templates/forum/board_index.html | 39 +++++++- web/templates/forum/index.html | 104 ++++++++++++++++++++- web/templates/forum/thread.html | 3 + web/templates/partials/forum_tabs.html | 5 + 15 files changed, 516 insertions(+), 15 deletions(-) create mode 100644 pkg/controller/forum/browse.go create mode 100644 pkg/controller/forum/subscribe.go create mode 100644 pkg/models/forum_membership.go diff --git a/pkg/config/page_sizes.go b/pkg/config/page_sizes.go index d2de4e6..19feadb 100644 --- a/pkg/config/page_sizes.go +++ b/pkg/config/page_sizes.go @@ -20,8 +20,9 @@ var ( PageSizeAdminUserNotes = 10 // other users' notes PageSizeSiteGallery = 16 PageSizeUserGallery = 16 - PageSizeInboxList = 20 // sidebar list - PageSizeInboxThread = 10 // conversation view + PageSizeInboxList = 20 // sidebar list + PageSizeInboxThread = 10 // conversation view + PageSizeBrowseForums = 20 PageSizeForums = 100 // TODO: for main category index view PageSizeThreadList = 20 // 20 threads per board, 20 posts per thread PageSizeForumAdmin = 20 diff --git a/pkg/controller/forum/browse.go b/pkg/controller/forum/browse.go new file mode 100644 index 0000000..93a3125 --- /dev/null +++ b/pkg/controller/forum/browse.go @@ -0,0 +1,104 @@ +package forum + +import ( + "net/http" + + "code.nonshy.com/nonshy/website/pkg/config" + "code.nonshy.com/nonshy/website/pkg/models" + "code.nonshy.com/nonshy/website/pkg/session" + "code.nonshy.com/nonshy/website/pkg/templates" +) + +// Browse all existing forums. +func Browse() http.HandlerFunc { + // This page shares a template with the board index (Categories) page. + tmpl := templates.Must("forum/index.html") + + // Whitelist for ordering options. + var sortWhitelist = []string{ + "title asc", + "title desc", + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var ( + searchTerm = r.FormValue("q") + search = models.ParseSearchString(searchTerm) + + show = r.FormValue("show") + categories = []string{} + + subscribed = r.FormValue("show") == "followed" + + sort = r.FormValue("sort") + sortOK bool + ) + + // Sort options. + for _, v := range sortWhitelist { + if sort == v { + sortOK = true + break + } + } + if !sortOK { + sort = sortWhitelist[0] + } + + // Set of forum categories to filter for. + switch show { + case "official": + categories = config.ForumCategories + case "community": + categories = []string{""} + } + + // Get the current user. + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Couldn't get current user: %s", err) + templates.Redirect(w, "/") + return + } + + var pager = &models.Pagination{ + Page: 1, + PerPage: config.PageSizeBrowseForums, + Sort: sort, + } + pager.ParsePage(r) + + // Browse all forums (no category filter for official) + forums, err := models.PaginateForums(currentUser, categories, search, subscribed, pager) + if err != nil { + session.FlashError(w, r, "Couldn't paginate forums: %s", err) + templates.Redirect(w, "/") + return + } + + // Bucket the forums into their categories for easy front-end. + categorized := models.CategorizeForums(forums, nil) + + // Map statistics for these forums. + forumMap := models.MapForumStatistics(forums) + followMap := models.MapForumMemberships(currentUser, forums) + + var vars = map[string]interface{}{ + "CurrentForumTab": "browse", + "IsBrowseTab": true, + "Pager": pager, + "Categories": categorized, + "ForumMap": forumMap, + "FollowMap": followMap, + + // Search filters + "SearchTerm": searchTerm, + "Show": show, + "Sort": sort, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/forum/forum.go b/pkg/controller/forum/forum.go index 71bd594..9e34c14 100644 --- a/pkg/controller/forum/forum.go +++ b/pkg/controller/forum/forum.go @@ -72,10 +72,11 @@ func Forum() http.HandlerFunc { threadMap := models.MapThreadStatistics(threads) var vars = map[string]interface{}{ - "Forum": forum, - "Threads": threads, - "ThreadMap": threadMap, - "Pager": pager, + "Forum": forum, + "IsForumSubscribed": models.IsForumSubscribed(currentUser, forum), + "Threads": threads, + "ThreadMap": threadMap, + "Pager": pager, } if err := tmpl.Execute(w, r, vars); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/pkg/controller/forum/forums.go b/pkg/controller/forum/forums.go index cc678ca..9d80ec1 100644 --- a/pkg/controller/forum/forums.go +++ b/pkg/controller/forum/forums.go @@ -40,7 +40,7 @@ func Landing() http.HandlerFunc { } pager.ParsePage(r) - forums, err := models.PaginateForums(currentUser, config.ForumCategories, pager) + forums, err := models.PaginateForums(currentUser, config.ForumCategories, nil, false, pager) if err != nil { session.FlashError(w, r, "Couldn't paginate forums: %s", err) templates.Redirect(w, "/") @@ -50,13 +50,29 @@ func Landing() http.HandlerFunc { // Bucket the forums into their categories for easy front-end. categorized := models.CategorizeForums(forums, config.ForumCategories) + // Inject the "My List" Category if the user subscribes to forums. + myList, err := models.PaginateForums(currentUser, nil, nil, true, pager) + if err != nil { + session.FlashError(w, r, "Couldn't get your followed forums: %s", err) + } else { + forums = append(forums, myList...) + categorized = append([]*models.CategorizedForum{ + { + Category: "My List", + Forums: myList, + }, + }, categorized...) + } + // Map statistics for these forums. forumMap := models.MapForumStatistics(forums) + followMap := models.MapForumMemberships(currentUser, forums) var vars = map[string]interface{}{ "Pager": pager, "Categories": categorized, "ForumMap": forumMap, + "FollowMap": followMap, } if err := tmpl.Execute(w, r, vars); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/pkg/controller/forum/new_post.go b/pkg/controller/forum/new_post.go index dc5417c..6df654f 100644 --- a/pkg/controller/forum/new_post.go +++ b/pkg/controller/forum/new_post.go @@ -87,6 +87,11 @@ func NewPost() http.HandlerFunc { } } + // If the current user can moderate the forum thread, e.g. edit or delete posts. + // Admins can edit always, user owners of forums can only delete. + var canModerate = currentUser.HasAdminScope(config.ScopeForumModerator) || + (forum.OwnerID == currentUser.ID && isDelete) + // Does the comment have an existing Photo ID? if len(photoID) > 0 { if i, err := strconv.Atoi(photoID); err == nil { @@ -116,7 +121,7 @@ func NewPost() http.HandlerFunc { comment = found // Verify that it is indeed OUR comment. - if currentUser.ID != comment.UserID && !currentUser.HasAdminScope(config.ScopeForumModerator) { + if currentUser.ID != comment.UserID && !canModerate { templates.ForbiddenPage(w, r) return } diff --git a/pkg/controller/forum/subscribe.go b/pkg/controller/forum/subscribe.go new file mode 100644 index 0000000..1eb782e --- /dev/null +++ b/pkg/controller/forum/subscribe.go @@ -0,0 +1,67 @@ +package forum + +import ( + "net/http" + + "code.nonshy.com/nonshy/website/pkg/models" + "code.nonshy.com/nonshy/website/pkg/session" + "code.nonshy.com/nonshy/website/pkg/templates" +) + +// Subscribe to a forum, adding it to your bookmark list. +func Subscribe() http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Parse the path parameters + var ( + fragment = r.FormValue("fragment") + forum *models.Forum + intent = r.FormValue("intent") + ) + + // Look up the forum by its fragment. + if found, err := models.ForumByFragment(fragment); err != nil { + templates.NotFoundPage(w, r) + return + } else { + forum = found + } + + // Get the current user. + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Couldn't get current user: %s", err) + templates.Redirect(w, "/") + return + } + + // Is it a private forum? + if forum.Private && !currentUser.IsAdmin { + templates.NotFoundPage(w, r) + return + } + + switch intent { + case "follow": + _, err := models.CreateForumMembership(currentUser, forum) + if err != nil { + session.FlashError(w, r, "Couldn't follow this forum: %s", err) + } else { + session.Flash(w, r, "You have added %s to your forum list.", forum.Title) + } + case "unfollow": + fm, err := models.GetForumMembership(currentUser, forum) + if err == nil { + err = fm.Delete() + if err != nil { + session.FlashError(w, r, "Couldn't delete your forum membership: %s", err) + } + } + + session.Flash(w, r, "You have removed %s from your forum list.", forum.Title) + default: + session.Flash(w, r, "Unknown intent.") + } + + templates.Redirect(w, "/f/"+fragment) + }) +} diff --git a/pkg/controller/forum/thread.go b/pkg/controller/forum/thread.go index 29c1ced..0f0a4ba 100644 --- a/pkg/controller/forum/thread.go +++ b/pkg/controller/forum/thread.go @@ -57,6 +57,13 @@ func Thread() http.HandlerFunc { return } + // Can we moderate this forum? (from a user-owned forum perspective, + // e.g. can we delete threads and posts, not edit them) + var canModerate bool + if currentUser.HasAdminScope(config.ScopeForumModerator) || forum.OwnerID == currentUser.ID { + canModerate = true + } + // Ping the view count on this thread. if err := thread.View(currentUser.ID); err != nil { log.Error("Couldn't ping view count on thread %d: %s", thread.ID, err) @@ -100,6 +107,7 @@ func Thread() http.HandlerFunc { "LikeMap": commentLikeMap, "PhotoMap": photos, "Pager": pager, + "CanModerate": canModerate, "IsSubscribed": isSubscribed, } if err := tmpl.Execute(w, r, vars); err != nil { diff --git a/pkg/models/forum.go b/pkg/models/forum.go index 2f45885..6b04a36 100644 --- a/pkg/models/forum.go +++ b/pkg/models/forum.go @@ -78,7 +78,7 @@ Parameters: - categories: optional, filter within categories - pager */ -func PaginateForums(user *User, categories []string, pager *Pagination) ([]*Forum, error) { +func PaginateForums(user *User, categories []string, search *Search, subscribed bool, pager *Pagination) ([]*Forum, error) { var ( fs = []*Forum{} query = (&Forum{}).Preload() @@ -101,6 +101,33 @@ func PaginateForums(user *User, categories []string, pager *Pagination) ([]*Foru wheres = append(wheres, "private is not true") } + // Followed forums only? (for the My List category on home page) + if subscribed { + wheres = append(wheres, ` + EXISTS ( + SELECT 1 + FROM forum_memberships + WHERE user_id = ? + AND forum_id = forums.id + ) + `) + placeholders = append(placeholders, user.ID) + } + + // Apply their search terms. + if search != nil { + for _, term := range search.Includes { + var ilike = "%" + strings.ToLower(term) + "%" + wheres = append(wheres, "(fragment ILIKE ? OR title ILIKE ? OR description ILIKE ?)") + placeholders = append(placeholders, ilike, ilike, ilike) + } + for _, term := range search.Excludes { + var ilike = "%" + strings.ToLower(term) + "%" + wheres = append(wheres, "(fragment NOT ILIKE ? AND title NOT ILIKE ? AND description NOT ILIKE ?)") + placeholders = append(placeholders, ilike, ilike, ilike) + } + } + // Filters? if len(wheres) > 0 { query = query.Where( @@ -178,6 +205,15 @@ func CategorizeForums(fs []*Forum, categories []string) []*CategorizedForum { idxMap = map[string]int{} ) + // Forum Browse page: we are not grouping by categories but still need at least one. + if len(categories) == 0 { + return []*CategorizedForum{ + { + Forums: fs, + }, + } + } + // Initialize the result set. for i, category := range categories { result = append(result, &CategorizedForum{ diff --git a/pkg/models/forum_membership.go b/pkg/models/forum_membership.go new file mode 100644 index 0000000..af771eb --- /dev/null +++ b/pkg/models/forum_membership.go @@ -0,0 +1,121 @@ +package models + +import ( + "strings" + "time" + + "code.nonshy.com/nonshy/website/pkg/log" + "gorm.io/gorm" +) + +// ForumMembership table. +type ForumMembership struct { + ID uint64 `gorm:"primaryKey"` + UserID uint64 `gorm:"index"` + User User `gorm:"foreignKey:user_id"` + ForumID uint64 `gorm:"index"` + Forum Forum `gorm:"foreignKey:forum_id"` + Approved bool `gorm:"index"` + IsModerator bool `gorm:"index"` + CreatedAt time.Time + UpdatedAt time.Time +} + +// Preload related tables for the forum (classmethod). +func (f *ForumMembership) Preload() *gorm.DB { + return DB.Preload("User").Preload("Forum") +} + +// CreateForumMembership subscribes the user to a forum. +func CreateForumMembership(user *User, forum *Forum) (*ForumMembership, error) { + var ( + f = &ForumMembership{ + User: *user, + Forum: *forum, + Approved: true, + } + result = DB.Create(f) + ) + return f, result.Error +} + +// GetForumMembership looks up a forum membership. +func GetForumMembership(user *User, forum *Forum) (*ForumMembership, error) { + var ( + f = &ForumMembership{} + result = f.Preload().Where( + "user_id = ? AND forum_id = ?", + user.ID, forum.ID, + ).First(&f) + ) + return f, result.Error +} + +// IsForumSubscribed checks if the current user subscribes to this forum. +func IsForumSubscribed(user *User, forum *Forum) bool { + f, _ := GetForumMembership(user, forum) + return f.UserID == user.ID +} + +// Delete a forum membership. +func (f *ForumMembership) Delete() error { + return DB.Delete(f).Error +} + +// PaginateForumMemberships paginates over a user's ForumMemberships. +func PaginateForumMemberships(user *User, pager *Pagination) ([]*ForumMembership, error) { + var ( + fs = []*ForumMembership{} + query = (&ForumMembership{}).Preload() + wheres = []string{} + placeholders = []interface{}{} + ) + + query = query.Where( + strings.Join(wheres, " AND "), + placeholders..., + ).Order(pager.Sort) + + query.Model(&ForumMembership{}).Count(&pager.Total) + result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs) + return fs, result.Error +} + +// ForumMembershipMap maps table IDs to Likes metadata. +type ForumMembershipMap map[uint64]bool + +// Get like stats from the map. +func (fm ForumMembershipMap) Get(id uint64) bool { + return fm[id] +} + +// MapForumMemberships looks up a user's memberships in bulk. +func MapForumMemberships(user *User, forums []*Forum) ForumMembershipMap { + var ( + result = ForumMembershipMap{} + forumIDs = []uint64{} + ) + + // Initialize the result set. + for _, forum := range forums { + result[forum.ID] = false + forumIDs = append(forumIDs, forum.ID) + } + + // Map the forum IDs the user subscribes to. + var followIDs = []uint64{} + if res := DB.Model(&ForumMembership{}).Select( + "forum_id", + ).Where( + "user_id = ? AND forum_id IN ?", + user.ID, forumIDs, + ).Scan(&followIDs); res.Error != nil { + log.Error("MapForumMemberships: %s", res.Error) + } + + for _, forumID := range followIDs { + result[forumID] = true + } + + return result +} diff --git a/pkg/models/models.go b/pkg/models/models.go index ff823ce..38c37de 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -35,4 +35,5 @@ func AutoMigrate() { DB.AutoMigrate(&IPAddress{}) DB.AutoMigrate(&PushNotification{}) DB.AutoMigrate(&WorldCities{}) + DB.AutoMigrate(&ForumMembership{}) } diff --git a/pkg/router/router.go b/pkg/router/router.go index aa9131a..0d2c34d 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -87,8 +87,10 @@ func New() http.Handler { mux.Handle("GET /forum", middleware.CertRequired(forum.Landing())) mux.Handle("/forum/post", middleware.CertRequired(forum.NewPost())) mux.Handle("GET /forum/thread/{id}", middleware.CertRequired(forum.Thread())) + mux.Handle("GET /forum/browse", middleware.CertRequired(forum.Browse())) mux.Handle("GET /forum/newest", middleware.CertRequired(forum.Newest())) mux.Handle("GET /forum/search", middleware.CertRequired(forum.Search())) + mux.Handle("POST /forum/subscribe", middleware.CertRequired(forum.Subscribe())) mux.Handle("GET /f/{fragment}", middleware.CertRequired(forum.Forum())) mux.Handle("POST /poll/vote", middleware.CertRequired(poll.Vote())) diff --git a/web/templates/forum/board_index.html b/web/templates/forum/board_index.html index f37ec3b..59532b5 100644 --- a/web/templates/forum/board_index.html +++ b/web/templates/forum/board_index.html @@ -4,10 +4,39 @@
-

- - {{.Forum.Title}} -

+
+
+

+ + {{.Forum.Title}} +

+
+ + {{if .FeatureUserForumsEnabled}} +
+ +
+ {{InputCSRF}} + + + {{if .IsForumSubscribed}} + + {{else}} + + {{end}} +
+
+ {{end}} +
@@ -29,7 +58,7 @@
{{if or .CurrentUser.IsAdmin (not .Forum.Privileged) (eq .Forum.OwnerID .CurrentUser.ID)}} - + New Thread diff --git a/web/templates/forum/index.html b/web/templates/forum/index.html index aad401d..b0b1e3c 100644 --- a/web/templates/forum/index.html +++ b/web/templates/forum/index.html @@ -32,9 +32,101 @@ {{template "ForumTabs" .}} + +{{if .IsBrowseTab}} +
+
+ +
+ +
+
+ +
+
+ + +

+ Tip: you can "quote exact phrases" and + -exclude words (or + -"exclude phrases") from your search. +

+
+
+ +
+
+ +
+ +
+
+
+ +
+
+ +
+ +
+
+
+ +
+ + Reset + +
+
+
+
+ +
+
+ +

+ Found {{.Pager.Total}} forum{{Pluralize64 .Pager.Total}}. + (page {{.Pager.Page}} of {{.Pager.Pages}}). +

+ +
+ {{SimplePager .Pager}} +
+{{end}} + {{range .Categories}}
-

{{.Category}}

+ {{if .Category}} +

+ {{.Category}} + {{if eq .Category "My List"}} + + {{end}} +

+ {{end}} {{if eq (len .Forums) 0}} @@ -50,6 +142,9 @@

+ {{if $Root.FollowMap.Get .ID}} + + {{end}} {{.Title}}

@@ -156,4 +251,11 @@
{{end}} + +{{if .IsBrowseTab}} +
+ {{SimplePager .Pager}} +
+{{end}} + {{end}} \ No newline at end of file diff --git a/web/templates/forum/thread.html b/web/templates/forum/thread.html index 7dd0f39..1f19e7c 100644 --- a/web/templates/forum/thread.html +++ b/web/templates/forum/thread.html @@ -289,6 +289,9 @@ Edit
+ {{end}} + + {{if or $Root.CanModerate ($Root.CurrentUser.HasAdminScope "social.moderator.forum") (eq $Root.CurrentUser.ID .User.ID)}}