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).
This commit is contained in:
Noah Petherbridge 2024-08-20 21:26:53 -07:00
parent 1bf846e78b
commit 9570129bba
15 changed files with 516 additions and 15 deletions

View File

@ -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

View File

@ -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
}
})
}

View File

@ -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)

View File

@ -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)

View File

@ -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
}

View File

@ -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)
})
}

View File

@ -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 {

View File

@ -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{

View File

@ -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
}

View File

@ -35,4 +35,5 @@ func AutoMigrate() {
DB.AutoMigrate(&IPAddress{})
DB.AutoMigrate(&PushNotification{})
DB.AutoMigrate(&WorldCities{})
DB.AutoMigrate(&ForumMembership{})
}

View File

@ -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()))

View File

@ -4,10 +4,39 @@
<section class="hero is-light is-success">
<div class="hero-body">
<div class="container">
<h1 class="title">
<span class="icon mr-4"><i class="fa fa-comments"></i></span>
<span>{{.Forum.Title}}</span>
</h1>
<div class="level">
<div class="level-left mb-4">
<h1 class="title">
<span class="icon mr-4"><i class="fa fa-comments"></i></span>
<span>{{.Forum.Title}}</span>
</h1>
</div>
{{if .FeatureUserForumsEnabled}}
<div class="level-right">
<!-- Follow/Unfollow This Forum -->
<form action="/forum/subscribe" method="POST" class="is-inline">
{{InputCSRF}}
<input type="hidden" name="fragment" value="{{.Forum.Fragment}}">
{{if .IsForumSubscribed}}
<button type="submit" class="button"
name="intent" value="unfollow"
onclick="return confirm('Do you want to remove this forum from your list?')">
<span class="icon"><i class="fa fa-bookmark"></i></span>
<span>Followed</span>
</button>
{{else}}
<button type="submit" class="button"
name="intent" value="follow">
<span class="icon"><i class="fa-regular fa-bookmark"></i></span>
<span>Follow</span>
</button>
{{end}}
</form>
</div>
{{end}}
</div>
</div>
</div>
</section>
@ -29,7 +58,7 @@
</div>
<div class="column is-narrow">
{{if or .CurrentUser.IsAdmin (not .Forum.Privileged) (eq .Forum.OwnerID .CurrentUser.ID)}}
<a href="/forum/post?to={{.Forum.Fragment}}" class="button is-primary">
<a href="/forum/post?to={{.Forum.Fragment}}" class="button is-info">
<span class="icon"><i class="fa fa-plus"></i></span>
<span>New Thread</span>
</a>

View File

@ -32,9 +32,101 @@
<!-- Tab bar -->
{{template "ForumTabs" .}}
<!-- Filters for the Browse tab -->
{{if .IsBrowseTab}}
<div class="block mb-0 p-4">
<form action="{{.Request.URL.Path}}" method="GET">
<div class="card nonshy-collapsible-mobile">
<header class="card-header has-background-link-light">
<p class="card-header-title has-text-dark">
Search Filters
</p>
<button class="card-header-icon" type="button">
<span class="icon">
<i class="fa fa-angle-up"></i>
</span>
</button>
</header>
<div class="card-content">
<div class="columns">
<div class="column is-half pr-1">
<div class="field">
<label class="label" for="q">Search terms:</label>
<input type="text" class="input"
name="q" id="q"
autocomplete="off"
value="{{.SearchTerm}}">
<p class="help">
Tip: you can <span class="has-text-success">"quote exact phrases"</span> and
<span class="has-text-success">-exclude</span> words (or
<span class="has-text-success">-"exclude phrases"</span>) from your search.
</p>
</div>
</div>
<div class="column px-1">
<div class="field">
<label class="label" for="show">Show:</label>
<div class="select is-fullwidth">
<select id="show" name="show">
<option value=""{{if eq .Show ""}} selected{{end}}>All forums</option>
<option value="official"{{if eq .Show "official"}} selected{{end}}>Official nonshy forums</option>
<option value="community"{{if eq .Show "community"}} selected{{end}}>Community forums only</option>
<option value="followed"{{if eq .Show "followed"}} selected{{end}}>My List</option>
</select>
</div>
</div>
</div>
<div class="column px-1">
<div class="field">
<label class="label" for="sort">Sort by:</label>
<div class="select is-fullwidth">
<select id="sort" name="sort">
<option value="title asc"{{if eq .Sort "title asc"}} selected{{end}}>Title (A-Z)</option>
<option value="title desc"{{if eq .Sort "title desc"}} selected{{end}}>Title (Z-A)</option>
</select>
</div>
</div>
</div>
<div class="column is-narrow pl-1 has-text-right">
<label class="label">&nbsp;</label>
<a href="{{.Request.URL.Path}}" class="button">Reset</a>
<button type="submit" class="button is-success">
<span>Search</span>
<span class="icon"><i class="fa fa-search"></i></span>
</button>
</div>
</div>
</div>
</div>
</form>
</div>
<p class="block p-4">
Found <strong>{{.Pager.Total}}</strong> forum{{Pluralize64 .Pager.Total}}.
(page {{.Pager.Page}} of {{.Pager.Pages}}).
</p>
<div class="block p-4">
{{SimplePager .Pager}}
</div>
{{end}}
{{range .Categories}}
<div class="block p-4">
<h1 class="title">{{.Category}}</h1>
{{if .Category}}
<h1 class="title">
{{.Category}}
{{if eq .Category "My List"}}
<i class="fa fa-book-bookmark ml-2"></i>
{{end}}
</h1>
{{end}}
{{if eq (len .Forums) 0}}
<em>
@ -50,6 +142,9 @@
<div class="column is-3 pt-0 pb-1">
<h2 class="is-size-4">
{{if $Root.FollowMap.Get .ID}}
<sup class="fa fa-bookmark has-text-success is-size-6 mr-1" title="Followed"></sup>
{{end}}
<strong><a href="/f/{{.Fragment}}">{{.Title}}</a></strong>
</h2>
@ -156,4 +251,11 @@
</div>
{{end}}<!-- range .Categories -->
<!-- Pager footer for Browse tab -->
{{if .IsBrowseTab}}
<div class="block p-4">
{{SimplePager .Pager}}
</div>
{{end}}
{{end}}

View File

@ -289,6 +289,9 @@
<span>Edit</span>
</a>
</div>
{{end}}
{{if or $Root.CanModerate ($Root.CurrentUser.HasAdminScope "social.moderator.forum") (eq $Root.CurrentUser.ID .User.ID)}}
<div class="column is-narrow">
<a href="/forum/post?to={{$Root.Forum.Fragment}}&thread={{$Root.Thread.ID}}&edit={{.ID}}&delete=true" onclick="return confirm('Are you sure you want to delete this comment?')" class="has-text-dark">
<span class="icon"><i class="fa fa-trash"></i></span>

View File

@ -16,6 +16,11 @@ Variables that your template should set:
Categories
</a>
</li>
<li {{if eq .CurrentForumTab "browse" }}class="is-active"{{end}}>
<a href="/forum/browse">
Browse
</a>
</li>
<li {{if eq .CurrentForumTab "newest" }}class="is-active"{{end}}>
<a href="/forum/newest">
Newest