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.
This commit is contained in:
parent
47aaf15078
commit
fdc410c9f1
|
@ -20,10 +20,25 @@ func Dashboard() http.HandlerFunc {
|
|||
}
|
||||
|
||||
// Mark all notifications read?
|
||||
if r.FormValue("intent") == "read-notifications" {
|
||||
models.MarkNotificationsRead(currentUser)
|
||||
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!'")
|
||||
templates.Redirect(w, "/me")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -172,8 +172,13 @@ func notifyFriendsNewPhoto(photo *models.Photo, currentUser *models.User) {
|
|||
// Who to notify?
|
||||
if photo.Visibility == models.PhotoPrivate {
|
||||
// Private grantees
|
||||
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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -224,7 +224,11 @@
|
|||
</header>
|
||||
|
||||
<div class="card-content">
|
||||
<div class="columns">
|
||||
<!-- 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}}.
|
||||
|
@ -232,15 +236,52 @@
|
|||
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 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}}
|
||||
</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">
|
||||
<tbody>
|
||||
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue
Block a user