diff --git a/pkg/controller/account/dashboard.go b/pkg/controller/account/dashboard.go index 87d742c..52bd528 100644 --- a/pkg/controller/account/dashboard.go +++ b/pkg/controller/account/dashboard.go @@ -20,10 +20,25 @@ func Dashboard() http.HandlerFunc { } // Mark all notifications read? - if r.FormValue("intent") == "read-notifications" { - models.MarkNotificationsRead(currentUser) - session.Flash(w, r, "All of your notifications have been marked as 'read!'") - templates.Redirect(w, "/me") + if r.Method == http.MethodPost { + switch r.FormValue("intent") { + case "read-notifications": + if err := models.MarkNotificationsRead(currentUser); err != nil { + session.FlashError(w, r, "Error marking your notifications as read: %s", err) + } else { + session.Flash(w, r, "All of your notifications have been marked as 'read!'") + } + case "clear-all": + if err := models.ClearAllNotifications(currentUser); err != nil { + session.FlashError(w, r, "Error clearing your notifications: %s", err) + } else { + session.Flash(w, r, "All of your notifications have been cleared!") + } + default: + session.FlashError(w, r, "Unknown intent.") + } + + templates.Redirect(w, r.URL.Path) return } diff --git a/pkg/controller/api/read_notification.go b/pkg/controller/api/read_notification.go index cda5855..82ba2ce 100644 --- a/pkg/controller/api/read_notification.go +++ b/pkg/controller/api/read_notification.go @@ -74,3 +74,69 @@ func ReadNotification() http.HandlerFunc { }) }) } + +// ClearNotification API to delete a single notification for the user. +func ClearNotification() http.HandlerFunc { + // Request JSON schema. + type Request struct { + ID uint64 `json:"id"` + } + + // Response JSON schema. + type Response struct { + OK bool `json:"OK"` + Error string `json:"error,omitempty"` + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + SendJSON(w, http.StatusNotAcceptable, Response{ + Error: "POST method only", + }) + return + } + + // Get the current user. + currentUser, err := session.CurrentUser(r) + if err != nil { + SendJSON(w, http.StatusBadRequest, Response{ + Error: "Couldn't get current user!", + }) + return + } + + // Parse request payload. + var req Request + if err := ParseJSON(r, &req); err != nil { + SendJSON(w, http.StatusBadRequest, Response{ + Error: fmt.Sprintf("Error with request payload: %s", err), + }) + return + } + + // Get this notification. + notif, err := models.GetNotification(req.ID) + if err != nil { + SendJSON(w, http.StatusInternalServerError, Response{ + Error: err.Error(), + }) + return + } + + // Ensure it's ours to read. + if notif.UserID != currentUser.ID { + SendJSON(w, http.StatusForbidden, Response{ + Error: "That is not your notification.", + }) + return + } + + // Delete it. + notif.Delete() + + // Send success response. + SendJSON(w, http.StatusOK, Response{ + OK: true, + }) + }) +} diff --git a/pkg/controller/photo/private.go b/pkg/controller/photo/private.go index ac0c728..7c58bd8 100644 --- a/pkg/controller/photo/private.go +++ b/pkg/controller/photo/private.go @@ -85,6 +85,11 @@ func Share() http.HandlerFunc { // Are we revoking our privates from ALL USERS? if isRevokeAll { + // Revoke any "has uploaded a new private photo" notifications from all users' lists. + if err := models.RevokePrivatePhotoNotifications(currentUser, nil); err != nil { + log.Error("RevokePrivatePhotoNotifications(%s): %s", currentUser.Username, err) + } + models.RevokePrivatePhotosAll(currentUser.ID) session.Flash(w, r, "Your private photos have been locked from ALL users.") templates.Redirect(w, "/photo/private") @@ -140,6 +145,11 @@ func Share() http.HandlerFunc { // Remove any notification we created when the grant was given. models.RemoveSpecificNotification(user.ID, models.NotificationPrivatePhoto, "__private_photos", currentUser.ID) + + // Revoke any "has uploaded a new private photo" notifications in this user's list. + if err := models.RevokePrivatePhotoNotifications(currentUser, &user.ID); err != nil { + log.Error("RevokePrivatePhotoNotifications(%s): %s", currentUser.Username, err) + } return } diff --git a/pkg/controller/photo/upload.go b/pkg/controller/photo/upload.go index 024c137..e05cc43 100644 --- a/pkg/controller/photo/upload.go +++ b/pkg/controller/photo/upload.go @@ -172,8 +172,13 @@ func notifyFriendsNewPhoto(photo *models.Photo, currentUser *models.User) { // Who to notify? if photo.Visibility == models.PhotoPrivate { // Private grantees - friendIDs = models.PrivateGranteeUserIDs(currentUser.ID) - log.Info("Notify %d private grantees about the new photo by %s", len(friendIDs), currentUser.Username) + if photo.Explicit { + friendIDs = models.PrivateGranteeAreExplicitUserIDs(currentUser.ID) + log.Info("Notify %d EXPLICIT private grantees about the new photo by %s", len(friendIDs), currentUser.Username) + } else { + friendIDs = models.PrivateGranteeUserIDs(currentUser.ID) + log.Info("Notify %d private grantees about the new photo by %s", len(friendIDs), currentUser.Username) + } } else if photo.Visibility == models.PhotoInnerCircle { // Inner circle members. If the pic is also Explicit, further narrow to explicit friend IDs. if photo.Explicit { diff --git a/pkg/models/notification.go b/pkg/models/notification.go index 8e4b325..127952b 100644 --- a/pkg/models/notification.go +++ b/pkg/models/notification.go @@ -93,6 +93,16 @@ func RemoveNotification(tableName string, tableID uint64) error { return result.Error } +// RemoveNotificationBulk about several table IDs, e.g. when bulk removing private photo upload +// notifications for everybody on the site. +func RemoveNotificationBulk(tableName string, tableIDs []uint64) error { + result := DB.Where( + "table_name = ? AND table_id IN ?", + tableName, tableIDs, + ).Delete(&Notification{}) + return result.Error +} + // RemoveSpecificNotification to remove more specialized notifications where just removing by // table name+ID is not adequate, e.g. for Private Photo Unlocks. func RemoveSpecificNotification(userID uint64, t NotificationType, tableName string, tableID uint64) error { @@ -103,6 +113,16 @@ func RemoveSpecificNotification(userID uint64, t NotificationType, tableName str return result.Error } +// RemoveSpecificNotificationBulk can remove notifications about several TableIDs of the same type, +// e.g. to bulk remove new private photo upload notifications. +func RemoveSpecificNotificationBulk(userID uint64, t NotificationType, tableName string, tableIDs []uint64) error { + result := DB.Where( + "user_id = ? AND type = ? AND table_name = ? AND table_id IN ?", + userID, t, tableName, tableIDs, + ).Delete(&Notification{}) + return result.Error +} + // MarkNotificationsRead sets all a user's notifications to read. func MarkNotificationsRead(user *User) error { return DB.Model(&Notification{}).Where( @@ -111,6 +131,13 @@ func MarkNotificationsRead(user *User) error { ).Update("read", true).Error } +// ClearAllNotifications removes a user's entire notification table. +func ClearAllNotifications(user *User) error { + return DB.Where( + "user_id = ?", user.ID, + ).Delete(&Notification{}).Error +} + // CountUnreadNotifications gets the count of unread Notifications for a user. func CountUnreadNotifications(userID uint64) (int64, error) { query := DB.Where( @@ -145,6 +172,11 @@ func (n *Notification) Save() error { return DB.Save(n).Error } +// Delete a notification. +func (n *Notification) Delete() error { + return DB.Delete(n).Error +} + // NotificationBody can store remote tables mapped. type NotificationBody struct { PhotoID uint64 diff --git a/pkg/models/private_photo.go b/pkg/models/private_photo.go index 675c6ac..b332b26 100644 --- a/pkg/models/private_photo.go +++ b/pkg/models/private_photo.go @@ -1,9 +1,11 @@ package models import ( + "fmt" "strings" "time" + "code.nonshy.com/nonshy/website/pkg/log" "gorm.io/gorm" ) @@ -56,6 +58,48 @@ func RevokePrivatePhotosAll(sourceUserID uint64) error { return result.Error } +// RevokePrivatePhotoNotifications removes notifications about newly uploaded private photos +// that were sent to one (or multiple) members when the user revokes their access later. Pass +// a nil fromUserID to revoke the photo upload notifications from ALL users. +func RevokePrivatePhotoNotifications(currentUser *User, fromUserID *uint64) error { + // Gather the IDs of all our private photos to nuke notifications for. + photoIDs, err := currentUser.AllPrivatePhotoIDs() + if err != nil { + return err + } else if len(photoIDs) == 0 { + // Nothing to do. + return nil + } + + // Who to clear the notifications for? + if fromUserID == nil { + log.Info("RevokePrivatePhotoNotifications(%s): forget about private photo uploads for EVERYBODY on photo IDs: %v", currentUser.Username, photoIDs) + return RemoveNotificationBulk("photos", photoIDs) + } else { + log.Info("RevokePrivatePhotoNotifications(%s): forget about private photo uploads for user %d on photo IDs: %v", currentUser.Username, *fromUserID, photoIDs) + return RemoveSpecificNotificationBulk(*fromUserID, NotificationNewPhoto, "photos", photoIDs) + } +} + +// AllPrivatePhotoIDs returns the listing of all IDs of the user's private photos. +func (u *User) AllPrivatePhotoIDs() ([]uint64, error) { + var photoIDs = []uint64{} + err := DB.Table( + "photos", + ).Select( + "photos.id AS id", + ).Where( + "user_id = ? AND visibility = ?", + u.ID, PhotoPrivate, + ).Scan(&photoIDs) + + if err.Error != nil { + return photoIDs, fmt.Errorf("AllPrivatePhotoIDs(%s): %s", u.Username, err.Error) + } + + return photoIDs, nil +} + // IsPrivateUnlocked quickly sees if sourceUserID has unlocked private photos for targetUserID to see. func IsPrivateUnlocked(sourceUserID, targetUserID uint64) bool { pb := &PrivatePhoto{} @@ -102,6 +146,30 @@ func PrivateGranteeUserIDs(userId uint64) []uint64 { return userIDs } +// PrivateGranteeAreExplicitUserIDs gets your private photo grantees who have opted-in to see explicit content. +func PrivateGranteeAreExplicitUserIDs(userId uint64) []uint64 { + var ( + userIDs = []uint64{} + ) + + err := DB.Table( + "private_photos", + ).Joins( + "JOIN users ON (users.id = private_photos.target_user_id)", + ).Select( + "private_photos.target_user_id AS user_id", + ).Where( + "source_user_id = ? AND users.explicit IS TRUE", + userId, + ).Scan(&userIDs) + + if err.Error != nil { + log.Error("PrivateGranteeAreExplicitUserIDs: %s", err.Error) + } + + return userIDs +} + /* PaginatePrivatePhotoList views a user's list of private photo grants. diff --git a/pkg/router/router.go b/pkg/router/router.go index 347448e..d88bc78 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -94,6 +94,7 @@ func New() http.Handler { mux.HandleFunc("/v1/users/me", api.LoginOK()) mux.Handle("/v1/likes", middleware.LoginRequired(api.Likes())) mux.Handle("/v1/notifications/read", middleware.LoginRequired(api.ReadNotification())) + mux.Handle("/v1/notifications/delete", middleware.LoginRequired(api.ClearNotification())) mux.Handle("/v1/comment-photos/remove-orphaned", api.RemoveOrphanedCommentPhotos()) // Static files. diff --git a/web/templates/account/dashboard.html b/web/templates/account/dashboard.html index 76caaf8..ad019f3 100644 --- a/web/templates/account/dashboard.html +++ b/web/templates/account/dashboard.html @@ -224,22 +224,63 @@
-
-
+ +
+
+ {{InputCSRF}} +
+
+ {{if gt .NavUnreadNotifications 0}} + {{.NavUnreadNotifications}} unread notification{{Pluralize64 .NavUnreadNotifications}}. + {{else}} + No unread notifications. + {{end}} +
+
+ + + +
+
+
+
+ + +
+

