Settings to opt-out of certain notification types

This commit is contained in:
Noah Petherbridge 2023-10-28 14:34:35 -07:00
parent 64d2749299
commit c0bff8ee18
13 changed files with 421 additions and 30 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -315,6 +315,13 @@
<hr>
</div>
<p class="block">
<a href="/settings#notifications">
<i class="fa fa-gear mr-1"></i>
Manage notification settings
</a>
</p>
<table class="table is-striped is-fullwidth is-hoverable">
<tbody>
{{range .Notifications}}

View File

@ -61,6 +61,15 @@
</a>
</li>
<li>
<a href="/settings#notifications" class="nonshy-tab-button">
<strong><i class="fa fa-bell mr-1"></i> Notifications</strong>
<p class="help">
Control your (on-site) notification preferences.
</p>
</a>
</li>
<li>
<a href="/settings#account" class="nonshy-tab-button">
<strong><i class="fa fa-user mr-1"></i> Account Settings</strong>
@ -655,6 +664,192 @@
</div>
</div>
<!-- Notification Settings -->
<div class="card mb-5" id="notifications">
<header class="card-header has-background-link">
<p class="card-header-title has-text-light">
<i class="fa fa-bell pr-2"></i>
Notification Settings
</p>
</header>
<div class="card-content">
<p class="block">
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
<a href="/me">home/user dashboard page</a>).
</p>
<form method="POST" action="/settings">
{{InputCSRF}}
<input type="hidden" name="intent" value="notifications">
<h2 class="subtitle">New Photo Uploads</h2>
<p class="block">
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.
</p>
<div class="field">
<label class="label">Notify me when...</label>
<label class="checkbox">
<input type="checkbox"
name="notif_optout_friends_photos"
value="true"
{{if ne (.CurrentUser.GetProfileField "notif_optout_friends_photos") "true"}}checked{{end}}>
My friends upload a new photo
</label>
<p class="help">
If unchecked, the following two notifications will not be sent either.
</p>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox"
name="notif_optout_private_photos"
value="true"
{{if ne (.CurrentUser.GetProfileField "notif_optout_private_photos") "true"}}checked{{end}}>
A friend who shared their private photos with me uploads a new private photo
</label>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox"
name="notif_optout_explicit_photos"
value="true"
{{if ne (.CurrentUser.GetProfileField "notif_optout_explicit_photos") "true"}}checked{{end}}>
Allow notifications for 'explicit' photo uploads by my friends
</label>
<p class="help">
This will also depend on your <a href="/settings#prefs" target="_blank">opt-in to see explicit content</a> -- otherwise
notifications about explicit photo uploads will not be sent to you.
</p>
</div>
<h2 class="subtitle mt-5">Likes &amp; Comments</h2>
<p class="block">
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.
</p>
<div class="field">
<label class="label">Notify me when...</label>
<label class="checkbox">
<input type="checkbox"
name="notif_optout_likes"
value="true"
{{if ne (.CurrentUser.GetProfileField "notif_optout_likes") "true"}}checked{{end}}>
Somebody 'likes' my profile page, photos, or comments
</label>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox"
name="notif_optout_comments"
value="true"
{{if ne (.CurrentUser.GetProfileField "notif_optout_comments") "true"}}checked{{end}}>
Somebody leaves a comment on one of my photos
</label>
</div>
<h2 class="subtitle mt-5">Comment Thread Subscriptions</h2>
<p class="block">
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.
</p>
<p class="block">
<strong>Note:</strong> 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 <em>in</em> to get notifications on a thread
that you didn't comment on by using the same link at the top of their pages.
</p>
<p class="block">
The options below can control the automatic opt-in for subscriptions when you leave a
comment on a new comment thread.
</p>
<div class="field">
<label class="checkbox">
<input type="checkbox"
name="notif_optout_subscriptions"
value="true"
{{if ne (.CurrentUser.GetProfileField "notif_optout_subscriptions") "true"}}checked{{end}}>
Subscribe to notifications for future comments when I leave a comment on something
</label>
</div>
<div class="field">
<label class="label">Unsubscribe from Comment Threads</label>
<label class="checkbox">
<input type="checkbox"
name="unsubscribe_all_threads"
value="true">
Unsubscribe NOW from <strong>all ({{.SubscriptionCount}}) comment threads</strong> that I am currently following.
</label>
<p class="help">
You are currently subscribed to <strong>{{.SubscriptionCount}}</strong> comment thread{{Pluralize64 .SubscriptionCount}}.
You may immediately unsubscribe from all of these threads by checking this box and clicking "Save" below.
</p>
</div>
<h2 class="subtitle mt-5">Miscellaneous</h2>
<div class="field">
<label class="label">Notify me when...</label>
<label class="checkbox">
<input type="checkbox"
name="notif_optout_friend_request_accepted"
value="true"
{{if ne (.CurrentUser.GetProfileField "notif_optout_friend_request_accepted") "true"}}checked{{end}}>
Somebody approves my friendship request
</label>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox"
name="notif_optout_private_grant"
value="true"
{{if ne (.CurrentUser.GetProfileField "notif_optout_private_grant") "true"}}checked{{end}}>
Somebody unlocks their private photos for me to see
</label>
</div>
<!-- Read-only box for certification photo response -->
<div class="field">
<label class="checkbox">
<input type="checkbox"
value="true"
checked
disabled>
My certification photo is approved or rejected
</label>
<p class="help">
This notification is important for your account status, is rarely sent out, and can
not be opted-out from.
</p>
</div>
<div class="field">
<button type="submit" class="button is-primary">
<i class="fa fa-save mr-2"></i> Save Privacy Settings
</button>
</div>
</form>
</div>
</div>
<!-- Account Settings -->
<div id="account">
<div class="card mb-5">
@ -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.