From c0bff8ee18a38fc51a851a97216268591c46c663 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sat, 28 Oct 2023 14:34:35 -0700 Subject: [PATCH] Settings to opt-out of certain notification types --- pkg/config/enum.go | 31 +++- pkg/controller/account/settings.go | 41 +++++ pkg/controller/api/likes.go | 2 +- pkg/controller/comment/post_comment.go | 16 +- pkg/controller/forum/new_post.go | 12 +- pkg/controller/friend/request.go | 17 +- pkg/controller/photo/edit_delete.go | 3 + pkg/controller/photo/private.go | 22 +-- pkg/controller/photo/upload.go | 3 + pkg/models/notification.go | 68 +++++++- pkg/models/subscription.go | 23 +++ web/templates/account/dashboard.html | 7 + web/templates/account/settings.html | 206 +++++++++++++++++++++++++ 13 files changed, 421 insertions(+), 30 deletions(-) diff --git a/pkg/config/enum.go b/pkg/config/enum.go index 70e1049..d901c77 100644 --- a/pkg/config/enum.go +++ b/pkg/config/enum.go @@ -63,9 +63,12 @@ var ( "interests", "music_movies", "hide_age", + } - // Site prefs - // "dm_privacy", "blur_explicit", + // Site preference names (stored in ProfileField table) + SitePreferenceFields = []string{ + "dm_privacy", + "blur_explicit", } // Choices for the Contact Us subject @@ -112,3 +115,27 @@ type Option struct { Value string Label string } + +// NotificationOptout field values (stored in user ProfileField table) +const ( + NotificationOptOutFriendPhotos = "notif_optout_friends_photos" + NotificationOptOutPrivatePhotos = "notif_optout_private_photos" + NotificationOptOutExplicitPhotos = "notif_optout_explicit_photos" + NotificationOptOutLikes = "notif_optout_likes" + NotificationOptOutComments = "notif_optout_comments" + NotificationOptOutSubscriptions = "notif_optout_subscriptions" + NotificationOptOutFriendRequestAccepted = "notif_optout_friend_request_accepted" + NotificationOptOutPrivateGrant = "notif_optout_private_grant" +) + +// Notification opt-outs (stored in ProfileField table) +var NotificationOptOutFields = []string{ + NotificationOptOutFriendPhotos, + NotificationOptOutPrivatePhotos, + NotificationOptOutExplicitPhotos, + NotificationOptOutLikes, + NotificationOptOutComments, + NotificationOptOutSubscriptions, + NotificationOptOutFriendRequestAccepted, + NotificationOptOutPrivateGrant, +} diff --git a/pkg/controller/account/settings.go b/pkg/controller/account/settings.go index 45daf16..7e8500e 100644 --- a/pkg/controller/account/settings.go +++ b/pkg/controller/account/settings.go @@ -164,6 +164,44 @@ func Settings() http.HandlerFunc { } session.Flash(w, r, "Privacy settings updated!") + case "notifications": + hashtag = "#notifications" + + // Store their notification opt-outs. + for _, key := range config.NotificationOptOutFields { + var value = r.PostFormValue(key) + + // Boolean flip for DB storage: + // - Pre-existing users before these options are added have no pref stored in the DB + // - The default pref is opt-IN (receive all notifications) + // - The checkboxes on front-end are on by default, uncheck them to opt-out, checkbox value="true" + // - So when they post as "true" (default), we keep the notifications sending + // - If they uncheck the box, no value is sent and that's an opt-out. + if value == "" { + value = "true" // opt-out, store opt-out=true in the DB + } else if value == "true" { + value = "false" // the box remained checked, they don't opt-out, store opt-out=false in the DB + } + + // Save it. TODO: fires off inserts/updates for each one, + // probably not performant to do. + user.SetProfileField(key, value) + } + session.Flash(w, r, "Notification preferences updated!") + + // Save the user for new fields to be committed to DB. + if err := user.Save(); err != nil { + session.FlashError(w, r, "Failed to save user to database: %s", err) + } + + // Are they unsubscribing from all threads? + if r.PostFormValue("unsubscribe_all_threads") == "true" { + if err := models.UnsubscribeAllThreads(user); err != nil { + session.FlashError(w, r, "Couldn't unsubscribe from threads: %s", err) + } else { + session.Flash(w, r, "Unsubscribed from all comment threads!") + } + } case "location": hashtag = "#location" var ( @@ -293,6 +331,9 @@ func Settings() http.HandlerFunc { // Show enabled status for 2FA. vars["TwoFactorEnabled"] = models.Get2FA(user.ID).Enabled + // Count of subscribed comment threads. + vars["SubscriptionCount"] = models.CountSubscriptions(user) + if err := tmpl.Execute(w, r, vars); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/pkg/controller/api/likes.go b/pkg/controller/api/likes.go index 3309573..b58d6b7 100644 --- a/pkg/controller/api/likes.go +++ b/pkg/controller/api/likes.go @@ -181,7 +181,7 @@ func Likes() http.HandlerFunc { // Notify the recipient of the like. log.Info("Added like on %s:%d, notifying owner %+v", req.TableName, tableID, targetUser) - if targetUser != nil { + if targetUser != nil && !targetUser.NotificationOptOut(config.NotificationOptOutLikes) { notif := &models.Notification{ UserID: targetUser.ID, AboutUser: *currentUser, diff --git a/pkg/controller/comment/post_comment.go b/pkg/controller/comment/post_comment.go index fd8eb2a..e1e6e0d 100644 --- a/pkg/controller/comment/post_comment.go +++ b/pkg/controller/comment/post_comment.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + "code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/session" @@ -109,6 +110,9 @@ func PostComment() http.HandlerFunc { // Are we DELETING this comment? if isDelete { + // Revoke notifications. + models.RemoveNotification("comments", comment.ID) + if err := comment.Delete(); err != nil { session.FlashError(w, r, "Error deleting your commenting: %s", err) } else { @@ -165,7 +169,7 @@ func PostComment() http.HandlerFunc { templates.Redirect(w, fromURL) // Notify the recipient of the comment. - if notifyUser != nil && notifyUser.ID != currentUser.ID { + if notifyUser != nil && notifyUser.ID != currentUser.ID && !notifyUser.NotificationOptOut(config.NotificationOptOutComments) { notif := &models.Notification{ UserID: notifyUser.ID, AboutUser: *currentUser, @@ -206,10 +210,12 @@ func PostComment() http.HandlerFunc { // 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, - ) + if !currentUser.NotificationOptOut(config.NotificationOptOutSubscriptions) { + 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 diff --git a/pkg/controller/forum/new_post.go b/pkg/controller/forum/new_post.go index f3fabda..c24cb6a 100644 --- a/pkg/controller/forum/new_post.go +++ b/pkg/controller/forum/new_post.go @@ -366,8 +366,10 @@ func NewPost() http.HandlerFunc { } // 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) + if !currentUser.NotificationOptOut(config.NotificationOptOutSubscriptions) { + 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) + } } // Redirect the poster to the correct page number too. @@ -420,8 +422,10 @@ func NewPost() http.HandlerFunc { } // 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) + if !currentUser.NotificationOptOut(config.NotificationOptOutSubscriptions) { + 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)) diff --git a/pkg/controller/friend/request.go b/pkg/controller/friend/request.go index 4788fb0..ee0d54f 100644 --- a/pkg/controller/friend/request.go +++ b/pkg/controller/friend/request.go @@ -5,6 +5,7 @@ import ( "net/http" "strings" + "code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/session" @@ -87,13 +88,15 @@ func AddFriend() http.HandlerFunc { } else { if verdict == "approve" { // Notify the requestor they'd been approved. - notif := &models.Notification{ - UserID: user.ID, - AboutUser: *currentUser, - Type: models.NotificationFriendApproved, - } - if err := models.CreateNotification(notif); err != nil { - log.Error("Couldn't create approved notification: %s", err) + if !user.NotificationOptOut(config.NotificationOptOutFriendRequestAccepted) { + notif := &models.Notification{ + UserID: user.ID, + AboutUser: *currentUser, + Type: models.NotificationFriendApproved, + } + if err := models.CreateNotification(notif); err != nil { + log.Error("Couldn't create approved notification: %s", err) + } } session.Flash(w, r, "You accepted the friend request from %s!", username) diff --git a/pkg/controller/photo/edit_delete.go b/pkg/controller/photo/edit_delete.go index 557e8ef..055c7ec 100644 --- a/pkg/controller/photo/edit_delete.go +++ b/pkg/controller/photo/edit_delete.go @@ -213,6 +213,9 @@ func Delete() http.HandlerFunc { } } + // Take back notifications on it. + models.RemoveNotification("photos", photo.ID) + if err := photo.Delete(); err != nil { session.FlashError(w, r, "Couldn't delete photo: %s", err) templates.Redirect(w, redirect) diff --git a/pkg/controller/photo/private.go b/pkg/controller/photo/private.go index 31696f7..13cdd98 100644 --- a/pkg/controller/photo/private.go +++ b/pkg/controller/photo/private.go @@ -138,16 +138,18 @@ func Share() http.HandlerFunc { templates.Redirect(w, "/photo/private") // Create a notification for this. - notif := &models.Notification{ - UserID: user.ID, - AboutUser: *currentUser, - Type: models.NotificationPrivatePhoto, - TableName: "__private_photos", - TableID: currentUser.ID, - Link: fmt.Sprintf("/photo/u/%s?visibility=private", currentUser.Username), - } - if err := models.CreateNotification(notif); err != nil { - log.Error("Couldn't create PrivatePhoto notification: %s", err) + if !user.NotificationOptOut(config.NotificationOptOutPrivateGrant) { + notif := &models.Notification{ + UserID: user.ID, + AboutUser: *currentUser, + Type: models.NotificationPrivatePhoto, + TableName: "__private_photos", + TableID: currentUser.ID, + Link: fmt.Sprintf("/photo/u/%s?visibility=private", currentUser.Username), + } + if err := models.CreateNotification(notif); err != nil { + log.Error("Couldn't create PrivatePhoto notification: %s", err) + } } return diff --git a/pkg/controller/photo/upload.go b/pkg/controller/photo/upload.go index e3871d7..7722a59 100644 --- a/pkg/controller/photo/upload.go +++ b/pkg/controller/photo/upload.go @@ -208,6 +208,9 @@ func notifyFriendsNewPhoto(photo *models.Photo, currentUser *models.User) { // You should not get notified about their new private photos. notifyUserIDs = models.FilterFriendIDs(notifyUserIDs, friendIDs) + // Filter down the notifyUserIDs further to respect their notification opt-out preferences. + notifyUserIDs = models.FilterPhotoUploadNotificationUserIDs(photo, notifyUserIDs) + for _, fid := range notifyUserIDs { notif := &models.Notification{ UserID: fid, diff --git a/pkg/models/notification.go b/pkg/models/notification.go index 73f2dcd..3e1572b 100644 --- a/pkg/models/notification.go +++ b/pkg/models/notification.go @@ -4,6 +4,7 @@ import ( "strings" "time" + "code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/log" "gorm.io/gorm" ) @@ -75,7 +76,6 @@ func CreateNotification(n *Notification) error { time.Now(), time.Now(), ).Error - // return DB.Create(n).Error } // GetNotification by ID. @@ -85,6 +85,11 @@ func GetNotification(id uint64) (*Notification, error) { return n, result.Error } +// NotificationOptOut checks whether the user opts-out of a class of notification. +func (u *User) NotificationOptOut(name string) bool { + return u.GetProfileField(name) == "true" +} + // RemoveNotification about a table ID, e.g. when removing a like. func RemoveNotification(tableName string, tableID uint64) error { result := DB.Where( @@ -218,6 +223,67 @@ func PaginateNotifications(user *User, pager *Pagination) ([]*Notification, erro return ns, result.Error } +// FilterPhotoUploadNotificationUserIDs will narrow a set of UserIDs who would be notified about +// a new photo upload to respect each user's preference for notification opt-outs. +// +// It is assumed that userIDs are already narrowed down to Friends of the current user. +func FilterPhotoUploadNotificationUserIDs(photo *Photo, userIDs []uint64) []uint64 { + var ( + result = []uint64{} + + // Collect notification opt-out profile fields and map them by user ID for easy lookup. + prefs = []*ProfileField{} + mapPrefs = map[uint64]map[string]bool{} + ) + if len(userIDs) == 0 { + return userIDs + } + + // Collect opt-out preferences for these users. + r := DB.Model(&ProfileField{}).Where( + "user_id IN ? AND name IN ?", + userIDs, []string{ + config.NotificationOptOutFriendPhotos, // all friends' photos + config.NotificationOptOutPrivatePhotos, // private photos from friends + config.NotificationOptOutExplicitPhotos, // explicit photos + }, + ).Find(&prefs) + if r.Error != nil { + log.Error("FilterPhotoUploadNotificationUserIDs: couldn't collect user preferences: %s", r.Error) + } + + // Map the preferences by user ID. + for _, row := range prefs { + if _, ok := mapPrefs[row.UserID]; !ok { + mapPrefs[row.UserID] = map[string]bool{} + } + mapPrefs[row.UserID][row.Name] = row.Value == "true" + } + + // Narrow the notification recipients based on photo property and their preferences. + for _, userID := range userIDs { + // Skip explicit photo notification? + if photo.Explicit && mapPrefs[userID][config.NotificationOptOutExplicitPhotos] { + continue + } + + // Skip private photo notification? + if photo.Visibility == PhotoPrivate && mapPrefs[userID][config.NotificationOptOutPrivatePhotos] { + continue + } + + // Skip friend photo notifications? + if mapPrefs[userID][config.NotificationOptOutFriendPhotos] { + continue + } + + // They get the notification. + result = append(result, userID) + } + + return result +} + // Save a notification. func (n *Notification) Save() error { return DB.Save(n).Error diff --git a/pkg/models/subscription.go b/pkg/models/subscription.go index 8ad0548..1773cdc 100644 --- a/pkg/models/subscription.go +++ b/pkg/models/subscription.go @@ -28,6 +28,29 @@ func GetSubscription(user *User, tableName string, tableID uint64) (*Subscriptio return s, result.Error } +// CountSubscriptions counts how many comment threads the user is subscribed to. +func CountSubscriptions(user *User) int64 { + var ( + count int64 + result = DB.Model(&Subscription{}).Where( + "user_id = ? AND subscribed IS TRUE", + user.ID, + ).Count(&count) + ) + if result.Error != nil { + log.Error("Error in CountSubscriptions(%s): %s", user.Username, result.Error) + } + return count +} + +// UnsubscribeAllThreads removes subscription preferences for all comment threads. +func UnsubscribeAllThreads(user *User) error { + return DB.Where( + "user_id = ?", + user.ID, + ).Delete(&Subscription{}).Error +} + // GetSubscribers returns all of the UserIDs that are subscribed to a thread. func GetSubscribers(tableName string, tableID uint64) []uint64 { var userIDs = []uint64{} diff --git a/web/templates/account/dashboard.html b/web/templates/account/dashboard.html index 2b01af6..7f8d332 100644 --- a/web/templates/account/dashboard.html +++ b/web/templates/account/dashboard.html @@ -315,6 +315,13 @@
+

