Clear/Delete Notifications, Private Photo Fixes

Added the ability to delete or clear notifications.
* A "Clear all" button deletes them all (with confirmation)
* A "Remove" button on individual notifications (one confirmation per
  page load, so you can remove several without too much tedium)

Fix some things regarding private photo notifications:
* When notifying your existing grants about a new upload, only users who
  opt-in for Explicit are notified about Explicit private pictures.
* When revoking private grants, clean up the "has uploaded a new private
  photo" notifications for all of your pics from their notification
  feeds.
face-detect
Noah Petherbridge 2023-08-04 18:54:04 -07:00
parent 47aaf15078
commit fdc410c9f1
8 changed files with 309 additions and 19 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -224,22 +224,63 @@
</header>
<div class="card-content">
<div class="columns">
<div class="column">
<!-- Notifications header row: tablets on upwards -->
<div class="is-hidden-mobile">
<form method="POST" action="{{.Request.URL.Path}}">
{{InputCSRF}}
<div class="columns mb-2">
<div class="column">
{{if gt .NavUnreadNotifications 0}}
{{.NavUnreadNotifications}} unread notification{{Pluralize64 .NavUnreadNotifications}}.
{{else}}
No unread notifications.
{{end}}
</div>
<div class="column is-narrow has-text-right">
<button type="submit" name="intent" value="clear-all"
class="button is-danger is-light is-small"
onclick="return window.confirm('Are you sure you want to REMOVE all notifications?')">
<i class="fa fa-xmark mr-1"></i>
Clear all
</button>
<button type="submit" name="intent" value="read-notifications"
class="button is-link is-light is-small">
<i class="fa fa-check mr-1"></i>
Mark all as read
</button>
</div>
</div>
</form>
</div>
<!-- Notifications header: for mobiles only version -->
<div class="is-hidden-tablet">
<p>
{{if gt .NavUnreadNotifications 0}}
{{.NavUnreadNotifications}} unread notification{{Pluralize64 .NavUnreadNotifications}}.
{{else}}
No unread notifications.
{{end}}
</div>
<div class="column is-narrow">
<a href="/me?intent=read-notifications" class="button is-link is-light is-small">
<span class="icon-text">
<span class="icon"><i class="fa fa-check"></i></span>
<span>Mark all as read</span>
</span>
</a>
</div>
</p>
<form method="POST" action="{{.Request.URL.Path}}">
{{InputCSRF}}
<div class="my-2 has-text-right">
<button type="submit" name="intent" value="clear-all"
class="button is-danger is-light is-small"
onclick="return window.confirm('Are you sure you want to REMOVE all notifications?')">
<i class="fa fa-xmark mr-1"></i>
Clear all
</button>
<button type="submit" name="intent" value="read-notifications"
class="button is-link is-light is-small">
<i class="fa fa-check mr-1"></i>
Mark all as read
</button>
</div>
</form>
<hr>
</div>
<table class="table is-striped is-fullwidth is-hoverable">
@ -404,7 +445,14 @@
<hr class="has-background-light mb-1">
<small title="{{.CreatedAt.Format "2006-01-02 15:04:05"}}">
{{SincePrettyCoarse .CreatedAt}} ago
{{SincePrettyCoarse .CreatedAt}} ago.
<!-- Delete button for just this notification -->
<button type="button"
class="button is-danger is-light is-small nonshy-notif-delete-button"
data-notification-id="{{.ID}}">
<i class="fa fa-xmark mr-1"></i> Remove
</button>
</small>
</div>
@ -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', () => {
}
});
})
})
});
});
});
</script>