Forum Creation Quotas

Add minimum quotas for users to earn the ability to create custom forums.

The entry requirements that could earn the first forum include:
1. Having a Certified account status for at least 45 days.
2. Having written 10 posts or replies in the forums.

Additional quota is granted in increasing difficulty based on the count of
forum posts created.

Other changes:

* Admin view of Manage Forums can filter for official/community.
* "Certified Since" now shown on profile pages.
* Update FAQ page for Forums feature.
This commit is contained in:
Noah Petherbridge 2024-08-22 21:57:14 -07:00
parent 170cd11f9c
commit b8146ae485
13 changed files with 473 additions and 34 deletions

View File

@ -127,6 +127,24 @@ const (
UserForumsEnabled = true
)
// User-Owned Forums: Quota settings for how many forums a user can own.
var (
// They get one forum after they've been Certified for 45 days.
UserForumQuotaCertLifetimeDays = time.Hour * 24 * 45
// Schedule for gaining additional quota for a number of comments written
// on any forum thread. The user must have the sum of all of these post
// counts to gain one forum per level.
UserForumQuotaCommentCountSchedule = []int64{
10, // Get a forum after your first 10 posts.
20, // Get a 2nd forum after 20 additional posts (30 total)
30, // 30 more posts (60 total)
60, // 60 more posts (120 total)
80, // 80 more posts (200 total)
100, // and then one new forum for every 100 additional posts
}
)
// Poll settings
var (
// Max number of responses to accept for a poll (how many form

View File

@ -54,6 +54,13 @@ func AddEdit() http.HandlerFunc {
}
}
// If we are over our quota for User Forums, do not allow creating a new one.
if forum == nil && !currentUser.HasAdminScope(config.ScopeForumAdmin) && currentUser.ForumQuotaRemaining() <= 0 {
session.FlashError(w, r, "You do not currently have spare quota to create a new forum.")
templates.Redirect(w, "/forum/admin")
return
}
// Saving?
if r.Method == http.MethodPost {
var (

View File

@ -16,10 +16,10 @@ func Explore() http.HandlerFunc {
// Whitelist for ordering options.
var sortWhitelist = []string{
"title asc",
"title desc",
"created_at desc",
"created_at asc",
"title asc",
"title desc",
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -97,6 +97,9 @@ func Explore() http.HandlerFunc {
"SearchTerm": searchTerm,
"Show": show,
"Sort": sort,
// Current viewer's forum quota.
"ForumQuota": models.ComputeForumQuota(currentUser),
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -55,7 +55,7 @@ func Landing() http.HandlerFunc {
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 {
} else if len(myList) > 0 {
forums = append(forums, myList...)
categorized = append([]*models.CategorizedForum{
{
@ -75,6 +75,9 @@ func Landing() http.HandlerFunc {
"Categories": categorized,
"ForumMap": forumMap,
"FollowMap": followMap,
// Current viewer's forum quota.
"ForumQuota": models.ComputeForumQuota(currentUser),
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -22,6 +22,8 @@ func Manage() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var (
searchTerm = r.FormValue("q")
show = r.FormValue("show")
categories = []string{}
sort = r.FormValue("sort")
sortOK bool
)
@ -37,6 +39,13 @@ func Manage() http.HandlerFunc {
sort = sortWhitelist[0]
}
// Show options.
if show == "official" {
categories = config.ForumCategories
} else if show == "community" {
categories = []string{""}
}
// Get the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
@ -56,7 +65,7 @@ func Manage() http.HandlerFunc {
}
pager.ParsePage(r)
forums, err := models.PaginateOwnedForums(currentUser.ID, currentUser.IsAdmin, search, pager)
forums, err := models.PaginateOwnedForums(currentUser.ID, currentUser.IsAdmin, categories, search, pager)
if err != nil {
session.FlashError(w, r, "Couldn't paginate owned forums: %s", err)
templates.Redirect(w, "/")
@ -67,8 +76,13 @@ func Manage() http.HandlerFunc {
"Pager": pager,
"Forums": forums,
// Quote settings.
"QuotaLimit": models.ComputeForumQuota(currentUser),
"QuotaCount": models.CountOwnedUserForums(currentUser),
// Search filters.
"SearchTerm": searchTerm,
"Show": show,
"Sort": sort,
}
if err := tmpl.Execute(w, r, vars); err != nil {

View File

@ -1,6 +1,8 @@
package models
import (
"errors"
"fmt"
"time"
"gorm.io/gorm"
@ -50,6 +52,26 @@ func GetCertificationPhoto(userID uint64) (*CertificationPhoto, error) {
return p, result.Error
}
// CertifiedSince retrieve's the last updated date of the user's certification photo, if approved.
//
// This incurs a DB query for their cert photo.
func (u *User) CertifiedSince() (time.Time, error) {
if !u.Certified {
return time.Time{}, errors.New("user is not certified")
}
cert, err := GetCertificationPhoto(u.ID)
if err != nil {
return time.Time{}, err
}
if cert.Status != CertificationPhotoApproved {
return time.Time{}, fmt.Errorf("cert photo status is: %s (expected 'approved')", cert.Status)
}
return cert.UpdatedAt, nil
}
// CertificationPhotosNeedingApproval returns a pager of the pictures that require admin approval.
func CertificationPhotosNeedingApproval(status CertificationPhotoStatus, pager *Pagination) ([]*CertificationPhoto, error) {
var p = []*CertificationPhoto{}

View File

@ -166,7 +166,7 @@ func PaginateForums(user *User, categories []string, search *Search, subscribed
}
// PaginateOwnedForums returns forums the user owns (or all forums to admins).
func PaginateOwnedForums(userID uint64, isAdmin bool, search *Search, pager *Pagination) ([]*Forum, error) {
func PaginateOwnedForums(userID uint64, isAdmin bool, categories []string, search *Search, pager *Pagination) ([]*Forum, error) {
var (
fs = []*Forum{}
query = (&Forum{}).Preload()
@ -180,6 +180,11 @@ func PaginateOwnedForums(userID uint64, isAdmin bool, search *Search, pager *Pag
placeholders = append(placeholders, userID)
}
if len(categories) > 0 {
wheres = append(wheres, "category IN ?")
placeholders = append(placeholders, categories)
}
// Apply their search terms.
if search != nil {
for _, term := range search.Includes {

67
pkg/models/forum_quota.go Normal file
View File

@ -0,0 +1,67 @@
package models
import (
"time"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
)
// Functions dealing with quota allowances for user-created forums.
// ComputeForumQuota returns a count of how many user-created forums this user is allowed to own.
//
// The forum quota slowly increases over time and quantity of forum posts written by this user.
func ComputeForumQuota(user *User) int64 {
var (
credits int64
numPosts = CountCommentsByUser(user, "threads")
)
// Get the user's certification date. They get one credit for having a long standing
// certified status on their account.
if certSince, err := user.CertifiedSince(); err != nil {
return 0
} else if time.Since(certSince) > config.UserForumQuotaCertLifetimeDays {
credits++
}
// Take their number of posts and compute their quota.
var schedule int64
for _, schedule = range config.UserForumQuotaCommentCountSchedule {
if numPosts > schedule {
credits++
numPosts -= schedule
}
}
// If they still have posts, repeat the final schedule per credit.
for numPosts > schedule {
credits++
numPosts -= schedule
}
return credits
}
// ForumQuotaRemaining computes the amount of additional forums the current user can create.
func (u *User) ForumQuotaRemaining() int64 {
var (
quota = ComputeForumQuota(u)
owned = CountOwnedUserForums(u)
)
return quota - owned
}
// CountOwnedUserForums returns the total number of user forums owned by the given user.
func CountOwnedUserForums(user *User) int64 {
var count int64
result := DB.Model(&Forum{}).Where(
"owner_id = ? AND (category='' OR category IS NULL)",
user.ID,
).Count(&count)
if result.Error != nil {
log.Error("CountOwnedUserForums(%d): %s", user.ID, result.Error)
}
return count
}

View File

@ -52,17 +52,18 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
`<strong style="color: #FF77FF">s</strong>`,
)
},
"Pluralize": Pluralize[int],
"Pluralize64": Pluralize[int64],
"PluralizeU64": Pluralize[uint64],
"Substring": Substring,
"TrimEllipses": TrimEllipses,
"IterRange": IterRange,
"SubtractInt": SubtractInt,
"UrlEncode": UrlEncode,
"QueryPlus": QueryPlus(r),
"SimplePager": SimplePager(r),
"HasSuffix": strings.HasSuffix,
"Pluralize": Pluralize[int],
"Pluralize64": Pluralize[int64],
"PluralizeU64": Pluralize[uint64],
"Substring": Substring,
"TrimEllipses": TrimEllipses,
"IterRange": IterRange,
"SubtractInt": SubtractInt,
"SubtractInt64": SubtractInt64,
"UrlEncode": UrlEncode,
"QueryPlus": QueryPlus(r),
"SimplePager": SimplePager(r),
"HasSuffix": strings.HasSuffix,
// Test if a photo should be blurred ({{BlurExplicit .Photo}})
"BlurExplicit": BlurExplicit(r),
@ -232,6 +233,11 @@ func SubtractInt(a, b int) int {
return a - b
}
// SubtractInt64 subtracts two numbers.
func SubtractInt64(a, b int64) int64 {
return a - b
}
// UrlEncode escapes a series of values (joined with no delimiter)
func UrlEncode(values ...interface{}) string {
var result string

View File

@ -72,17 +72,23 @@
{{end}}
{{if .User.Certified}}
<div class="pt-1">
<div class="icon-text" title="This user has been certified via a verification selfie.">
<div class="icon-text" title="Their certification photo was approved by a website admin.">
<span class="icon">
<i class="fa-solid fa-certificate has-text-success"></i>
</span>
<strong class="has-text-info">Certified!</strong>
{{$CertSince := .User.CertifiedSince}}
<small title="On {{$CertSince.Format "Jan _2 2006"}}" class="has-text-grey is-size-7">
{{SincePrettyCoarse $CertSince}} ago
</small>
<!-- Admin link to see it -->
{{if .CurrentUser.IsAdmin}}
<a href="/admin/photo/certification?username={{.User.Username}}"
class="fa fa-image has-text-link ml-2 is-size-7"
title="Search for certification picture"></a>
class="fa fa-image ml-1 is-size-7"
title="Search for certification picture">
</a>
{{end}}
</div>
</div>

View File

@ -62,7 +62,21 @@
<ul>
<li><a href="#forum-badges">What do the various badges on the forum mean?</a></li>
<li><a href="#create-forums">Can I create my own forums?</a></li>
{{if .FeatureUserForumsEnabled}}
<li>
<a href="#forum-quota">Why can I only create a couple of forums?</a>
<span class="tag is-success">ALL new Aug 30 2024 <i class="fa fa-turn-down ml-2"></i></span>
</li>
<li><a href="#forum-topics">What should I make a forum about?</a></li>
<li><a href="#forum-explore">How do I find all these forums that people are creating?</a></li>
<li><a href="#my-list">What is <strong>"My List?"</strong></a></li>
<li><a href="#my-list-newest">How do I keep up with new forum posts only from My List?</a></li>
<li><a href="#forum-follow">How do I <strong>follow a forum</strong> that I'm interested in?</a></li>
<li><a href="#forum-moderators">How do I <strong>appoint moderators</strong> for my forum?</a></li>
<li><a href="#forum-permissions">What can forum owners and moderators do?</a></li>
<li><a href="#forum-owner-deleted">What happens if the <strong>forum owner deletes their account?</strong></a></li>
<li><a href="#forum-owner-request">How can I request to adopt a forum without an owner?</a></li>
{{end}}
</ul>
</li>
<li>
@ -859,28 +873,257 @@
<h3 id="create-forums">Can I create my own forums?</h3>
<p>
<span class="tag is-success">NEW: August 30, 2024</span>
</p>
{{if .FeatureUserForumsEnabled}}
<p>
<strong>Yes!</strong> As of August 30, 2024 (the two-year anniversary of {{PrettyTitle}}),
we now have <strong>Community Forums</strong> where members are allowed to create their own
boards.
</p>
<p>
This feature is available to {{PrettyTitle}} members who have been Certified and continued to hang around
on the site for a while, or who have written a post of posts on the forums that we already have.
</p>
<p>
<strong>To create your own forum,</strong> look for the "Create a forum" button in the header of the
<a href="/forum">Forums</a> landing page. The button should appear on the Categories or the Explore tab.
</p>
<p>
If you do not see the button, it may be because your {{PrettyTitle}} account is too new and you have
not earned allowance to create your first forum yet. See <a href="#forum-quota">the next question</a>
for more information.
</p>
{{else}}
<p>
This feature is coming soon! Users will be allowed to create their own forums and
act as moderator within their own board. The forum admin pages need a bit more
spit &amp; polish before it's ready!
</p>
{{end}}
{{if .FeatureUserForumsEnabled}}
<h3 id="forum-quota">Why can I only create a couple of forums?</h3>
<p>
Some related features with managing your own forums will include:
Managing your own forum can be a big responsibility, and it is preferable that a forum's owner
should be an active participant on the website and is not likely to delete their account in the
near future (which would <a href="#forum-owner-deleted">leave their forums orphaned</a>). We
also wish to avoid a "power moderator" who might snipe all of the best names for forums before
anybody else could have a chance to create those forums themselves.
</p>
<p>
So, the allowance to create your own forums is a privileged and is earned over time.
A couple of the easiest ways you are likely to gain your first forum include:
</p>
<ol>
<li>
Simply owning a <strong>Certified</strong> {{PrettyTitle}} account for at least 45 days.
</li>
<li>
<strong>Participating</strong> with us on the forums that we already have and writing
<strong>at least 10</strong> posts or replies.
</li>
</ol>
<p>
If you achieve both, you will have allowance to create <strong>two</strong> forums to call
your own.
</p>
<p>
You can gain <strong>additional</strong> allowance by continuing to participate on the forum.
As you write more posts yourself, on any forum, you will be able to create and manage additional
forums of your own!
</p>
<h3 id="forum-topics">What should I make a forum about?</h3>
<p>
Make it about anything you want! (Within reason -- the <a href="/tos">global website rules</a> always apply!)
</p>
<p>
Here are some examples for inspiration on what your forum could be about:
</p>
<ul>
<li>
You'll be able to make your forum "invite-only" if you want, where only approved
members can see and reply to threads.
Regional forums: create one for your city or country, so that {{PrettyTitle}} members who live near
the area can meet up discuss.
</li>
<li>
You'll be able to choose other users to help you moderate your forum. As the forum
owner, you'll retain admin control of your forum unless you assign ownership away
to another member.
Hobbies or interests: create forums about board games, Star Trek, knitting or sewing, golf or
scrabble -- and then nerd out with like-minded members of the {{PrettyTitle}} community!
</li>
<li>
Nude beaches, resorts or clubs: create a forum for your favorite spot for local fans to follow
and chat about!
</li>
</ul>
<h3 id="forum-explore">How do I find all these forums that people are creating?</h3>
<p>
From the <a href="/forum">Forums</a> page, click on the <a href="/forum/explore">Explore</a> tab.
</p>
<p>
This page will show a view into <strong>all</strong> of the forums that exist on {{PrettyTitle}}.
You may click into the "Search Filters" box to search and sort the forum list so you may narrow
in on interesting forums to follow.
</p>
<p>
To <strong>follow</strong> a forum, click into it so that you see its posts and look for the
"<i class="fa-regular fa-bookmark"></i> Follow" button in the page header. This will add the
forum to <strong>"My List"</strong> and you will be able to easily find it again from there.
</p>
<h3 id="my-list">What is "My List?"</h3>
<p>
When you have <a href="#forum-explore">followed</a> a forum, it will be added to
<strong>"My List"</strong> and it will now appear on your <a href="/forum">Forums home page</a>.
</p>
<p>
On the <a href="/forum">Forums</a> home page, a "My List" category will appear up top and
show the latest posts on all of your favorite forums. On the <a href="/forum/newest">"Newest"</a>
tab, you can toggle to see only "My List" and then you can easily catch up with <em>only</em>
the newest posts on forums you care about and hide all the rest!
</p>
<h3 id="my-list-newest">How do I keep up with new forum posts only from My List?</h3>
<p>
On the <a href="/forum/newest">"Newest"</a> tab of the forums, there are controls near the
top of the page to select <strong>Which forums</strong> you want to see new posts from.
</p>
<p>
By selecting "My List", the "Newest" tab will only show new posts from the forums that you have
specifically followed. This way, you can tune out all the rest of the noise across the forums if
you don't care about them, and keep up with only the topics you want to see.
</p>
<p>
<strong>Note:</strong> the Newest tab will remember your last setting! So if you leave it on "My List,"
that will be the new default when you come back later.
</p>
<h3 id="forum-follow">How do I follow a forum that I'm interested in?</h3>
<p>
At the top of the forum's home page (where you can see all its threads), look for the
"<i class="fa-regular fa-bookmark"></i> Follow" button in the header of the page. The forum will
be added to "My List" which will appear on the <a href="/forum">Forum home page</a>.
</p>
<p>
You can also un-follow a forum by using the same button, which will be updated with the
text "<i class="fa fa-bookmark"></i> Unfollow" and will confirm that you're sure when clicked.
</p>
<h3 id="forum-moderators">How do I appoint moderators for my forum?</h3>
<p>
You may appoint other members from the {{PrettyTitle}} community to help you with moderating
your forum. You can choose any certified member who you know and trust.
</p>
<p>
Forum moderators are able to help you with:
</p>
<ul>
<li>Deleting threads and replies that people have posted on your forum.</li>
<li>Locking threads to any new comments in case a conversation is going off the rails.</li>
</ul>
<p>
To appoint a moderator, go to your <a href="/forum/admin">Forum Management page</a> and click
on the "Edit" button for one of your existing forums. At the bottom of its settings is the
<strong>Moderators</strong> list, with a link below to appoint a new moderator. You may also
<strong>remove</strong> moderators from this page when you have any.
</p>
<p>
Copy and paste their username and confirm that you got the right profile, and they can be added
to your moderator team. They will receive a notification and be subscribed to your forum
automatically.
</p>
<h3 id="forum-permissions">What can forum owners and moderators do?</h3>
<p>
Starting from the bottom up:
</p>
<p>
<i class="fa fa-user-tie"></i>
<strong>Forum Moderators</strong> are appointed by a forum's owner to help them moderate it.
They can:
<ul>
<li>Delete posts or replies written by anybody on their specific forum.</li>
<li>Lock or unlock threads to prevent new replies in case a discussion is going off the rails.</li>
</ul>
</p>
<p>
<i class="fa fa-user-tie"></i>
<strong>Forum Owners</strong> have additional management controls over their forum. They can
do everything Moderators can, plus the ability to:
<ul>
<li>Manage the forum's settings (title, description, options).</li>
<li><i class="fa fa-thumbtack"></i> <strong>Pin</strong> threads to the top of the forum.</li>
<li>Add or remove additional moderators.</li>
</ul>
</p>
<p>
<i class="fa fa-peace"></i>
<strong>Website Admins</strong> who manage the forums overall can do all of the above.
</p>
<h3 id="forum-owner-deleted">What happens if the forum owner deletes their account?</h3>
<p>
If you create your own Forum, and then decide to fully delete your {{PrettyTitle}} account in the
future, then you will leave your forums without an owner.
</p>
<p>
Your forums will still exist and other members' posts and replies in them will remain. <em>Your</em>
posts and replies will have been deleted, along with your account, but your forums remain because
it wouldn't be fair to your forum's members if _all of their_ posts would need to be deleted along
with it.
</p>
<p>
Your forums will just be without an Owner and may be put up for adoption by another member. Any
moderators that you had appointed to help you manage your forum will also remain as moderators,
and they would have priority if any of them wanted to step in as the forum's new owner.
</p>
<p>
<strong>Note:</strong> all forums may be moderated by (some) {{PrettyTitle}} admins, even if a
forum is currently without an owner or any appointed moderators.
</p>
<h3 id="forum-owner-request">How can I request to adopt a forum without an owner?</h3>
<p>
For now, use the red "Report this forum" link at the bottom of the page and write a message
requesting to take ownership of the forum.
</p>
{{end}}
<h1 id="chat-faqs">Chat Room FAQs</h1>
<h2 id="chat-access">Who can access the chat rooms?</h2>

View File

@ -6,7 +6,7 @@
<div class="container">
<h1 class="title">
<span class="icon mr-4"><i class="fa fa-book"></i></span>
<span>Forum Administration</span>
<span>My Forums</span>
</h1>
</div>
</div>
@ -15,12 +15,40 @@
{{$Root := .}}
<div class="block p-2 content">
<p>
On this page, you may <strong>create your own Forums</strong> around any topic of your choosing. Create
a forum for people in your local area, or create one for your favorite hobby so that others who share
your interests may join your forum.
</p>
</div>
<div class="notification {{if ge .QuotaCount .QuotaLimit}}is-warning{{else}}is-info{{end}} is-light block content">
<p>
You currently own <strong>{{.QuotaCount}}</strong> of your allowed {{.QuotaLimit}} forum{{Pluralize64 .QuotaLimit}}.
{{if ge .QuotaCount .QuotaLimit}}
You have reached your maximum amount of owned forums at this time.
{{else}}
You may create <strong>{{SubtractInt64 .QuotaLimit .QuotaCount}}</strong> more forum{{Pluralize64 (SubtractInt64 .QuotaLimit .QuotaCount)}}.
{{end}}
</p>
<p>
As you continue to participate on the Forums, your allowance of forums you may create yourself will increase over time.
<a href="#" class="has-text-danger">Learn more <i class="fa fa-external-link"></i></a>
</p>
</div>
<!-- Create New Forum button, if the user has quota -->
{{if or (.CurrentUser.HasAdminScope "admin.forum.manage") (gt .QuotaLimit .QuotaCount)}}
<div class="block p-2">
<a href="/forum/admin/edit" class="button is-success">
<span class="icon"><i class="fa fa-plus"></i></span>
<span>Create New Forum</span>
</a>
</div>
{{end}}
<div class="block p-2">
<form action="{{.Request.URL.Path}}" method="GET">
@ -54,6 +82,21 @@
</div>
</div>
{{if .CurrentUser.IsAdmin}}
<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="">All forums</option>
<option value="official"{{if eq .Show "official"}} selected{{end}}>Official forums</option>
<option value="community"{{if eq .Show "community"}} selected{{end}}>Community forums</option>
</select>
</div>
</div>
</div>
{{end}}
<div class="column px-1">
<div class="field">
<label class="label" for="sort">Sort by:</label>

View File

@ -12,14 +12,16 @@
</h1>
</div>
{{if or .FeatureUserForumsEnabled (.CurrentUser.HasAdminScope "admin.forum.manage")}}
<div class="level-right">
<div>
<a href="/forum/admin" class="button is-small">
<span class="icon"><i class="fa fa-circle-plus"></i></span>
<span>Create a forum</span>
</a>
{{if .ForumQuota}}
<div class="level-right">
<div>
<a href="/forum/admin" class="button is-small">
<span class="icon"><i class="fa fa-circle-plus"></i></span>
<span>Create a forum</span>
</a>
</div>
</div>
</div>
{{end}}
{{end}}
</div>
</div>