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}}
+
+
+ Found {{.Pager.Total}} forum{{Pluralize64 .Pager.Total}}. + (page {{.Pager.Page}} of {{.Pager.Pages}}). +
+ +