User Forums: Newest Tab, Moderators

* The "Newest" tab of the forum is updated with new filter options.
  * Which forums: All, Official, Community, My List
  * Show: By threads, All posts
  * The option for "Which forums" is saved in the user's preferences and set as
    their default on future visits, similar to the Site Gallery "Whose photos"
    option.
  * So users can subscribe to their favorite forums and always get their latest
    posts easily while filtering out the rest.
* Forum Moderators
  * Add the ability to add and remove moderators for your forum.
  * Users are notified when they are added as a moderator.
  * Moderators can opt themselves out by unfollowing the forum.
* ForumMembership: add unique constraint on user_id,forum_id.
This commit is contained in:
Noah Petherbridge 2024-08-21 21:53:35 -07:00
parent 9570129bba
commit 28d1e284ab
22 changed files with 816 additions and 63 deletions

View File

@ -45,7 +45,7 @@ func AddEdit() http.HandlerFunc {
return return
} else { } else {
// Do we have permission? // Do we have permission?
if found.OwnerID != currentUser.ID && !currentUser.IsAdmin { if !found.CanEdit(currentUser) {
templates.ForbiddenPage(w, r) templates.ForbiddenPage(w, r)
return return
} }
@ -67,8 +67,8 @@ func AddEdit() http.HandlerFunc {
isPrivate = r.PostFormValue("private") == "true" isPrivate = r.PostFormValue("private") == "true"
) )
// Sanity check admin-only settings. // Sanity check admin-only settings -> default these to OFF.
if !currentUser.IsAdmin { if !currentUser.HasAdminScope(config.ScopeForumAdmin) {
isPrivileged = false isPrivileged = false
isPermitPhotos = false isPermitPhotos = false
isPrivate = false isPrivate = false
@ -81,18 +81,25 @@ func AddEdit() http.HandlerFunc {
models.NewFieldDiff("Description", forum.Description, description), models.NewFieldDiff("Description", forum.Description, description),
models.NewFieldDiff("Category", forum.Category, category), models.NewFieldDiff("Category", forum.Category, category),
models.NewFieldDiff("Explicit", forum.Explicit, isExplicit), models.NewFieldDiff("Explicit", forum.Explicit, isExplicit),
models.NewFieldDiff("Privileged", forum.Privileged, isPrivileged),
models.NewFieldDiff("PermitPhotos", forum.PermitPhotos, isPermitPhotos),
models.NewFieldDiff("Private", forum.Private, isPrivate),
} }
forum.Title = title forum.Title = title
forum.Description = description forum.Description = description
forum.Category = category forum.Category = category
forum.Explicit = isExplicit forum.Explicit = isExplicit
// Forum Admin-only options: if the current viewer is not a forum admin, do not change these settings.
// e.g.: the front-end checkboxes are hidden and don't want to accidentally unset these!
if currentUser.HasAdminScope(config.ScopeForumAdmin) {
diffs = append(diffs,
models.NewFieldDiff("Privileged", forum.Privileged, isPrivileged),
models.NewFieldDiff("PermitPhotos", forum.PermitPhotos, isPermitPhotos),
models.NewFieldDiff("Private", forum.Private, isPrivate),
)
forum.Privileged = isPrivileged forum.Privileged = isPrivileged
forum.PermitPhotos = isPermitPhotos forum.PermitPhotos = isPermitPhotos
forum.Private = isPrivate forum.Private = isPrivate
}
// Save it. // Save it.
if err := forum.Save(); err == nil { if err := forum.Save(); err == nil {
@ -164,12 +171,20 @@ func AddEdit() http.HandlerFunc {
} }
} }
_ = editID // Get the list of moderators.
var mods []*models.User
if forum != nil {
mods, err = forum.GetModerators()
if err != nil {
session.FlashError(w, r, "Error getting moderators list: %s", err)
}
}
var vars = map[string]interface{}{ var vars = map[string]interface{}{
"EditID": editID, "EditID": editID,
"EditForum": forum, "EditForum": forum,
"Categories": config.ForumCategories, "Categories": config.ForumCategories,
"Moderators": mods,
} }
if err := tmpl.Execute(w, r, vars); err != nil { if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -9,8 +9,8 @@ import (
"code.nonshy.com/nonshy/website/pkg/templates" "code.nonshy.com/nonshy/website/pkg/templates"
) )
// Browse all existing forums. // Explore all existing forums.
func Browse() http.HandlerFunc { func Explore() http.HandlerFunc {
// This page shares a template with the board index (Categories) page. // This page shares a template with the board index (Categories) page.
tmpl := templates.Must("forum/index.html") tmpl := templates.Must("forum/index.html")
@ -84,8 +84,8 @@ func Browse() http.HandlerFunc {
followMap := models.MapForumMemberships(currentUser, forums) followMap := models.MapForumMemberships(currentUser, forums)
var vars = map[string]interface{}{ var vars = map[string]interface{}{
"CurrentForumTab": "browse", "CurrentForumTab": "explore",
"IsBrowseTab": true, "IsExploreTab": true,
"Pager": pager, "Pager": pager,
"Categories": categorized, "Categories": categorized,
"ForumMap": forumMap, "ForumMap": forumMap,

View File

@ -4,6 +4,7 @@ import (
"net/http" "net/http"
"code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates" "code.nonshy.com/nonshy/website/pkg/templates"
@ -71,8 +72,15 @@ func Forum() http.HandlerFunc {
// Map the statistics (replies, views) of these threads. // Map the statistics (replies, views) of these threads.
threadMap := models.MapThreadStatistics(threads) threadMap := models.MapThreadStatistics(threads)
// Load the forum's moderators.
mods, err := forum.GetModerators()
if err != nil {
log.Error("Getting forum moderators: %s", err)
}
var vars = map[string]interface{}{ var vars = map[string]interface{}{
"Forum": forum, "Forum": forum,
"ForumModerators": mods,
"IsForumSubscribed": models.IsForumSubscribed(currentUser, forum), "IsForumSubscribed": models.IsForumSubscribed(currentUser, forum),
"Threads": threads, "Threads": threads,
"ThreadMap": threadMap, "ThreadMap": threadMap,

View File

@ -0,0 +1,140 @@
package forum
import (
"fmt"
"net/http"
"strconv"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
)
// ManageModerators controller (/forum/admin/moderators) to appoint moderators to your (user) forum.
func ManageModerators() http.HandlerFunc {
// Reuse the upload page but with an EditPhoto variable.
tmpl := templates.Must("forum/moderators.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var (
intent = r.FormValue("intent")
stringID = r.FormValue("forum_id")
)
// Parse forum_id query parameter.
var forumID uint64
if stringID != "" {
if i, err := strconv.Atoi(stringID); err == nil {
forumID = uint64(i)
} else {
session.FlashError(w, r, "Edit parameter: forum_id was not an integer")
templates.Redirect(w, "/forum/admin")
return
}
}
// Redirect URLs
var (
next = fmt.Sprintf("%s?forum_id=%d", r.URL.Path, forumID)
nextFinished = fmt.Sprintf("/forum/admin/edit?id=%d", forumID)
)
// Load the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Unexpected error: could not get currentUser.")
templates.Redirect(w, "/")
return
}
// Are we adding/removing a user as moderator?
var (
username = r.FormValue("to")
user *models.User
)
if username != "" {
if found, err := models.FindUser(username); err != nil {
templates.NotFoundPage(w, r)
return
} else {
user = found
}
}
// Look up the forum by its fragment.
forum, err := models.GetForum(forumID)
if err != nil {
templates.NotFoundPage(w, r)
return
}
// User must be the owner of this forum, or a privileged admin.
if !forum.CanEdit(currentUser) {
templates.ForbiddenPage(w, r)
return
}
// The forum owner can not add themself.
if user != nil && forum.OwnerID == user.ID {
session.FlashError(w, r, "You can not add the forum owner to its moderators list.")
templates.Redirect(w, next)
return
}
// POSTing?
if r.Method == http.MethodPost {
switch intent {
case "submit":
// Confirmed adding a moderator.
if _, err := forum.AddModerator(user); err != nil {
session.FlashError(w, r, "Error adding the moderator: %s", err)
templates.Redirect(w, next)
return
}
// Create a notification for this.
notif := &models.Notification{
UserID: user.ID,
AboutUser: *currentUser,
Type: models.NotificationForumModerator,
TableName: "forums",
TableID: forum.ID,
Link: fmt.Sprintf("/f/%s", forum.Fragment),
}
if err := models.CreateNotification(notif); err != nil {
log.Error("Couldn't create PrivatePhoto notification: %s", err)
}
session.Flash(w, r, "%s has been added to the moderators list!", user.Username)
templates.Redirect(w, nextFinished)
return
case "confirm-remove":
// Confirm removing a moderator.
if _, err := forum.RemoveModerator(user); err != nil {
session.FlashError(w, r, "Error removing the moderator: %s", err)
templates.Redirect(w, next)
return
}
// Revoke any past notifications they had about being added as moderator.
if err := models.RemoveSpecificNotification(user.ID, models.NotificationForumModerator, "forums", forum.ID); err != nil {
log.Error("Couldn't revoke the forum moderator notification: %s", err)
}
session.Flash(w, r, "%s has been removed from the moderators list.", user.Username)
templates.Redirect(w, nextFinished)
return
}
}
var vars = map[string]interface{}{
"Forum": forum,
"User": user,
"IsRemoving": intent == "remove",
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}

View File

@ -17,6 +17,9 @@ func Newest() http.HandlerFunc {
// Query parameters. // Query parameters.
var ( var (
allComments = r.FormValue("all") == "true" allComments = r.FormValue("all") == "true"
whichForums = r.FormValue("which")
categories = []string{}
subscribed bool
) )
// Get the current user. // Get the current user.
@ -27,6 +30,29 @@ func Newest() http.HandlerFunc {
return return
} }
// Recall the user's default "Which forum:" answer if not selected.
if whichForums == "" {
whichForums = currentUser.GetProfileField("forum_newest_default")
if whichForums == "" {
whichForums = "official"
}
}
// Narrow down to which set of forums?
switch whichForums {
case "official":
categories = config.ForumCategories
case "community":
categories = []string{""}
case "followed":
subscribed = true
default:
whichForums = "all"
}
// Store their "Which forums" filter to be their new default view.
currentUser.SetProfileField("forum_newest_default", whichForums)
// Get all the categorized index forums. // Get all the categorized index forums.
var pager = &models.Pagination{ var pager = &models.Pagination{
Page: 1, Page: 1,
@ -34,7 +60,7 @@ func Newest() http.HandlerFunc {
} }
pager.ParsePage(r) pager.ParsePage(r)
posts, err := models.PaginateRecentPosts(currentUser, config.ForumCategories, allComments, pager) posts, err := models.PaginateRecentPosts(currentUser, categories, subscribed, allComments, pager)
if err != nil { if err != nil {
session.FlashError(w, r, "Couldn't paginate forums: %s", err) session.FlashError(w, r, "Couldn't paginate forums: %s", err)
templates.Redirect(w, "/") templates.Redirect(w, "/")
@ -56,6 +82,9 @@ func Newest() http.HandlerFunc {
"Pager": pager, "Pager": pager,
"RecentPosts": posts, "RecentPosts": posts,
"PhotoMap": photos, "PhotoMap": photos,
// Filter options.
"WhichForums": whichForums,
"AllComments": allComments, "AllComments": allComments,
} }
if err := tmpl.Execute(w, r, vars); err != nil { if err := tmpl.Execute(w, r, vars); err != nil {

View File

@ -3,6 +3,7 @@ package forum
import ( import (
"net/http" "net/http"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates" "code.nonshy.com/nonshy/website/pkg/templates"
@ -51,6 +52,13 @@ func Subscribe() http.HandlerFunc {
case "unfollow": case "unfollow":
fm, err := models.GetForumMembership(currentUser, forum) fm, err := models.GetForumMembership(currentUser, forum)
if err == nil { if err == nil {
// Were we a moderator previously? If so, revoke the notification about it.
if fm.IsModerator {
if err := models.RemoveSpecificNotification(currentUser.ID, models.NotificationForumModerator, "forums", forum.ID); err != nil {
log.Error("User unsubscribed from forum and couldn't remove their moderator notification: %s", err)
}
}
err = fm.Delete() err = fm.Delete()
if err != nil { if err != nil {
session.FlashError(w, r, "Couldn't delete your forum membership: %s", err) session.FlashError(w, r, "Couldn't delete your forum membership: %s", err)

View File

@ -109,6 +109,7 @@ func Thread() http.HandlerFunc {
"Pager": pager, "Pager": pager,
"CanModerate": canModerate, "CanModerate": canModerate,
"IsSubscribed": isSubscribed, "IsSubscribed": isSubscribed,
"IsForumSubscribed": models.IsForumSubscribed(currentUser, forum),
} }
if err := tmpl.Execute(w, r, vars); err != nil { if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -5,6 +5,7 @@ import (
"strings" "strings"
"time" "time"
"code.nonshy.com/nonshy/website/pkg/config"
"gorm.io/gorm" "gorm.io/gorm"
) )
@ -27,7 +28,7 @@ type Forum struct {
// Preload related tables for the forum (classmethod). // Preload related tables for the forum (classmethod).
func (f *Forum) Preload() *gorm.DB { func (f *Forum) Preload() *gorm.DB {
return DB.Preload("Owner") return DB.Preload("Owner").Preload("Owner.ProfilePhoto")
} }
// GetForum by ID. // GetForum by ID.
@ -69,6 +70,13 @@ func ForumByFragment(fragment string) (*Forum, error) {
return f, result.Error return f, result.Error
} }
// CanEdit checks if the user has edit rights over this forum.
//
// That is, they are its Owner or they are an admin with Manage Forums permission.
func (f *Forum) CanEdit(user *User) bool {
return user.HasAdminScope(config.ScopeForumAdmin) || f.OwnerID == user.ID
}
/* /*
PaginateForums scans over the available forums for a user. PaginateForums scans over the available forums for a user.

View File

@ -1,6 +1,7 @@
package models package models
import ( import (
"errors"
"strings" "strings"
"time" "time"
@ -9,11 +10,13 @@ import (
) )
// ForumMembership table. // ForumMembership table.
//
// Unique key constraint pairs user_id and forum_id.
type ForumMembership struct { type ForumMembership struct {
ID uint64 `gorm:"primaryKey"` ID uint64 `gorm:"primaryKey"`
UserID uint64 `gorm:"index"` UserID uint64 `gorm:"uniqueIndex:idx_forum_membership"`
User User `gorm:"foreignKey:user_id"` User User `gorm:"foreignKey:user_id"`
ForumID uint64 `gorm:"index"` ForumID uint64 `gorm:"uniqueIndex:idx_forum_membership"`
Forum Forum `gorm:"foreignKey:forum_id"` Forum Forum `gorm:"foreignKey:forum_id"`
Approved bool `gorm:"index"` Approved bool `gorm:"index"`
IsModerator bool `gorm:"index"` IsModerator bool `gorm:"index"`
@ -39,7 +42,7 @@ func CreateForumMembership(user *User, forum *Forum) (*ForumMembership, error) {
return f, result.Error return f, result.Error
} }
// GetForumMembership looks up a forum membership. // GetForumMembership looks up a forum membership, returning an error if one is not found.
func GetForumMembership(user *User, forum *Forum) (*ForumMembership, error) { func GetForumMembership(user *User, forum *Forum) (*ForumMembership, error) {
var ( var (
f = &ForumMembership{} f = &ForumMembership{}
@ -51,12 +54,88 @@ func GetForumMembership(user *User, forum *Forum) (*ForumMembership, error) {
return f, result.Error return f, result.Error
} }
// AddModerator appoints a moderator to the forum, returning that user's ForumMembership.
//
// If the target is not following the forum, a ForumMembership is created, marked as a moderator and returned.
func (f *Forum) AddModerator(user *User) (*ForumMembership, error) {
var fm *ForumMembership
if found, err := GetForumMembership(user, f); err != nil {
fm = &ForumMembership{
User: *user,
Forum: *f,
Approved: true,
}
} else {
fm = found
}
// They are already a moderator?
if fm.IsModerator {
return fm, errors.New("they are already a moderator of this forum")
}
fm.IsModerator = true
err := fm.Save()
return fm, err
}
// RemoveModerator will unset a user's moderator flag on this forum.
func (f *Forum) RemoveModerator(user *User) (*ForumMembership, error) {
fm, err := GetForumMembership(user, f)
if err != nil {
return nil, err
}
fm.IsModerator = false
err = fm.Save()
return fm, err
}
// GetModerators loads all of the moderators of a forum, ordered alphabetically by username.
func (f *Forum) GetModerators() ([]*User, error) {
// Find all forum memberships that moderate us.
var (
fm = []*ForumMembership{}
result = (&ForumMembership{}).Preload().Where(
"forum_id = ? AND is_moderator IS TRUE",
f.ID,
).Find(&fm)
)
if result.Error != nil {
log.Error("Forum(%d).GetModerators(): %s", f.ID, result.Error)
return nil, nil
}
// Load these users.
var userIDs = []uint64{}
for _, row := range fm {
userIDs = append(userIDs, row.UserID)
}
return GetUsersAlphabetically(userIDs)
}
// IsForumSubscribed checks if the current user subscribes to this forum. // IsForumSubscribed checks if the current user subscribes to this forum.
func IsForumSubscribed(user *User, forum *Forum) bool { func IsForumSubscribed(user *User, forum *Forum) bool {
f, _ := GetForumMembership(user, forum) f, _ := GetForumMembership(user, forum)
return f.UserID == user.ID return f.UserID == user.ID
} }
// HasForumSubscriptions returns if the current user has at least one forum membership.
func (u *User) HasForumSubscriptions() bool {
var count int64
DB.Model(&ForumMembership{}).Where(
"user_id = ?",
u.ID,
).Count(&count)
return count > 0
}
// Save a forum membership.
func (f *ForumMembership) Save() error {
return DB.Save(f).Error
}
// Delete a forum membership. // Delete a forum membership.
func (f *ForumMembership) Delete() error { func (f *ForumMembership) Delete() error {
return DB.Delete(f).Error return DB.Delete(f).Error

View File

@ -22,7 +22,7 @@ type RecentPost struct {
} }
// PaginateRecentPosts returns all of the comments on a forum paginated. // PaginateRecentPosts returns all of the comments on a forum paginated.
func PaginateRecentPosts(user *User, categories []string, allComments bool, pager *Pagination) ([]*RecentPost, error) { func PaginateRecentPosts(user *User, categories []string, subscribed, allComments bool, pager *Pagination) ([]*RecentPost, error) {
var ( var (
result = []*RecentPost{} result = []*RecentPost{}
blockedUserIDs = BlockedUserIDs(user) blockedUserIDs = BlockedUserIDs(user)
@ -52,6 +52,19 @@ func PaginateRecentPosts(user *User, categories []string, allComments bool, page
wheres = append(wheres, "forums.private is not true") wheres = append(wheres, "forums.private is not true")
} }
// Forums I follow?
if subscribed {
wheres = append(wheres, `
EXISTS (
SELECT 1
FROM forum_memberships
WHERE user_id = ?
AND forum_id = forums.id
)
`)
placeholders = append(placeholders, user.ID)
}
// Blocked users? // Blocked users?
if len(blockedUserIDs) > 0 { if len(blockedUserIDs) > 0 {
comment_wheres = append(comment_wheres, "comments.user_id NOT IN ?") comment_wheres = append(comment_wheres, "comments.user_id NOT IN ?")

View File

@ -45,6 +45,7 @@ const (
NotificationCertApproved NotificationType = "cert_approved" NotificationCertApproved NotificationType = "cert_approved"
NotificationPrivatePhoto NotificationType = "private_photo" // private photo grants NotificationPrivatePhoto NotificationType = "private_photo" // private photo grants
NotificationNewPhoto NotificationType = "new_photo" NotificationNewPhoto NotificationType = "new_photo"
NotificationForumModerator NotificationType = "forum_moderator" // appointed as a forum moderator
NotificationCustom NotificationType = "custom" // custom message pushed NotificationCustom NotificationType = "custom" // custom message pushed
) )
@ -372,9 +373,11 @@ func (n *Notification) Delete() error {
type NotificationBody struct { type NotificationBody struct {
PhotoID uint64 PhotoID uint64
ThreadID uint64 ThreadID uint64
ForumID uint64
CommentID uint64 CommentID uint64
Photo *Photo Photo *Photo
Thread *Thread Thread *Thread
Forum *Forum
Comment *Comment Comment *Comment
} }
@ -403,6 +406,7 @@ func MapNotifications(ns []*Notification) NotificationMap {
result.mapNotificationPhotos(IDs) result.mapNotificationPhotos(IDs)
result.mapNotificationThreads(IDs) result.mapNotificationThreads(IDs)
result.mapNotificationForums(IDs)
// NOTE: comment loading is not used - was added when trying to add "Like" buttons inside // NOTE: comment loading is not used - was added when trying to add "Like" buttons inside
// your Comment notifications. But when a photo is commented on, the notification table_name=photos, // your Comment notifications. But when a photo is commented on, the notification table_name=photos,
@ -507,6 +511,53 @@ func (nm NotificationMap) mapNotificationThreads(IDs []uint64) {
} }
} }
// Helper function of MapNotifications to eager load Forum attachments.
func (nm NotificationMap) mapNotificationForums(IDs []uint64) {
type scanner struct {
ForumID uint64
NotificationID uint64
}
var scan []scanner
// Load all of these that have forums.
err := DB.Table(
"notifications",
).Joins(
"JOIN forums ON (notifications.table_name='forums' AND notifications.table_id=forums.id)",
).Select(
"forums.id AS forum_id",
"notifications.id AS notification_id",
).Where(
"notifications.id IN ?",
IDs,
).Scan(&scan)
if err.Error != nil {
log.Error("Couldn't select forum IDs for notifications: %s", err.Error)
}
// Collect and load all the forums by ID.
var forumIDs = []uint64{}
for _, row := range scan {
// Store the forum ID in the result now.
nm[row.NotificationID].ForumID = row.ForumID
forumIDs = append(forumIDs, row.ForumID)
}
// Load the forums.
if len(forumIDs) > 0 {
if forums, err := GetForums(forumIDs); err != nil {
log.Error("Couldn't load forum IDs for notifications: %s", err)
} else {
// Marry them to their notification IDs.
for _, body := range nm {
if forum, ok := forums[body.ForumID]; ok {
body.Forum = forum
}
}
}
}
}
// Helper function of MapNotifications to eager load Comment attachments. // Helper function of MapNotifications to eager load Comment attachments.
func (nm NotificationMap) mapNotificationComments(IDs []uint64) { func (nm NotificationMap) mapNotificationComments(IDs []uint64) {
type scanner struct { type scanner struct {

View File

@ -125,6 +125,25 @@ func GetUsers(currentUser *User, userIDs []uint64) ([]*User, error) {
return users, nil return users, nil
} }
// GetUsersAlphabetically queries for multiple user IDs and returns them sorted by username.
//
// Note: it doesn't respect blocked lists or a viewer context. Used for things like the forum moderators lists.
func GetUsersAlphabetically(userIDs []uint64) ([]*User, error) {
var (
users = []*User{}
result = (&User{}).Preload().Where(
"id IN ?", userIDs,
).Order("username asc").Find(&users)
)
// Inject user relationships.
for _, user := range users {
SetUserRelationships(user, users)
}
return users, result.Error
}
// GetUsersByUsernames queries for multiple usernames and returns users in the same order. // GetUsersByUsernames queries for multiple usernames and returns users in the same order.
func GetUsersByUsernames(currentUser *User, usernames []string) ([]*User, error) { func GetUsersByUsernames(currentUser *User, usernames []string) ([]*User, error) {
// Map the usernames. // Map the usernames.

View File

@ -87,12 +87,15 @@ func New() http.Handler {
mux.Handle("GET /forum", middleware.CertRequired(forum.Landing())) mux.Handle("GET /forum", middleware.CertRequired(forum.Landing()))
mux.Handle("/forum/post", middleware.CertRequired(forum.NewPost())) mux.Handle("/forum/post", middleware.CertRequired(forum.NewPost()))
mux.Handle("GET /forum/thread/{id}", middleware.CertRequired(forum.Thread())) mux.Handle("GET /forum/thread/{id}", middleware.CertRequired(forum.Thread()))
mux.Handle("GET /forum/browse", middleware.CertRequired(forum.Browse())) mux.Handle("GET /forum/explore", middleware.CertRequired(forum.Explore()))
mux.Handle("GET /forum/newest", middleware.CertRequired(forum.Newest())) mux.Handle("GET /forum/newest", middleware.CertRequired(forum.Newest()))
mux.Handle("GET /forum/search", middleware.CertRequired(forum.Search())) mux.Handle("GET /forum/search", middleware.CertRequired(forum.Search()))
mux.Handle("POST /forum/subscribe", middleware.CertRequired(forum.Subscribe())) mux.Handle("POST /forum/subscribe", middleware.CertRequired(forum.Subscribe()))
mux.Handle("GET /f/{fragment}", middleware.CertRequired(forum.Forum())) mux.Handle("GET /f/{fragment}", middleware.CertRequired(forum.Forum()))
mux.Handle("POST /poll/vote", middleware.CertRequired(poll.Vote())) mux.Handle("POST /poll/vote", middleware.CertRequired(poll.Vote()))
mux.Handle("/forum/admin", middleware.CertRequired(forum.Manage()))
mux.Handle("/forum/admin/edit", middleware.CertRequired(forum.AddEdit()))
mux.Handle("/forum/admin/moderator", middleware.CertRequired(forum.ManageModerators()))
// Admin endpoints. // Admin endpoints.
mux.Handle("GET /admin", middleware.AdminRequired("", admin.Dashboard())) mux.Handle("GET /admin", middleware.AdminRequired("", admin.Dashboard()))
@ -102,8 +105,6 @@ func New() http.Handler {
mux.Handle("/admin/user-action", middleware.AdminRequired("", admin.UserActions())) mux.Handle("/admin/user-action", middleware.AdminRequired("", admin.UserActions()))
mux.Handle("/admin/maintenance", middleware.AdminRequired(config.ScopeMaintenance, admin.Maintenance())) mux.Handle("/admin/maintenance", middleware.AdminRequired(config.ScopeMaintenance, admin.Maintenance()))
mux.Handle("/admin/add-user", middleware.AdminRequired(config.ScopeUserCreate, admin.AddUser())) mux.Handle("/admin/add-user", middleware.AdminRequired(config.ScopeUserCreate, admin.AddUser()))
mux.Handle("/forum/admin", middleware.CertRequired(forum.Manage()))
mux.Handle("/forum/admin/edit", middleware.CertRequired(forum.AddEdit()))
mux.Handle("/admin/photo/mark-explicit", middleware.AdminRequired("", admin.MarkPhotoExplicit())) mux.Handle("/admin/photo/mark-explicit", middleware.AdminRequired("", admin.MarkPhotoExplicit()))
mux.Handle("GET /admin/changelog", middleware.AdminRequired(config.ScopeChangeLog, admin.ChangeLog())) mux.Handle("GET /admin/changelog", middleware.AdminRequired(config.ScopeChangeLog, admin.ChangeLog()))

View File

@ -492,7 +492,7 @@
</a> </a>
</div> </div>
<div class="column"> <div class="column">
<div class="mb-1"> <div class="mb-1 pr-4">
{{if eq .Type "like"}} {{if eq .Type "like"}}
<span class="icon"><i class="fa fa-heart has-text-danger"></i></span> <span class="icon"><i class="fa fa-heart has-text-danger"></i></span>
<span> <span>
@ -594,6 +594,12 @@
<span> <span>
Your <strong>certification photo</strong> was rejected! Your <strong>certification photo</strong> was rejected!
</span> </span>
{{else if eq .Type "forum_moderator"}}
<span class="icon"><i class="fa fa-peace has-text-success"></i></span>
<span>
You have been appointed as a <strong class="has-text-success">moderator</strong>
for the forum <a href="/f/{{$Body.Forum.Fragment}}">{{$Body.Forum.Title}}</a>!
</span>
{{else}} {{else}}
{{.AboutUser.Username}} {{.Type}} {{.TableName}} {{.TableID}} {{.AboutUser.Username}} {{.Type}} {{.TableName}} {{.TableID}}
{{end}} {{end}}

View File

@ -1,7 +1,7 @@
{{define "title"}}Forums{{end}} {{define "title"}}Forums{{end}}
{{define "content"}} {{define "content"}}
<div class="block"> <div class="block">
<section class="hero is-light is-danger"> <section class="hero is-bold is-primary">
<div class="hero-body"> <div class="hero-body">
<div class="container"> <div class="container">
<h1 class="title"> <h1 class="title">
@ -14,12 +14,30 @@
{{$Root := .}} {{$Root := .}}
<div class="block p-4">
<div class="columns is-multiline is-mobile">
<div class="column is-narrow">
<a href="/forum/admin">
<i class="fa fa-book"></i> My Forums
</a>
</div>
{{if .EditForum}}
<div class="column is-narrow">
<a href="/f/{{.EditForum.Fragment}}">
<i class="fa fa-comments"></i>
Go to Forum
</a>
</div>
{{end}}
</div>
</div>
<div class="block p-4"> <div class="block p-4">
<div class="columns is-centered"> <div class="columns is-centered">
<div class="column is-two-thirds"> <div class="column is-two-thirds">
<div class="card"> <div class="card">
<header class="card-header has-background-info"> <header class="card-header has-background-info">
<p class="card-header-title has-text-light">Forum Properties</p> <p class="card-header-title">Forum Properties</p>
</header> </header>
<div class="card-content"> <div class="card-content">
@ -96,11 +114,11 @@
Explicit <i class="fa fa-fire has-text-danger ml-1"></i> Explicit <i class="fa fa-fire has-text-danger ml-1"></i>
</label> </label>
<p class="help"> <p class="help">
Check this box if the forum is intended for explicit content. Users must Check this box if the forum is intended for explicit content. Only members who have opted-in
opt-in to see explicit content. to see explicit content can find this forum.
</p> </p>
{{if .CurrentUser.IsAdmin}} {{if .CurrentUser.HasAdminScope "admin.forum.manage"}}
<label class="checkbox mt-3"> <label class="checkbox mt-3">
<input type="checkbox" <input type="checkbox"
name="privileged" name="privileged"
@ -115,26 +133,26 @@
</p> </p>
{{end}} {{end}}
{{if .CurrentUser.IsAdmin}} {{if .CurrentUser.HasAdminScope "admin.forum.manage"}}
<label class="checkbox mt-3"> <label class="checkbox mt-3">
<input type="checkbox" <input type="checkbox"
name="permit_photos" name="permit_photos"
value="true" value="true"
{{if and .EditForum .EditForum.PermitPhotos}}checked{{end}}> {{if and .EditForum .EditForum.PermitPhotos}}checked{{end}}>
Permit Photos <i class="fa fa-camera ml-1"></i> Permit Photos <i class="fa fa-camera ml-1"></i> <i class="fa fa-peace has-text-danger ml-1"></i>
</label> </label>
<p class="help"> <p class="help">
Check this box if the forum allows photos to be uploaded (not implemented) Check this box if the forum allows photos to be uploaded (not implemented)
</p> </p>
{{end}} {{end}}
{{if .CurrentUser.IsAdmin}} {{if .CurrentUser.HasAdminScope "admin.forum.manage"}}
<label class="checkbox mt-3"> <label class="checkbox mt-3">
<input type="checkbox" <input type="checkbox"
name="private" name="private"
value="true" value="true"
{{if and .EditForum .EditForum.Private}}checked{{end}}> {{if and .EditForum .EditForum.Private}}checked{{end}}>
Private forum Private forum <i class="fa fa-peace has-text-danger ml-1"></i>
</label> </label>
<p class="help"> <p class="help">
This forum is only visible to admins or approved subscribers. This forum is only visible to admins or approved subscribers.
@ -142,6 +160,45 @@
{{end}} {{end}}
</div> </div>
<!-- On "Edit" of existing forum: show and allow adding Moderators. -->
{{if .EditForum}}
<hr>
<div class="field">
<label class="label">Forum Moderators</label>
{{if .Moderators}}
<table class="table">
<tbody>
{{range .Moderators}}
<tr>
<td>
{{template "avatar-16x16" .}}
<a href="/u/{{.Username}}">{{.Username}}</a>
</td>
<td>
<a href="/forum/admin/moderator?forum_id={{$Root.EditForum.ID}}&intent=remove&to={{.Username}}"
class="has-text-danger"
aria-label="Remove">
<i class="fa fa-trash"></i>
</a>
</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
<a href="/forum/admin/moderator?forum_id={{.EditForum.ID}}">
<i class="fa fa-plus"></i> Appoint a moderator
</a>
<p class="help">
You may appoint other members from the {{PrettyTitle}} community to help you moderate your forum.
</p>
</div>
{{end}}
<hr>
<div class="field"> <div class="field">
<button type="submit" class="button is-success"> <button type="submit" class="button is-success">
{{if .EditForum}}Save Forum{{else}}Create Forum{{end}} {{if .EditForum}}Save Forum{{else}}Create Forum{{end}}

View File

@ -57,6 +57,13 @@
</nav> </nav>
</div> </div>
<div class="column is-narrow"> <div class="column is-narrow">
<!-- Edit button for forum's settings -->
{{if or (eq .Forum.OwnerID .CurrentUser.ID) (.CurrentUser.HasAdminScope "admin.forum.manage")}}
<a href="/forum/admin/edit?id={{.Forum.ID}}" class="button mr-2">
<span class="icon"><i class="fa fa-gear"></i></span>
</a>
{{end}}
{{if or .CurrentUser.IsAdmin (not .Forum.Privileged) (eq .Forum.OwnerID .CurrentUser.ID)}} {{if or .CurrentUser.IsAdmin (not .Forum.Privileged) (eq .Forum.OwnerID .CurrentUser.ID)}}
<a href="/forum/post?to={{.Forum.Fragment}}" class="button is-info"> <a href="/forum/post?to={{.Forum.Fragment}}" class="button is-info">
<span class="icon"><i class="fa fa-plus"></i></span> <span class="icon"><i class="fa fa-plus"></i></span>
@ -83,6 +90,20 @@
{{SimplePager .Pager}} {{SimplePager .Pager}}
</div> </div>
<!-- If no posts at all? -->
{{if not .Threads}}
<div class="block p-2">
<div class="notification is-info is-light content">
<p>
There are no posts yet on this forum.
{{if not .Forum.Privileged}}
Why not <a href="/forum/post?to={{.Forum.Fragment}}">start the first thread?</a>
{{end}}
</p>
</div>
</div>
{{end}}
{{$Root := .}} {{$Root := .}}
<div class="block p-2"> <div class="block p-2">
{{range .Threads}} {{range .Threads}}
@ -172,4 +193,63 @@
{{SimplePager .Pager}} {{SimplePager .Pager}}
</div> </div>
<!-- Info box: owner, moderators -->
<div class="block p-2">
<div class="box">
<label class="label">Forum Info</label>
<div class="mb-4">
Created: <span title="{{.Forum.CreatedAt}}">{{.Forum.CreatedAt.Format "Jan _2 2006"}}</span>
<div class="mt-2">
{{if .Forum.Explicit}}
<span class="tag is-danger is-light">
<span class="icon"><i class="fa fa-fire"></i></span>
<span>Explicit</span>
</span>
{{end}}
{{if .Forum.Privileged}}
<span class="tag is-warning is-light">
<span class="icon"><i class="fa fa-peace"></i></span>
<span>Privileged</span>
</span>
{{end}}
{{if .Forum.PermitPhotos}}
<span class="tag is-grey">
<span class="icon"><i class="fa fa-camera"></i></span>
<span>Photos</span>
</span>
{{end}}
{{if .Forum.Private}}
<span class="tag is-private is-light">
<span class="icon"><i class="fa fa-lock"></i></span>
<span>Private</span>
</span>
{{end}}
</div>
</div>
<label class="label">Forum Moderators</label>
<!-- The owner first -->
{{template "avatar-16x16" .Forum.Owner}}
<a href="/u/{{.Forum.Owner.Username}}">
{{.Forum.Owner.Username}}
</a>
<small class="has-text-grey">(owner)</small>
<!-- Additional moderators -->
{{range .ForumModerators}}
<span class="pl-2">
{{template "avatar-16x16" .}}
<a href="/u/{{.Username}}">
{{.Username}}
</a>
</span>
{{end}}
</div>
</div>
{{end}} {{end}}

View File

@ -32,8 +32,8 @@
<!-- Tab bar --> <!-- Tab bar -->
{{template "ForumTabs" .}} {{template "ForumTabs" .}}
<!-- Filters for the Browse tab --> <!-- Filters for the Explore tab -->
{{if .IsBrowseTab}} {{if .IsExploreTab}}
<div class="block mb-0 p-4"> <div class="block mb-0 p-4">
<form action="{{.Request.URL.Path}}" method="GET"> <form action="{{.Request.URL.Path}}" method="GET">
@ -251,8 +251,8 @@
</div> </div>
{{end}}<!-- range .Categories --> {{end}}<!-- range .Categories -->
<!-- Pager footer for Browse tab --> <!-- Pager footer for Explore tab -->
{{if .IsBrowseTab}} {{if .IsExploreTab}}
<div class="block p-4"> <div class="block p-4">
{{SimplePager .Pager}} {{SimplePager .Pager}}
</div> </div>

View File

@ -0,0 +1,138 @@
{{define "title"}}Manage Forum Moderators{{end}}
{{define "content"}}
<div class="container">
<section class="hero is-warning is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">
Manage Forum Moderators
</h1>
<h2 class="subtitle">
For {{.Forum.Title}} <small class="ml-2">/f/{{.Forum.Fragment}}</small>
</h2>
</div>
</div>
</section>
<div class="block p-4">
<div class="columns is-centered">
<div class="column is-half">
<div class="card" style="width: 100%; max-width: 640px">
<header class="card-header {{if .IsRemoving}}has-background-danger{{else}}has-background-link{{end}}">
<p class="card-header-title has-text-light">
<span class="icon mr-2"><i class="fa fa-user-tie"></i></span>
<span>
{{if .IsRemoving}}
Remove a Moderator
{{else}}
Appoint a Moderator
{{end}}
</span>
</p>
</header>
<div class="card-content">
<!-- Show the introduction or selected user profile -->
{{if not .User}}
<div class="block">
You may use this page to <strong>appoint a moderator</strong> to help you
manage your forum. This can be <em>anybody</em> from the {{PrettyTitle}} community.
</div>
<div class="block">
Moderators will be able to delete threads and replies on your forum.
</div>
<div class="block">
As the owner of the forum, you retain full control over its settings and you alone
can add or remove its moderators.
</div>
{{else}}
<div class="block">
{{if .IsRemoving}}
Confirm that you wish to <strong>remove</strong> moderator rights for
{{.User.Username}} on your forum.
{{else}}
Confirm that you wish to grant <strong>{{.User.Username}}</strong>
access to moderate your forum by clicking the button below.
{{end}}
</div>
<div class="media block">
<div class="media-left">
{{template "avatar-64x64" .User}}
</div>
<div class="media-content">
<p class="title is-4">{{.User.NameOrUsername}}</p>
<p class="subtitle is-6">
<span class="icon"><i class="fa fa-user"></i></span>
<a href="/u/{{.User.Username}}" target="_blank">{{.User.Username}}</a>
</p>
</div>
</div>
{{end}}
<!-- Show the relevant form -->
<form action="/forum/admin/moderator" method="POST">
{{InputCSRF}}
<input type="hidden" name="forum_id" value="{{.Forum.ID}}">
{{if .User}}
<input type="hidden" name="to" value="{{.User.Username}}">
{{else}}
<div class="field block">
<label for="to" class="label">Add a moderator by their username:</label>
<input type="text" class="input"
name="to" id="to"
placeholder="username">
</div>
{{end}}
<div class="field has-text-centered">
{{if .IsRemoving}}
<button type="submit" class="button is-danger"
name="intent"
value="{{if .User}}confirm-remove{{else}}preview{{end}}">
{{if .User}}
<span class="icon"><i class="fa fa-user-xmark"></i></span>
<span>Remove from moderators</span>
{{else}}
Continue
{{end}}
</button>
{{else}}
<button type="submit" class="button is-success"
name="intent"
value="{{if .User}}submit{{else}}preview{{end}}">
{{if .User}}
<span class="icon"><i class="fa fa-user-tie"></i></span>
<span>Add to moderators</span>
{{else}}
Continue
{{end}}
</button>
{{end}}
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<script type="text/javascript">
window.addEventListener("DOMContentLoaded", (event) => {
let $file = document.querySelector("#file"),
$fileName = document.querySelector("#fileName");
$file.addEventListener("change", function() {
let file = this.files[0];
$fileName.innerHTML = file.name;
});
});
</script>
{{end}}

View File

@ -19,18 +19,70 @@
<!-- Tab bar --> <!-- Tab bar -->
{{template "ForumTabs" .}} {{template "ForumTabs" .}}
<div class="p-4"> <!-- Display Options -->
<div class="px-4">
<div class="level">
<div class="level-left">
<div class="level-item">
Found {{FormatNumberCommas .Pager.Total}} {{if .AllComments}}posts{{else}}threads{{end}} (page {{.Pager.Page}} of {{.Pager.Pages}}) Found {{FormatNumberCommas .Pager.Total}} {{if .AllComments}}posts{{else}}threads{{end}} (page {{.Pager.Page}} of {{.Pager.Pages}})
</div>
</div>
<div class="mt-2"> <div class="level-right">
<div class="level-item">
<div class="columns align-right">
{{if .FeatureUserForumsEnabled}}
<div class="column is-narrow">
<label class="label">Which forums:</label>
<div class="tabs is-toggle is-small">
<ul>
<li {{if eq .WhichForums "all"}}class="is-active"{{end}}>
<a href="{{.Request.URL.Path}}?{{QueryPlus "which" "all"}}">All</a>
</li>
<li {{if eq .WhichForums "official"}}class="is-active"{{end}}>
<a href="{{.Request.URL.Path}}?{{QueryPlus "which" "official"}}">Official</a>
</li>
<li {{if eq .WhichForums "community"}}class="is-active"{{end}}>
<a href="{{.Request.URL.Path}}?{{QueryPlus "which" "community"}}">Community</a>
</li>
{{if .CurrentUser.HasForumSubscriptions}}
<li {{if eq .WhichForums "followed"}}class="is-active"{{end}}>
<a href="{{.Request.URL.Path}}?{{QueryPlus "which" "followed"}}">My List</a>
</li>
{{end}}
</ul>
</div>
</div>
{{end}}
<div class="column is-narrow">
<label class="label">Show:</label>
<div class="tabs is-toggle is-small">
<ul>
<li {{if not .AllComments}}class="is-active"{{end}}>
<a href="{{.Request.URL.Path}}?{{QueryPlus "all" ""}}">By thread</a>
</li>
<li {{if .AllComments}}class="is-active"{{end}}>
<a href="{{.Request.URL.Path}}?{{QueryPlus "all" "true"}}">All posts</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="p-4">
{{if not .AllComments}} {{if not .AllComments}}
<!-- Default view is to deduplicate and show only threads and their newest comment --> <!-- Default view is to deduplicate and show only threads and their newest comment -->
Showing only the latest comment per thread. <a href="?{{QueryPlus "all" "true"}}">Show all comments instead?</a> Showing only the latest comment per thread. <a href="?{{QueryPlus "all" "true"}}">Show all comments instead?</a>
{{else}} {{else}}
Showing <strong>all</strong> forum posts by most recent. <a href="{{.Request.URL.Path}}">Deduplicate by thread?</a> Showing <strong>all</strong> forum posts by most recent. <a href="{{.Request.URL.Path}}?{{QueryPlus "all" ""}}">Deduplicate by thread?</a>
{{end}} {{end}}
</div> </div>
</div>
<div class="p-4"> <div class="p-4">
{{SimplePager .Pager}} {{SimplePager .Pager}}

View File

@ -4,11 +4,40 @@
<section class="hero is-light is-success"> <section class="hero is-light is-success">
<div class="hero-body"> <div class="hero-body">
<div class="container"> <div class="container">
<div class="level">
<div class="level-left mb-4">
<h1 class="title"> <h1 class="title">
<span class="icon mr-4"><i class="fa fa-comments"></i></span> <span class="icon mr-4"><i class="fa fa-comments"></i></span>
<span>{{.Forum.Title}}</span> <span>{{.Forum.Title}}</span>
</h1> </h1>
</div> </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> </div>
</section> </section>
</div> </div>

View File

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

View File

@ -1,5 +1,24 @@
<!-- User avatar widgets --> <!-- User avatar widgets -->
<!-- Parameter: .User -->
{{define "avatar-16x16"}}
<figure class="image is-16x16 is-inline-block">
<a href="/u/{{.Username}}" class="has-text-dark">
{{if .ProfilePhoto.ID}}
{{if and (eq .ProfilePhoto.Visibility "private") (not .UserRelationship.IsPrivateGranted)}}
<img src="/static/img/shy-private.png" class="is-rounded">
{{else if and (eq .ProfilePhoto.Visibility "friends") (not .UserRelationship.IsFriend)}}
<img src="/static/img/shy-friends.png" class="is-rounded">
{{else}}
<img src="{{PhotoURL .ProfilePhoto.CroppedFilename}}" class="is-rounded">
{{end}}
{{else}}
<img src="/static/img/shy.png" class="is-rounded">
{{end}}
</a>
</figure>
{{end}}
<!-- Parameter: .User --> <!-- Parameter: .User -->
{{define "avatar-24x24"}} {{define "avatar-24x24"}}
<figure class="image is-24x24 is-inline-block"> <figure class="image is-24x24 is-inline-block">