diff --git a/pkg/config/config.go b/pkg/config/config.go index ac34500..b090c6c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -81,6 +81,15 @@ const ( PhotoQuotaCertified = 24 ) +// Forum settings +const ( + // Only ++ the Views count per user per thread within a small + // window of time - if a user keeps reloading the same thread + // rapidly it does not increment the view counter more. + ThreadViewDebounceRedisKey = "debounce-view/user=%d/thr=%d" + ThreadViewDebounceCooldown = 1 * time.Hour +) + // Variables set by main.go to make them readily available. var ( RuntimeVersion string diff --git a/pkg/config/page_sizes.go b/pkg/config/page_sizes.go index 4637e83..a9a614e 100644 --- a/pkg/config/page_sizes.go +++ b/pkg/config/page_sizes.go @@ -1,5 +1,12 @@ package config +// Number of page buttons to show on a pager. Default shows page buttons +// 1 thru N (e.g., 1 thru 8) or w/ your page number in the middle surrounded +// by its neighboring pages. +const ( + PagerButtonLimit = 6 // only even numbers make a difference +) + // Pagination sizes per page. var ( PageSizeMemberSearch = 60 diff --git a/pkg/controller/forum/newest.go b/pkg/controller/forum/newest.go new file mode 100644 index 0000000..3ed5875 --- /dev/null +++ b/pkg/controller/forum/newest.go @@ -0,0 +1,47 @@ +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" +) + +// Newest posts across all of the (official) forums. +func Newest() http.HandlerFunc { + tmpl := templates.Must("forum/newest.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 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 + } + + // Get all the categorized index forums. + var pager = &models.Pagination{ + Page: 1, + PerPage: config.PageSizeThreadList, + } + pager.ParsePage(r) + + posts, err := models.PaginateRecentPosts(currentUser, config.ForumCategories, pager) + if err != nil { + session.FlashError(w, r, "Couldn't paginate forums: %s", err) + templates.Redirect(w, "/") + return + } + + var vars = map[string]interface{}{ + "Pager": pager, + "RecentPosts": posts, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/forum/thread.go b/pkg/controller/forum/thread.go index 05bb7cf..27e3e09 100644 --- a/pkg/controller/forum/thread.go +++ b/pkg/controller/forum/thread.go @@ -55,7 +55,7 @@ func Thread() http.HandlerFunc { } // Ping the view count on this thread. - if err := thread.View(); err != nil { + if err := thread.View(currentUser.ID); err != nil { log.Error("Couldn't ping view count on thread %d: %s", thread.ID, err) } diff --git a/pkg/models/forum.go b/pkg/models/forum.go index cd44c46..43aba2a 100644 --- a/pkg/models/forum.go +++ b/pkg/models/forum.go @@ -36,6 +36,21 @@ func GetForum(id uint64) (*Forum, error) { return forum, result.Error } +// GetForums queries a set of thread IDs and returns them mapped. +func GetForums(IDs []uint64) (map[uint64]*Forum, error) { + var ( + mt = map[uint64]*Forum{} + fs = []*Forum{} + ) + + result := (&Forum{}).Preload().Where("id IN ?", IDs).Find(&fs) + for _, row := range fs { + mt[row.ID] = row + } + + return mt, result.Error +} + // ForumByFragment looks up a forum by its URL fragment. func ForumByFragment(fragment string) (*Forum, error) { if fragment == "" { diff --git a/pkg/models/forum_recent.go b/pkg/models/forum_recent.go new file mode 100644 index 0000000..f131934 --- /dev/null +++ b/pkg/models/forum_recent.go @@ -0,0 +1,152 @@ +package models + +import ( + "strings" + "time" + + "code.nonshy.com/nonshy/website/pkg/log" +) + +// RecentPost drives the "Forums / Newest" page - carrying all forum comments +// on all threads sorted by date. +type RecentPost struct { + CommentID uint64 + ThreadID uint64 + ForumID uint64 + UpdatedAt time.Time + Thread *Thread + Comment *Comment + Forum *Forum +} + +// PaginateRecentPosts returns all of the comments on a forum paginated. +func PaginateRecentPosts(user *User, categories []string, pager *Pagination) ([]*RecentPost, error) { + var ( + result = []*RecentPost{} + query = (&Comment{}).Preload() + wheres = []string{"table_name = 'threads'"} + placeholders = []interface{}{} + ) + + if categories != nil && len(categories) > 0 { + wheres = append(wheres, "forums.category IN ?") + placeholders = append(placeholders, categories) + } + + // Hide explicit forum if user hasn't opted into it. + if !user.Explicit && !user.IsAdmin { + wheres = append(wheres, "explicit = false") + } + + // Get the page of recent forum comment IDs of all time. + type scanner struct { + CommentID uint64 + ThreadID *uint64 + ForumID *uint64 + } + var scan []scanner + query = DB.Table("comments").Select( + `comments.id AS comment_id, + threads.id AS thread_id, + forums.id AS forum_id`, + ).Joins( + "LEFT OUTER JOIN threads ON (table_name = 'threads' AND table_id = threads.id)", + ).Joins( + "LEFT OUTER JOIN forums ON (threads.forum_id = forums.id)", + ).Where( + strings.Join(wheres, " AND "), + placeholders..., + ).Order("comments.updated_at desc") + + // Get the total for the pager and scan the page of ID sets. + query.Model(&Comment{}).Count(&pager.Total) + query = query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&scan) + if query.Error != nil { + return nil, query.Error + } + + // Ingest the results. + var ( + commentIDs = []uint64{} // collect distinct IDs + threadIDs = []uint64{} + forumIDs = []uint64{} + seenComments = map[uint64]interface{}{} // deduplication + seenThreads = map[uint64]interface{}{} + seenForums = map[uint64]interface{}{} + mapCommentRC = map[uint64]*RecentPost{} // map commentID to result + ) + for _, row := range scan { + // Upsert the result set. + var rp *RecentPost + if existing, ok := mapCommentRC[row.CommentID]; ok { + rp = existing + } else { + rp = &RecentPost{ + CommentID: row.CommentID, + } + mapCommentRC[row.CommentID] = rp + result = append(result, rp) + } + + // Got a thread ID? + if row.ThreadID != nil { + rp.ThreadID = *row.ThreadID + if _, ok := seenThreads[rp.ThreadID]; !ok { + seenThreads[rp.ThreadID] = nil + threadIDs = append(threadIDs, rp.ThreadID) + } + + } + + // Got a forum ID? + if row.ForumID != nil { + rp.ForumID = *row.ForumID + if _, ok := seenForums[rp.ForumID]; !ok { + seenForums[rp.ForumID] = nil + forumIDs = append(forumIDs, rp.ForumID) + } + } + + // Collect distinct comment IDs. + if _, ok := seenComments[rp.CommentID]; !ok { + seenComments[rp.CommentID] = nil + commentIDs = append(commentIDs, rp.CommentID) + } + } + + // Load all of the distinct comments, threads and forums. + var ( + comments = map[uint64]*Comment{} + threads = map[uint64]*Thread{} + forums = map[uint64]*Forum{} + ) + + if len(commentIDs) > 0 { + comments, _ = GetComments(commentIDs) + } + if len(threadIDs) > 0 { + threads, _ = GetThreads(threadIDs) + } + if len(forumIDs) > 0 { + forums, _ = GetForums(forumIDs) + } + + // Merge all the objects back in. + for _, rc := range result { + if com, ok := comments[rc.CommentID]; ok { + rc.Comment = com + } + + if thr, ok := threads[rc.ThreadID]; ok { + rc.Thread = thr + } else { + log.Error("RecentPosts: didn't find thread ID %d in map!") + } + + if f, ok := forums[rc.ForumID]; ok { + rc.Forum = f + } + } + + return result, nil +} diff --git a/pkg/models/forum_stats.go b/pkg/models/forum_stats.go index 1ab89df..2bbb8ba 100644 --- a/pkg/models/forum_stats.go +++ b/pkg/models/forum_stats.go @@ -1,6 +1,8 @@ package models -import "code.nonshy.com/nonshy/website/pkg/log" +import ( + "code.nonshy.com/nonshy/website/pkg/log" +) // ForumStatistics queries for forum-level statistics. type ForumStatistics struct { diff --git a/pkg/models/pagination.go b/pkg/models/pagination.go index f505f1d..2d2571d 100644 --- a/pkg/models/pagination.go +++ b/pkg/models/pagination.go @@ -4,14 +4,19 @@ import ( "math" "net/http" "strconv" + + "code.nonshy.com/nonshy/website/pkg/config" ) // Pagination result object. type Pagination struct { - Page int + Page int // provide <0 to mean "last page" PerPage int Total int64 Sort string + + // privates + lastPage bool } // Page for Iter. @@ -26,6 +31,7 @@ func (p *Pagination) ParsePage(r *http.Request) { a, err := strconv.Atoi(raw) if err == nil { if a <= 0 { + p.lastPage = true a = 1 } p.Page = a @@ -37,14 +43,43 @@ func (p *Pagination) ParsePage(r *http.Request) { // Iter the pages, for templates. func (p *Pagination) Iter() []Page { var ( - pages = []Page{} - total = p.Pages() + pages = []Page{} + pageIdx int + total = p.Pages() ) for i := 1; i <= total; i++ { pages = append(pages, Page{ Page: i, IsCurrent: i == p.Page, }) + + if i == p.Page { + pageIdx = i + } + } + + // Do we have A LOT of pages? + if len(pages) > config.PagerButtonLimit { + // We return a slide only N pages long. Where is our current page in the offset? + if pageIdx <= config.PagerButtonLimit/2 { + // We are near the front, return the first N pages. + return pages[:config.PagerButtonLimit+1] + } + + // Are we near the end? + if pageIdx > len(pages)-(config.PagerButtonLimit/2) { + // We are near the end, return the last N pages. + return pages[len(pages)-config.PagerButtonLimit-1:] + } + + // We are somewhere in the middle. + var result = []Page{} + for i := pageIdx - (config.PagerButtonLimit / 2) - 1; i < pageIdx+(config.PagerButtonLimit/2); i++ { + if i >= 0 && i < len(pages) { + result = append(result, pages[i]) + } + } + return result } return pages } @@ -54,6 +89,10 @@ func (p *Pagination) Pages() int { } func (p *Pagination) GetOffset() int { + // Are we looking for the FINAL page? + if p.lastPage && p.Pages() >= 1 { + p.Page = p.Pages() + } return (p.Page - 1) * p.PerPage } diff --git a/pkg/models/thread.go b/pkg/models/thread.go index 1450a9a..22f5f3b 100644 --- a/pkg/models/thread.go +++ b/pkg/models/thread.go @@ -6,7 +6,9 @@ import ( "strings" "time" + "code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/log" + "code.nonshy.com/nonshy/website/pkg/redis" "gorm.io/gorm" ) @@ -153,7 +155,15 @@ func PaginateThreads(user *User, forum *Forum, pager *Pagination) ([]*Thread, er } // View a thread, incrementing its View count but not its UpdatedAt. -func (t *Thread) View() error { +// Debounced with a Redis key. +func (t *Thread) View(userID uint64) error { + // Debounce this. + var redisKey = fmt.Sprintf(config.ThreadViewDebounceRedisKey, userID, t.ID) + if redis.Exists(redisKey) { + return nil + } + redis.Set(redisKey, nil, config.ThreadViewDebounceCooldown) + return DB.Model(&Thread{}).Where( "id = ?", t.ID, diff --git a/pkg/redis/redis.go b/pkg/redis/redis.go index 5adc1c5..9efe5c2 100644 --- a/pkg/redis/redis.go +++ b/pkg/redis/redis.go @@ -75,6 +75,16 @@ func Get(key string, v any) error { return json.Unmarshal([]byte(val), v) } +// Exists checks if a Redis key existed. +func Exists(key string) bool { + val, err := Client.Exists(ctx, key).Result() + if err != nil { + return false + } + log.Debug("redis.Exists(%s): %s", key, val) + return val == 1 +} + // Delete a key from Redis. func Delete(key string) error { return Client.Del(ctx, key).Err() diff --git a/pkg/router/router.go b/pkg/router/router.go index f41dd70..bf2d684 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -63,6 +63,7 @@ func New() http.Handler { mux.Handle("/forum", middleware.CertRequired(forum.Landing())) mux.Handle("/forum/post", middleware.CertRequired(forum.NewPost())) mux.Handle("/forum/thread/", middleware.CertRequired(forum.Thread())) + mux.Handle("/forum/newest", middleware.CertRequired(forum.Newest())) mux.Handle("/f/", middleware.CertRequired(forum.Forum())) // Admin endpoints. diff --git a/pkg/templates/template_funcs.go b/pkg/templates/template_funcs.go index b71cf47..a91bddc 100644 --- a/pkg/templates/template_funcs.go +++ b/pkg/templates/template_funcs.go @@ -42,6 +42,17 @@ func TemplateFuncs(r *http.Request) template.FuncMap { return labels[1] } }, + "PluralizeU64": func(count uint64, labels ...string) string { + if len(labels) < 2 { + labels = []string{"", "s"} + } + + if count == 1 { + return labels[0] + } else { + return labels[1] + } + }, "Pluralize": func(count int, labels ...string) string { if len(labels) < 2 { labels = []string{"", "s"} diff --git a/web/templates/forum/index.html b/web/templates/forum/index.html index 8aa2f03..6b17b38 100644 --- a/web/templates/forum/index.html +++ b/web/templates/forum/index.html @@ -28,6 +28,24 @@ {{$Root := .}} + +
Topics
+ {{if $Stats}} + {{$Stats.Threads}} + {{else}} + err + {{end}} +Posts
+ {{if $Stats}} + {{$Stats.Posts}} + {{else}} + err + {{end}} +Users
+ {{if $Stats}} + {{$Stats.Users}} + {{else}} + err + {{end}} +@@ -130,7 +137,7 @@ {{.User.Username}} {{if .User.IsAdmin}}