+ + + Manage notification settings + +

+ {{range .Notifications}} diff --git a/web/templates/account/settings.html b/web/templates/account/settings.html index 2939e4b..2ec385d 100644 --- a/web/templates/account/settings.html +++ b/web/templates/account/settings.html @@ -61,6 +61,15 @@ +
  • + + Notifications +

    + Control your (on-site) notification preferences. +

    +
    +
  • +
  • Account Settings @@ -655,6 +664,192 @@ + +
    + + +
    +

    + On this page you may opt-out of certain kinds of (on-site) notification messages. + {{PrettyTitle}} does not send you any e-mails or push notification -- these on-site + notifications only appear while you are visiting the website (on your + home/user dashboard page). +

    + +
    + {{InputCSRF}} + + +

    New Photo Uploads

    + +

    + By default you will be notified when your friends upload a new picture to the site. + Below, you may opt-out of new photo upload notifications. +

    + +
    + + +

    + If unchecked, the following two notifications will not be sent either. +

    +
    + +
    + +
    + +
    + +

    + This will also depend on your opt-in to see explicit content -- otherwise + notifications about explicit photo uploads will not be sent to you. +

    +
    + +

    Likes & Comments

    + +

    + By default you will be notified when somebody 'likes' or comments on your profile page + or photos. You may turn off those notifications with the options below. +

    + +
    + + +
    + +
    + +
    + +

    Comment Thread Subscriptions

    + +

    + Comment threads and forum posts may be 'subscribed' to so that you can be notified about + comments left by other people after you. By default, you will subscribe to comment threads + after you leave your first comment. +

    + +

    + Note: you may unsubscribe from comment threads by using the link at the + top of its page (for example: at the top of a forum thread page or the top of the list of + comments on a photo page). You may also opt in to get notifications on a thread + that you didn't comment on by using the same link at the top of their pages. +

    + +

    + The options below can control the automatic opt-in for subscriptions when you leave a + comment on a new comment thread. +

    + +
    + +
    + +
    + + +

    + You are currently subscribed to {{.SubscriptionCount}} comment thread{{Pluralize64 .SubscriptionCount}}. + You may immediately unsubscribe from all of these threads by checking this box and clicking "Save" below. +

    +
    + +

    Miscellaneous

    + +
    + + +
    + +
    + +
    + + +
    + +

    + This notification is important for your account status, is rarely sent out, and can + not be opted-out from. +

    +
    + +
    + +
    + + +
    +
    +
    @@ -819,6 +1014,7 @@ window.addEventListener("DOMContentLoaded", (event) => { $prefs = document.querySelector("#prefs"), $location = document.querySelector("#location"), $privacy = document.querySelector("#privacy"), + $notifications = document.querySelector("#notifications"), $account = document.querySelector("#account") $deactivate = document.querySelector("#deactivate"), buttons = Array.from(document.getElementsByClassName("nonshy-tab-button")); @@ -828,6 +1024,7 @@ window.addEventListener("DOMContentLoaded", (event) => { $prefs.style.display = 'none'; $location.style.display = 'none'; $privacy.style.display = 'none'; + $notifications.style.display = 'none'; $account.style.display = 'none'; $deactivate.style.display = 'none'; @@ -849,6 +1046,9 @@ window.addEventListener("DOMContentLoaded", (event) => { case "privacy": $activeTab = $privacy; break; + case "notifications": + $activeTab = $notifications; + break; case "account": $activeTab = $account; break; @@ -887,11 +1087,17 @@ window.addEventListener("DOMContentLoaded", (event) => { showTab(name); e.preventDefault(); + window.requestAnimationFrame(() => { + window.scrollTo(0, 0); + }); }); }) // Show the requested tab on first page load. showTab(window.location.hash.replace(/^#/, '')); + window.requestAnimationFrame(() => { + window.scrollTo(0, 0); + }); }); // Location tab scripts.