Comment Thread Subscriptions

* Add ability to (un)subscribe from comment threads on Forums and Photos.
* Creating a forum post, replying to a post or adding a comment to a photo
  automatically subscribes you to be notified when somebody else adds a
  comment to the thing later.
* At the top of each comment thread is a link to disable or re-enable your
  subscription. You can join a subscription without even needing to comment.
  If you click to disable notifications, they stay disabled even if you
  add another comment later.
pull/12/head
Noah 2022-08-27 11:42:48 -07:00
parent 6081aefb2f
commit aa8d719fc4
16 changed files with 432 additions and 32 deletions

View File

@ -56,6 +56,10 @@ Run `nonshy --help` for its documentation.
Run `nonshy web` to start the web server.
```bash
nonshy web --host 0.0.0.0 --port 8080 --debug
```
## Create Admin User Accounts
Use the `nonshy user add` command like so:
@ -70,6 +74,44 @@ $ nonshy user add --admin \
Shorthand options `-e`, `-p` and `-u` can work in place of the longer
options `--email`, `--password` and `--username` respectively.
After the first admin user is created, you may promote other users thru
the web app by using the admin controls on their profile page.
## A Brief Tour of the Code
* `cmd/nonshy/main.go`: the entry point for the Go program.
* `pkg/webserver.go`: the entry point for the web server.
* `pkg/config`: mostly hard-coded configuration values - all of the page
sizes and business logic controls are in here, set at compile time. For
ease of local development you may want to toggle SkipEmailValidation in
here - the signup form will then directly allow full signup with a user
and password.
* `pkg/controller`: the various web endpoint controllers are here,
categorized into subpackages (account, forum, inbox, photo, etc.)
* `pkg/log`: the logging to terminal functions.
* `pkg/mail`: functions for delivering HTML email messages.
* `pkg/markdown`: functions to render GitHub Flavored Markdown.
* `pkg/middleware`: HTTP middleware functions, for things such as:
* Session cookies
* Authentication (LoginRequired, AdminRequired)
* CSRF protection
* Logging HTTP requests
* Panic recovery for unhandled server errors
* `pkg/models`: the SQL database models and query functions are here.
* `pkg/models/deletion`: the code to fully scrub wipe data for
user deletion (GDPR/CCPA compliance).
* `pkg/photo`: photo management functions: handle uploads, scale and
crop, generate URLs and deletion.
* `pkg/ratelimit`: rate limiter for login attempts etc.
* `pkg/redis`: Redis cache functions - get/set JSON values for things like
session cookie storage and temporary rate limits.
* `pkg/router`: the HTTP route URLs for the controllers are here.
* `pkg/session`: functions to read/write the user's session cookie
(log in/out, get current user, flash messages)
* `pkg/templates`: functions to handle HTTP responses - render HTML
templates, issue redirects, error pages, ...
* `pkg/utility`: miscellaneous useful functions for the app.
## License
GPLv3.

View File

@ -178,6 +178,39 @@ func PostComment() http.HandlerFunc {
log.Error("Couldn't create Comment notification: %s", err)
}
}
// Notify subscribers to this comment thread.
for _, userID := range models.GetSubscribers(comment.TableName, comment.TableID) {
if notifyUser != nil && userID == notifyUser.ID {
// Don't notify the recipient twice.
continue
} else if userID == currentUser.ID {
// Don't notify the poster of the comment.
continue
}
notif := &models.Notification{
UserID: userID,
AboutUser: *currentUser,
Type: models.NotificationAlsoCommented,
TableName: comment.TableName,
TableID: comment.TableID,
Message: message,
Link: fromURL,
}
if err := models.CreateNotification(notif); err != nil {
log.Error("Couldn't create Comment notification for subscriber %d: %s", userID, err)
}
}
// Subscribe the current user to this comment thread, so they are
// notified if other users add followup comments.
if _, err := models.SubscribeTo(currentUser, comment.TableName, comment.TableID); err != nil {
log.Error("Couldn't subscribe user %d to comment thread %s/%d: %s",
currentUser.ID, comment.TableName, comment.TableID, err,
)
}
return
}
}

View File

