Recent Forum Posts

* Add a "Newest" tab to the Forums landing page to order ALL forum posts
  (comments) by most recent, paginated.
* Add a "Views" cooldown in Redis: viewing the same post multiple times
  within 1 hour doesn't ++ the view count with every page load, per user
  per thread ID.
* Update the paginators to handle unlimited numbers of pages: shows max
  7 page buttons with your current page towards the middle.
* General ability to jump to the "last page" of anything: use a negative
  page size like ?page=-1 and it acts like the last page.
This commit is contained in:
Noah 2022-08-30 22:13:57 -07:00
parent abb6c1c3b1
commit 500456c05e
15 changed files with 568 additions and 8 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

152
pkg/models/forum_recent.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -28,6 +28,24 @@
</div>
{{$Root := .}}
<div class="block p-4 mb-0">
<div class="tabs is-boxed">
<ul>
<li class="is-active">
<a href="/forum">
Categories
</a>
</li>
<li>
<a href="/forum/newest">
Newest
</a>
</li>
</ul>
</div>
</div>
{{range .Categories}}
<div class="block p-4">
<h1 class="title">{{.Category}}</h1>

View File

@ -0,0 +1,232 @@
{{define "title"}}Newest Posts - Forums{{end}}
{{define "content"}}
<div class="block">
<section class="hero is-light is-success">
<div class="hero-body">
<h1 class="title">
<span class="icon mr-4"><i class="fa fa-comments"></i></span>
<span>Forums</span>
</h1>
<h2 class="subtitle">
Newest Posts
</h2>
</div>
</section>
</div>
{{$Root := .}}
<div class="block p-4 mb-0">
<div class="tabs is-boxed">
<ul>
<li>
<a href="/forum">
Categories
</a>
</li>
<li class="is-active">
<a href="/forum/newest">
Newest
</a>
</li>
</ul>
</div>
</div>
<div class="p-4">
Found {{.Pager.Total}} posts (page {{.Pager.Page}} of {{.Pager.Pages}})
</div>
<div class="p-4">
<nav class="pagination" role="navigation" aria-label="pagination">
<a class="pagination-previous{{if not .Pager.HasPrevious}} is-disabled{{end}}" title="Previous"
href="{{.Request.URL.Path}}?page={{.Pager.Previous}}">Previous</a>
<a class="pagination-next{{if not .Pager.HasNext}} is-disabled{{end}}" title="Next"
href="{{.Request.URL.Path}}?page={{.Pager.Next}}">Next page</a>
<ul class="pagination-list">
{{$Root := .}}
{{range .Pager.Iter}}
<li>
<a class="pagination-link{{if .IsCurrent}} is-current{{end}}"
aria-label="Page {{.Page}}"
href="{{$Root.Request.URL.Path}}?page={{.Page}}">
{{.Page}}
</a>
</li>
{{end}}
</ul>
</nav>
</div>
<div class="p-4">
{{range .RecentPosts}}
{{$User := .Thread.Comment.User}}
<div class="card block has-background-link-light">
<div class="card-content">
<div class="columns">
<div class="column is-narrow has-text-centered pt-0 pb-1">
<a href="/u/{{$User.Username}}">
<figure class="image is-96x96 is-inline-block">
{{if $User.ProfilePhoto.ID}}
<img src="{{PhotoURL $User.ProfilePhoto.CroppedFilename}}">
{{else}}
<img src="/static/img/shy.png">
{{end}}
</figure>
<div>
<a href="/u/{{$User.Username}}" class="is-size-7">{{$User.Username}}</a>
</div>
</a>
</div>
<div class="column py-0">
<h2 class="is-size-4 pt-0">
<a href="/forum/thread/{{.ThreadID}}" class="has-text-dark has-text-weight-bold">
{{.Thread.Title}}
</a>
</h2>
<a href="/forum/thread/{{.ThreadID}}" class="has-text-dark">
{{TrimEllipses .Thread.Comment.Message 256}}
</a>
<div class="is-size-7 is-italic is-grey">
by {{.Thread.Comment.User.Username}}
&ndash;
{{SincePrettyCoarse .Thread.Comment.UpdatedAt}} ago
</div>
<hr class="has-background-grey my-2">
<h2 class="is-size-5 pt-0">
<a href="/forum/thread/{{.ThreadID}}?page=-1" class="has-text-dark">Latest Post</a>
</h2>
{{if ne .Comment.ID .Thread.CommentID}}
<a href="/forum/thread/{{.ThreadID}}?page=-1" class="is-size-7 has-text-dark">
{{TrimEllipses .Comment.Message 256}}
</a>
{{end}}
<div class="is-size-7 is-italic is-grey">
by {{.Comment.User.Username}}
&ndash;
{{SincePrettyCoarse .Comment.UpdatedAt}} ago
</div>
</div>
</div>
</div>
</div>
{{end}}
</div>
{{range .Categories}}
<div class="block p-4">
<h1 class="title">{{.Category}}</h1>
{{if eq (len .Forums) 0}}
<em>
There are no forums under this category.
{{if not $Root.CurrentUser.Explicit}}Your content filters (non-explicit) may be hiding some forums.{{end}}
</em>
{{else}}
{{range .Forums}}
{{$Stats := $Root.ForumMap.Get .ID}}
<div class="card block has-background-primary-light">
<div class="card-content">
<div class="columns">
<div class="column is-3 pt-0 pb-1">
<h2 class="is-size-4">
<strong><a href="/f/{{.Fragment}}">{{.Title}}</a></strong>
</h2>
<div class="content mb-1">
{{if .Description}}
{{ToMarkdown .Description}}
{{else}}
<em>No description</em>
{{end}}
</div>
<div>
{{if .Explicit}}
<span class="tag is-danger is-light">
<span class="icon"><i class="fa fa-fire"></i></span>
<span>Explicit</span>
</span>
{{end}}
{{if .Privileged}}
<span class="tag is-warning is-light">
<span class="icon"><i class="fa fa-gavel"></i></span>
<span>Privileged</span>
</span>
{{end}}
</div>
</div>
<div class="column py-1">
<div class="box has-background-success-light">
<h2 class="subtitle mb-1">Latest Post</h2>
{{if $Stats.RecentThread}}
<a href="/forum/thread/{{$Stats.RecentThread.ID}}">
<strong>{{$Stats.RecentThread.Title}}</strong>
</a>
<em>by {{$Stats.RecentThread.Comment.User.Username}}</em>
<div>
<em>
{{if and $Stats.RecentPost (not (eq $Stats.RecentPost.ID $Stats.RecentThread.CommentID))}}
<small>Last comment by {{$Stats.RecentPost.User.Username}}</small>
{{end}}
<small title="{{$Stats.RecentThread.UpdatedAt.Format "2006-01-02 15:04:05"}}">{{SincePrettyCoarse $Stats.RecentThread.UpdatedAt}} ago</small>
</em>
</div>
{{else}}
<em>No posts found.</em>
{{end}}
</div>
</div>
<div class="column is-3 py-1">
<div class="columns is-mobile is-gapless">
<div class="column has-text-centered mr-1">
<div class="box has-background-warning-light p-2">
<p class="is-size-7">Topics</p>
{{if $Stats}}
{{$Stats.Threads}}
{{else}}
err
{{end}}
</div>
</div>
<div class="column has-text-centered mx-1">
<div class="box has-background-warning-light p-2">
<p class="is-size-7">Posts</p>
{{if $Stats}}
{{$Stats.Posts}}
{{else}}
err
{{end}}
</div>
</div>
<div class="column has-text-centered ml-1">
<div class="box has-background-warning-light p-2">
<p class="is-size-7">Users</p>
{{if $Stats}}
{{$Stats.Users}}
{{else}}
err
{{end}}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}
{{end}}
</div>
{{end}}<!-- range .Categories -->
{{end}}

View File

@ -68,7 +68,14 @@
</span>
{{end}}
<em title="{{.Thread.UpdatedAt.Format "2006-01-02 15:04:05"}}">Updated {{SincePrettyCoarse .Thread.UpdatedAt}} ago</em>
<span class="tag is-grey is-light mr-2" title="This thread can not be replied to.">
<span class="icon"><i class="fa fa-eye"></i></span>
<span>{{.Thread.Views}} View{{PluralizeU64 .Thread.Views}}</span>
</span>
<em title="{{.Thread.UpdatedAt.Format "2006-01-02 15:04:05"}}">
Updated {{SincePrettyCoarse .Thread.UpdatedAt}} ago
</em>
</div>
<p class="block p-4 mb-0">
@ -130,7 +137,7 @@
<a href="/u/{{.User.Username}}">{{.User.Username}}</a>
{{if .User.IsAdmin}}
<div class="is-size-7 mt-1">
<span class="tag is-danger">
<span class="tag is-danger is-light">
<span class="icon"><i class="fa fa-gavel"></i></span>
<span>Admin</span>
</span>