diff --git a/pkg/config/config.go b/pkg/config/config.go index 32322fc..3a93a2f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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 diff --git a/pkg/controller/forum/add_edit.go b/pkg/controller/forum/add_edit.go index ec60300..ba196f2 100644 --- a/pkg/controller/forum/add_edit.go +++ b/pkg/controller/forum/add_edit.go @@ -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 ( diff --git a/pkg/controller/forum/browse.go b/pkg/controller/forum/browse.go index fdbde91..cf4ae55 100644 --- a/pkg/controller/forum/browse.go +++ b/pkg/controller/forum/browse.go @@ -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) diff --git a/pkg/controller/forum/forums.go b/pkg/controller/forum/forums.go index 8972fc7..eb69e08 100644 --- a/pkg/controller/forum/forums.go +++ b/pkg/controller/forum/forums.go @@ -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) diff --git a/pkg/controller/forum/manage.go b/pkg/controller/forum/manage.go index 33905ca..9c2e941 100644 --- a/pkg/controller/forum/manage.go +++ b/pkg/controller/forum/manage.go @@ -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 { diff --git a/pkg/models/certification.go b/pkg/models/certification.go index de44fce..5377c5d 100644 --- a/pkg/models/certification.go +++ b/pkg/models/certification.go @@ -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{} diff --git a/pkg/models/forum.go b/pkg/models/forum.go index 3ccb0d6..a58366d 100644 --- a/pkg/models/forum.go +++ b/pkg/models/forum.go @@ -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 { diff --git a/pkg/models/forum_quota.go b/pkg/models/forum_quota.go new file mode 100644 index 0000000..3695d06 --- /dev/null +++ b/pkg/models/forum_quota.go @@ -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 +} diff --git a/pkg/templates/template_funcs.go b/pkg/templates/template_funcs.go index af56e89..9a22860 100644 --- a/pkg/templates/template_funcs.go +++ b/pkg/templates/template_funcs.go @@ -52,17 +52,18 @@ func TemplateFuncs(r *http.Request) template.FuncMap { `s`, ) }, - "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 diff --git a/web/templates/account/profile.html b/web/templates/account/profile.html index f07bd74..4d51a4d 100644 --- a/web/templates/account/profile.html +++ b/web/templates/account/profile.html @@ -72,17 +72,23 @@ {{end}} {{if .User.Certified}}
+ NEW: August 30, 2024 +
+ + {{if .FeatureUserForumsEnabled}} ++ Yes! As of August 30, 2024 (the two-year anniversary of {{PrettyTitle}}), + we now have Community Forums where members are allowed to create their own + boards. +
+ ++ 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. +
+ ++ To create your own forum, look for the "Create a forum" button in the header of the + Forums landing page. The button should appear on the Categories or the Explore tab. +
+ ++ 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 the next question + for more information. +
+ {{else}}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 & polish before it's ready!
+ {{end}} + + {{if .FeatureUserForumsEnabled}} +- 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 leave their forums orphaned). 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. +
+ ++ 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: +
+ ++ If you achieve both, you will have allowance to create two forums to call + your own. +
+ ++ You can gain additional 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! +
+ ++ Make it about anything you want! (Within reason -- the global website rules always apply!) +
+ ++ Here are some examples for inspiration on what your forum could be about:
+ From the Forums page, click on the Explore tab. +
+ ++ This page will show a view into all 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. +
+ ++ To follow a forum, click into it so that you see its posts and look for the + " Follow" button in the page header. This will add the + forum to "My List" and you will be able to easily find it again from there. +
+ ++ When you have followed a forum, it will be added to + "My List" and it will now appear on your Forums home page. +
+ ++ On the Forums home page, a "My List" category will appear up top and + show the latest posts on all of your favorite forums. On the "Newest" + tab, you can toggle to see only "My List" and then you can easily catch up with only + the newest posts on forums you care about and hide all the rest! +
+ ++ On the "Newest" tab of the forums, there are controls near the + top of the page to select Which forums you want to see new posts from. +
+ ++ 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. +
+ ++ Note: 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. +
+ ++ At the top of the forum's home page (where you can see all its threads), look for the + " Follow" button in the header of the page. The forum will + be added to "My List" which will appear on the Forum home page. +
+ ++ You can also un-follow a forum by using the same button, which will be updated with the + text " Unfollow" and will confirm that you're sure when clicked. +
+ ++ 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. +
+ ++ Forum moderators are able to help you with: +
+ ++ To appoint a moderator, go to your Forum Management page and click + on the "Edit" button for one of your existing forums. At the bottom of its settings is the + Moderators list, with a link below to appoint a new moderator. You may also + remove moderators from this page when you have any. +
+ ++ 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. +
+ ++ Starting from the bottom up: +
+ ++ + Forum Moderators are appointed by a forum's owner to help them moderate it. + They can: +
+ + Forum Owners have additional management controls over their forum. They can + do everything Moderators can, plus the ability to: +
+ + Website Admins who manage the forums overall can do all of the above. +
+ ++ 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. +
+ ++ Your forums will still exist and other members' posts and replies in them will remain. Your + 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. +
+ ++ 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. +
+ ++ Note: all forums may be moderated by (some) {{PrettyTitle}} admins, even if a + forum is currently without an owner or any appointed moderators. +
+ ++ 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. +
+ {{end}} ++ On this page, you may create your own Forums 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. +
++ You currently own {{.QuotaCount}} 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 {{SubtractInt64 .QuotaLimit .QuotaCount}} more forum{{Pluralize64 (SubtractInt64 .QuotaLimit .QuotaCount)}}. + {{end}} +
+ ++ As you continue to participate on the Forums, your allowance of forums you may create yourself will increase over time. + Learn more +
+