@ -0,0 +1,91 @@
package comment
import (
"net/http"
"net/url"
"strconv"
"strings"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
)
// Subscription endpoint - to opt in or out of comment thread subscriptions.
func Subscription() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Query params.
var (
tableName = r.FormValue("table_name")
tableID uint64
subscribe = r.FormValue("subscribe") == "true"
fromURL = r.FormValue("next") // what page to send back to
)
// Parse the table ID param.
if idStr := r.FormValue("table_id"); idStr == "" {
session.FlashError(w, r, "Comment table ID required.")
templates.Redirect(w, "/")
return
} else {
if idInt, err := strconv.Atoi(idStr); err != nil {
session.FlashError(w, r, "Comment table ID invalid.")
templates.Redirect(w, "/")
return
} else {
tableID = uint64(idInt)
}
}
// Redirect URL must be relative.
if !strings.HasPrefix(fromURL, "/") {
// Maybe it's URL encoded?
fromURL, _ = url.QueryUnescape(fromURL)
if !strings.HasPrefix(fromURL, "/") {
fromURL = "/"
}
}
// Validate everything else.
if _, ok := models.CommentableTables[tableName]; !ok {
session.FlashError(w, r, "You can not comment on that.")
templates.Redirect(w, "/")
return
}
// 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 their subscription.
sub, err := models.GetSubscription(currentUser, tableName, tableID)
if err != nil {
// If they want to subscribe, insert their row.
if subscribe {
if _, err := models.SubscribeTo(currentUser, tableName, tableID); err != nil {
session.FlashError(w, r, "Couldn't create subscription: %s", err)
} else {
session.Flash(w, r, "You will now be notified about comments on this page.")
}
}
} else {
// Toggle it.
sub.Subscribed = subscribe
if err := sub.Save(); err != nil {
session.FlashError(w, r, "Couldn't save your subscription settings: %s", err)
} else {
if subscribe {
session.Flash(w, r, "You will now be notified about comments on this page.")
} else {
session.Flash(w, r, "You will no longer be notified about new comments on this page.")
}
}
}
templates.Redirect(w, fromURL)
})
}

View File