{{if gt .NavUnreadNotifications 0}} {{.NavUnreadNotifications}} unread notification{{Pluralize64 .NavUnreadNotifications}}. {{else}} No unread notifications. {{end}} -

- +

+
+ {{InputCSRF}} +
+ + + +
+
+
@@ -404,7 +445,14 @@
- {{SincePrettyCoarse .CreatedAt}} ago + {{SincePrettyCoarse .CreatedAt}} ago. + + + @@ -477,11 +525,56 @@ document.addEventListener('DOMContentLoaded', () => { let busy = false; + // For delete buttons: if they click thru the first confirm, do not ask every single time + // for the rest of the current page load. + let dontAskAgain = false; + // Bind to the notification table rows. (document.querySelectorAll(".nonshy-notification-row") || []).forEach(node => { let $newBadge = node.querySelector(".nonshy-notification-new"), + $deleteButton = node.querySelector(".nonshy-notif-delete-button"), ID = node.dataset.notificationId; + // Delete buttons for individual notifications. + $deleteButton.addEventListener("click", (e) => { + e.stopPropagation(); + e.preventDefault(); + if (!dontAskAgain) { + if (!window.confirm( + "Do you want to DELETE this notification?\n\nNote: If you click Ok, you will not be asked "+ + "the next time you want to delete another notification until your next page reload." + )) { + return; + } + dontAskAgain = true; + } + + busy = true; + return fetch("/v1/notifications/delete", { + method: "POST", + mode: "same-origin", + cache: "no-cache", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + "id": parseInt(ID), + }), + }) + .then((response) => response.json()) + .then((data) => { + console.log(data); + + // Hide the notification row immediately. + node.style.display = 'none'; + }).catch(resp => { + window.alert(resp); + }).finally(() => { + busy = false; + }); + }); + // If the notification doesn't have a "NEW!" badge, no action needed. if ($newBadge === null) return; @@ -530,7 +623,7 @@ document.addEventListener('DOMContentLoaded', () => { } }); }) - }) + }); }); });