@ -5,6 +5,7 @@ import (
"net/http"
"strconv"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/markdown"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session"
@ -160,6 +161,31 @@ func NewPost() http.HandlerFunc {
session.FlashError(w, r, "Couldn't add reply to thread: %s", err)
} else {
session.Flash(w, r, "Reply added to the thread!")
// Notify watchers about this new post.
for _, userID := range models.GetSubscribers("threads", thread.ID) {
if userID == currentUser.ID {
continue
}
notif := &models.Notification{
UserID: userID,
AboutUser: *currentUser,
Type: models.NotificationAlsoPosted,
TableName: "threads",
TableID: thread.ID,
Message: message,
Link: fmt.Sprintf("/forum/thread/%d", thread.ID),
}
if err := models.CreateNotification(notif); err != nil {
log.Error("Couldn't create thread reply notification for subscriber %d: %s", userID, err)
}
}
// Subscribe the current user to further responses on this thread.
if _, err := models.SubscribeTo(currentUser, "threads", thread.ID); err != nil {
log.Error("Couldn't subscribe user %d to forum thread %d: %s", currentUser.ID, thread.ID, err)
}
}
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
return
@ -178,6 +204,12 @@ func NewPost() http.HandlerFunc {
session.FlashError(w, r, "Couldn't create thread: %s", err)
} else {
session.Flash(w, r, "Thread created!")
// Subscribe the current user to responses on this thread.
if _, err := models.SubscribeTo(currentUser, "threads", thread.ID); err != nil {
log.Error("Couldn't subscribe user %d to forum thread %d: %s", currentUser.ID, thread.ID, err)
}
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
return
}

View File

@ -74,11 +74,15 @@ func Thread() http.HandlerFunc {
return
}
// Is the current user subscribed to notifications on this thread?
_, isSubscribed := models.IsSubscribed(currentUser, "threads", thread.ID)
var vars = map[string]interface{}{
"Forum": forum,
"Thread": thread,
"Comments": comments,
"Pager": pager,
"Forum": forum,
"Thread": thread,
"Comments": comments,
"Pager": pager,
"IsSubscribed": isSubscribed,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -76,13 +76,17 @@ func View() http.HandlerFunc {
log.Error("Couldn't list comments for photo %d: %s", photo.ID, err)
}
// Is the current user subscribed to notifications on this thread?
_, isSubscribed := models.IsSubscribed(currentUser, "photos", photo.ID)
var vars = map[string]interface{}{
"IsOwnPhoto": currentUser.ID == user.ID,
"User": user,
"Photo": photo,
"LikeMap": likeMap,
"CommentMap": commentMap,
"Comments": comments,
"IsOwnPhoto": currentUser.ID == user.ID,
"User": user,
"Photo": photo,
"LikeMap": likeMap,
"CommentMap": commentMap,
"Comments": comments,
"IsSubscribed": isSubscribed,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -22,7 +22,8 @@ type Comment struct {
// CommentableTables are the set of table names that allow comments (via the
// generic "/comments" URI which accepts a table_name param)
var CommentableTables = map[string]interface{}{
"photos": nil,
"photos": nil,
"threads": nil,
}
// Preload related tables for the forum (classmethod).

View File

@ -23,6 +23,7 @@ func DeleteUser(user *models.User) error {
{"Likes", DeleteLikes},
{"Threads", DeleteForumThreads},
{"Comments", DeleteComments},
{"Subscriptions", DeleteSubscriptions},
{"Photos", DeleteUserPhotos},
{"Certification Photo", DeleteCertification},
{"Messages", DeleteUserMessages},
@ -126,6 +127,16 @@ func DeleteNotifications(userID uint64) error {
return result.Error
}
// DeleteSubscriptions scrubs all notification subscriptions about a user.
func DeleteSubscriptions(userID uint64) error {
log.Error("DeleteUser: DeleteSubscriptions(%d)", userID)
result := models.DB.Where(
"user_id = ?",
userID,
).Delete(&models.Subscription{})
return result.Error
}
// DeleteLikes scrubs all Likes about a user.
func DeleteLikes(userID uint64) error {
log.Error("DeleteUser: DeleteLikes(%d)", userID)

View File

@ -21,4 +21,5 @@ func AutoMigrate() {
DB.AutoMigrate(&Comment{})
DB.AutoMigrate(&Like{})
DB.AutoMigrate(&Notification{})
DB.AutoMigrate(&Subscription{})
}

View File

@ -34,6 +34,8 @@ const (
NotificationLike NotificationType = "like"
NotificationFriendApproved = "friendship_approved"
NotificationComment = "comment"
NotificationAlsoCommented = "also_comment"
NotificationAlsoPosted = "also_posted" // forum replies
NotificationCertRejected = "cert_rejected"
NotificationCertApproved = "cert_approved"
NotificationCustom = "custom" // custom message pushed
@ -104,8 +106,10 @@ func (n *Notification) Save() error {
// NotificationBody can store remote tables mapped.
type NotificationBody struct {
PhotoID uint64
Photo *Photo
PhotoID uint64
ThreadID uint64
Photo *Photo
Thread *Thread
}
type NotificationMap map[uint64]*NotificationBody
@ -131,6 +135,14 @@ func MapNotifications(ns []*Notification) NotificationMap {
result[row.ID] = &NotificationBody{}
}
result.mapNotificationPhotos(IDs)
result.mapNotificationThreads(IDs)
return result
}
// Helper function of MapNotifications to eager load Photo attachments.
func (nm NotificationMap) mapNotificationPhotos(IDs []uint64) {
type scanner struct {
PhotoID uint64
NotificationID uint64
@ -157,7 +169,7 @@ func MapNotifications(ns []*Notification) NotificationMap {
var photoIDs = []uint64{}
for _, row := range scan {
// Store the photo ID in the result now.
result[row.NotificationID].PhotoID = row.PhotoID
nm[row.NotificationID].PhotoID = row.PhotoID
photoIDs = append(photoIDs, row.PhotoID)
}
@ -167,13 +179,58 @@ func MapNotifications(ns []*Notification) NotificationMap {
log.Error("Couldn't load photo IDs for notifications: %s", err)
} else {
// Marry them to their notification IDs.
for _, body := range result {
for _, body := range nm {
if photo, ok := photos[body.PhotoID]; ok {
body.Photo = photo
}
}
}
}
return result
}
// Helper function of MapNotifications to eager load Thread attachments.
func (nm NotificationMap) mapNotificationThreads(IDs []uint64) {
type scanner struct {
ThreadID uint64
NotificationID uint64
}
var scan []scanner
// Load all of these that have threads.
err := DB.Table(
"notifications",
).Joins(
"JOIN threads ON (notifications.table_name='threads' AND notifications.table_id=threads.id)",
).Select(
"threads.id AS thread_id",
"notifications.id AS notification_id",
).Where(
"notifications.id IN ?",
IDs,
).Scan(&scan)
if err.Error != nil {
log.Error("Couldn't select thread IDs for notifications: %s", err.Error)
}
// Collect and load all the threads by ID.
var threadIDs = []uint64{}
for _, row := range scan {
// Store the thread ID in the result now.
nm[row.NotificationID].ThreadID = row.ThreadID
threadIDs = append(threadIDs, row.ThreadID)
}
// Load the threads.
if len(threadIDs) > 0 {
if threads, err := GetThreads(threadIDs); err != nil {
log.Error("Couldn't load thread IDs for notifications: %s", err)
} else {
// Marry them to their notification IDs.
for _, body := range nm {
if thread, ok := threads[body.ThreadID]; ok {
body.Thread = thread
}
}
}
}
}

View File

@ -0,0 +1,81 @@
package models
import (
"time"
"code.nonshy.com/nonshy/website/pkg/log"
)
// Subscription table - for notifications. You comment on someone's post, you get subscribed
// to other comments added to the post (unless you opt off).
type Subscription struct {
ID uint64 `gorm:"primaryKey"`
UserID uint64 `gorm:"index"` // who it belongs to
Subscribed bool `gorm:"index"`
TableName string // on which of your tables (photos, comments, ...)
TableID uint64
CreatedAt time.Time
UpdatedAt time.Time
}
// GetSubscription looks for an existing subscription or returns error if not found.
func GetSubscription(user *User, tableName string, tableID uint64) (*Subscription, error) {
var s *Subscription
result := DB.Model(s).Where(
"user_id = ? AND table_name = ? AND table_id = ?",
user.ID, tableName, tableID,
).First(&s)
return s, result.Error
}
// GetSubscribers returns all of the UserIDs that are subscribed to a thread.
func GetSubscribers(tableName string, tableID uint64) []uint64 {
var userIDs = []uint64{}
result := DB.Table(
"subscriptions",
).Select(
"user_id",
).Where(
"table_name = ? AND table_id = ? AND subscribed IS TRUE",
tableName, tableID,
).Scan(&userIDs)
if result.Error != nil {
log.Error("GetSubscribers(%s, %d): couldn't get user IDs: %s", tableName, tableID)
}
return userIDs
}
// IsSubscribed checks whether a user is currently subscribed (and notified) to a thing.
// Returns whether the row exists, and whether the user is to be notified (false if opted out).
func IsSubscribed(user *User, tableName string, tableID uint64) (exists bool, notified bool) {
if sub, err := GetSubscription(user, tableName, tableID); err != nil {
return false, false
} else {
return true, sub.Subscribed
}
}
// SubscribeTo creates a subscription to a thing (comment thread) to be notified of future activity on.
func SubscribeTo(user *User, tableName string, tableID uint64) (*Subscription, error) {
// Is there already a subscription row?
if sub, err := GetSubscription(user, tableName, tableID); err == nil {
return sub, err
}
// Create the default subscription.
sub := &Subscription{
UserID: user.ID,
Subscribed: true,
TableName: tableName,
TableID: tableID,
}
result := DB.Create(sub)
return sub, result.Error
}
// Save a subscription.
func (n *Subscription) Save() error {
return DB.Save(n).Error
}

View File

@ -54,6 +54,7 @@ func New() http.Handler {
mux.Handle("/users/block", middleware.LoginRequired(block.BlockUser()))
mux.Handle("/users/blocked", middleware.LoginRequired(block.Blocked()))
mux.Handle("/comments", middleware.LoginRequired(comment.PostComment()))
mux.Handle("/comments/subscription", middleware.LoginRequired(comment.Subscription()))
mux.Handle("/admin/unimpersonate", middleware.LoginRequired(admin.Unimpersonate()))
// Certification Required. Pages that only full (verified) members can access.

View File

@ -218,6 +218,26 @@
{{end}}
</a>
</span>
{{else if eq .Type "also_comment"}}
<span class="icon"><i class="fa fa-comment has-text-success"></i></span>
<span>
<a href="/u/{{.AboutUser.Username}}"><strong>{{.AboutUser.Username}}</strong></a>
also commented on a
<a href="{{.Link}}">
{{if eq .TableName "photos"}}
photo
{{else}}
{{.TableName}}
{{end}}
</a>
that you replied to:
</span>
{{else if eq .Type "also_posted"}}
<span class="icon"><i class="fa fa-comments has-text-success"></i></span>
<span>
<a href="/u/{{.AboutUser.Username}}"><strong>{{.AboutUser.Username}}</strong></a>
replied to <a href="{{.Link}}">a forum thread</a> that you follow:
</span>
{{else if eq .Type "friendship_approved"}}
<span class="icon"><i class="fa fa-user-group has-text-success"></i></span>
<span>
@ -242,7 +262,14 @@
<!-- Attached message? -->
{{if .Message}}
<div class="block content mb-1">
{{ToMarkdown .Message}}
<blockquote class="p-2 pl-4">{{ToMarkdown (TrimEllipses .Message 256)}}</blockquote>
</div>
{{end}}
<!-- Attached forum thread? -->
{{if $Body.Thread}}
<div>
On thread: <a href="{{.Link}}">{{$Body.Thread.Title}}</a>
</div>
{{end}}

View File

@ -71,11 +71,24 @@
<em title="{{.Thread.UpdatedAt.Format "2006-01-02 15:04:05"}}">Updated {{SincePrettyCoarse .Thread.UpdatedAt}} ago</em>
</div>
<p class="block p-4">
<p class="block p-4 mb-0">
Found <strong>{{.Pager.Total}}</strong> post{{Pluralize64 .Pager.Total}} on this thread
(page {{.Pager.Page}} of {{.Pager.Pages}}).
</p>
<p class="block px-4 mb-4">
<a href="/comments/subscription?table_name=threads&table_id={{.Thread.ID}}&next={{UrlEncode .Request.URL.String}}&subscribe={{if not .IsSubscribed}}true{{else}}false{{end}}">
<span class="icon"><i class="fa fa-bell{{if not .IsSubscribed}}-slash{{end}}"></i></span>
<span>
{{if .IsSubscribed}}
Disable notifications about this thread
{{else}}
Enable notifications about this thread
{{end}}
</span>
</a>
</p>
<div class="block p-2">
<nav class="pagination" role="navigation" aria-label="pagination">
<a class="pagination-previous{{if not .Pager.HasPrevious}} is-disabled{{end}}" title="Previous"

View File

@ -44,19 +44,6 @@
</li>
</ul>
<h1>Open Beta</h1>
<p>
This website is currently open for beta testing. It's not 100% complete yet, but is basically
functional. It currently supports profile pages, photo galleries, friend requests, Direct
Messages, a member directory to discover new users, and basic account management features.
</p>
<p>
Before the "1.0" launch it will include web forums which will provide a community space to
chat with and meet other members.
</p>
<h1>Site Rules</h1>
<ul>

View File

@ -183,6 +183,21 @@
<hr class="is-dark">
{{if not .IsOwnPhoto}}
<p class="mb-4">
<a href="/comments/subscription?table_name=photos&table_id={{.Photo.ID}}&next={{UrlEncode .Request.URL.String}}&subscribe={{if not .IsSubscribed}}true{{else}}false{{end}}">
<span class="icon"><i class="fa fa-bell{{if not .IsSubscribed}}-slash{{end}}"></i></span>
<span>
{{if .IsSubscribed}}
Disable notifications about this comment thread
{{else}}
Enable notifications about this comment thread
{{end}}
</span>
</a>
</p>
{{end}}
{{if eq $Comments 0}}
<p>
<em>There are no comments yet.</em>