From 1b3e8cb25023ec77a9075ba26e4b5c5e385dcf1d Mon Sep 17 00:00:00 2001
From: Noah Petherbridge
Date: Sat, 19 Oct 2024 12:44:47 -0700
Subject: [PATCH] Private Photo Sharing Improvements
* Add a user privacy setting so they can gate who is allowed to share private
photos with them (for people who dislike unsolicited shares):
* Anybody (default)
* Friends only
* Friends + people whom they have sent a DM to (on the main website)
* Nobody
* Add gating around whether to display the prompt to unlock your private photos
while you are viewing somebody's gallery:
* The current user needs at least one private photo to share.
* The target user's new privacy preference is taken into consideration.
* The "should show private photo share prompt" logic is also used on the actual
share page, e.g. for people who manually paste in a username to share with.
You can not grant access to private photos which don't exist.
* Improve the UI on the private photo shares page.
* Profile cards to add elements from the Member Directory page, such as a
Friends and Liked indicator.
* A count of the user's Private photos is shown, which links directly to
their private gallery.
* Add "Decline" buttons to the Shared With Me page: so the target of a private
photo share is able to remove/cancel shares with them.
---
pkg/controller/account/settings.go | 2 +
pkg/controller/photo/private.go | 36 ++++++++++++
pkg/controller/photo/user_gallery.go | 4 ++
pkg/models/message.go | 10 ++++
pkg/models/photo.go | 16 +++++-
pkg/models/private_photo.go | 38 ++++++++++++
web/templates/account/settings.html | 86 +++++++++++++++++++++++++---
web/templates/photo/gallery.html | 25 ++++----
web/templates/photo/private.html | 76 ++++++++++++++++++++++--
9 files changed, 266 insertions(+), 27 deletions(-)
diff --git a/pkg/controller/account/settings.go b/pkg/controller/account/settings.go
index a6a77fe..34fa087 100644
--- a/pkg/controller/account/settings.go
+++ b/pkg/controller/account/settings.go
@@ -224,6 +224,7 @@ func Settings() http.HandlerFunc {
var (
visibility = models.UserVisibility(r.PostFormValue("visibility"))
dmPrivacy = r.PostFormValue("dm_privacy")
+ ppPrivacy = r.PostFormValue("private_photo_gate")
)
user.Visibility = models.UserVisibilityPublic
@@ -236,6 +237,7 @@ func Settings() http.HandlerFunc {
// Set profile field prefs.
user.SetProfileField("dm_privacy", dmPrivacy)
+ user.SetProfileField("private_photo_gate", ppPrivacy)
if err := user.Save(); err != nil {
session.FlashError(w, r, "Failed to save user to database: %s", err)
diff --git a/pkg/controller/photo/private.go b/pkg/controller/photo/private.go
index f057ed0..428fa26 100644
--- a/pkg/controller/photo/private.go
+++ b/pkg/controller/photo/private.go
@@ -42,6 +42,12 @@ func Private() http.HandlerFunc {
return
}
+ // Collect user IDs for some mappings.
+ var userIDs = []uint64{}
+ for _, user := range users {
+ userIDs = append(userIDs, user.ID)
+ }
+
// Map reverse grantee statuses.
var GranteeMap interface{}
if isGrantee {
@@ -58,6 +64,12 @@ func Private() http.HandlerFunc {
"GranteeMap": GranteeMap,
"Users": users,
"Pager": pager,
+
+ // Mapped user statuses for frontend cards.
+ "PhotoCountMap": models.MapPhotoCountsByVisibility(users, models.PhotoPrivate),
+ "FriendMap": models.MapFriends(currentUser, users),
+ "LikedMap": models.MapLikes(currentUser, "users", userIDs),
+ "ShyMap": models.MapShyAccounts(users),
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -129,6 +141,15 @@ func Share() http.HandlerFunc {
intent = r.PostFormValue("intent")
)
+ // Is the recipient blocking this photo share?
+ if intent != "decline" && intent != "revoke" {
+ if ok, err := models.ShouldShowPrivateUnlockPrompt(currentUser, user); !ok {
+ session.FlashError(w, r, "You are unable to share your private photos with %s: %s.", user.Username, err)
+ templates.Redirect(w, "/u/"+user.Username)
+ return
+ }
+ }
+
// If submitting, do it and redirect.
if intent == "submit" {
models.UnlockPrivatePhotos(currentUser.ID, user.ID)
@@ -164,6 +185,21 @@ func Share() http.HandlerFunc {
log.Error("RevokePrivatePhotoNotifications(%s): %s", currentUser.Username, err)
}
return
+ } else if intent == "decline" {
+ // Decline = they shared with me and we do not want it.
+ models.RevokePrivatePhotos(user.ID, currentUser.ID)
+ session.Flash(w, r, "You have declined access to see %s's private photos.", user.Username)
+
+ // Remove any notification we created when the grant was given.
+ models.RemoveSpecificNotification(currentUser.ID, models.NotificationPrivatePhoto, "__private_photos", user.ID)
+
+ // Revoke any "has uploaded a new private photo" notifications in this user's list.
+ if err := models.RevokePrivatePhotoNotifications(user, currentUser); err != nil {
+ log.Error("RevokePrivatePhotoNotifications(%s): %s", user.Username, err)
+ }
+
+ templates.Redirect(w, "/photo/private?view=grantee")
+ return
}
// The other intent is "preview" so the user gets the confirmation
diff --git a/pkg/controller/photo/user_gallery.go b/pkg/controller/photo/user_gallery.go
index 5e1d39b..d1fbcba 100644
--- a/pkg/controller/photo/user_gallery.go
+++ b/pkg/controller/photo/user_gallery.go
@@ -195,12 +195,16 @@ func UserPhotos() http.HandlerFunc {
areNotificationsMuted = !v
}
+ // Should the current user be able to share their private photos with the target?
+ showPrivateUnlockPrompt, _ := models.ShouldShowPrivateUnlockPrompt(currentUser, user)
+
var vars = map[string]interface{}{
"IsOwnPhotos": currentUser.ID == user.ID,
"IsShyUser": isShy,
"IsShyFrom": isShyFrom,
"IsMyPrivateUnlockedFor": isGranted, // have WE granted THIS USER to see our private pics?
"AreWeGrantedPrivate": isGrantee, // have THEY granted US private photo access.
+ "ShowPrivateUnlockPrompt": showPrivateUnlockPrompt,
"AreFriends": areFriends,
"AreNotificationsMuted": areNotificationsMuted,
"ProfilePictureHiddenVisibility": profilePictureHidden,
diff --git a/pkg/models/message.go b/pkg/models/message.go
index 19624e8..7cd3792 100644
--- a/pkg/models/message.go
+++ b/pkg/models/message.go
@@ -169,6 +169,16 @@ func HasMessageThread(a, b *User) (uint64, bool) {
return 0, false
}
+// HasSentAMessage tells if the source user has sent a DM to the target user.
+func HasSentAMessage(sourceUser, targetUser *User) bool {
+ var count int64
+ DB.Model(&Message{}).Where(
+ "source_user_id = ? AND target_user_id = ?",
+ sourceUser.ID, targetUser.ID,
+ ).Count(&count)
+ return count > 0
+}
+
// DeleteMessageThread removes all message history between two people.
func DeleteMessageThread(message *Message) error {
return DB.Where(
diff --git a/pkg/models/photo.go b/pkg/models/photo.go
index 569662e..be7d8c3 100644
--- a/pkg/models/photo.go
+++ b/pkg/models/photo.go
@@ -451,6 +451,11 @@ func CountPhotosICanSee(user *User, viewer *User) int64 {
// MapPhotoCounts returns a mapping of user ID to the CountPhotos()-equivalent result for each.
// It's used on the member directory to show photo counts on each user card.
func MapPhotoCounts(users []*User) PhotoCountMap {
+ return MapPhotoCountsByVisibility(users, PhotoPublic)
+}
+
+// MapPhotoCountsByVisibility returns a mapping of user ID to the CountPhotos()-equivalent result for each.
+func MapPhotoCountsByVisibility(users []*User, visibility PhotoVisibility) PhotoCountMap {
var (
userIDs = []uint64{}
result = PhotoCountMap{}
@@ -471,7 +476,7 @@ func MapPhotoCounts(users []*User) PhotoCountMap {
).Select(
"user_id, count(id) AS photo_count",
).Where(
- "user_id IN ? AND visibility = ?", userIDs, PhotoPublic,
+ "user_id IN ? AND visibility = ?", userIDs, visibility,
).Group("user_id").Scan(&groups); res.Error != nil {
log.Error("CountPhotosForUsers: %s", res.Error)
}
@@ -590,16 +595,21 @@ func CountExplicitPhotos(userID uint64, visibility []PhotoVisibility) (int64, er
// CountPublicPhotos returns the number of public photos on a user's page.
func CountPublicPhotos(userID uint64) int64 {
+ return CountUserPhotosByVisibility(userID, PhotoPublic)
+}
+
+// CountUserPhotosByVisibility returns the number of a user's photos by visibility.
+func CountUserPhotosByVisibility(userID uint64, visibility PhotoVisibility) int64 {
query := DB.Where(
"user_id = ? AND visibility = ?",
userID,
- PhotoPublic,
+ visibility,
)
var count int64
result := query.Model(&Photo{}).Count(&count)
if result.Error != nil {
- log.Error("CountPublicPhotos(%d): %s", userID, result.Error)
+ log.Error("CountUserPhotosByVisibility(%d, %s): %s", userID, visibility, result.Error)
}
return count
}
diff --git a/pkg/models/private_photo.go b/pkg/models/private_photo.go
index 736ac54..4cb8b15 100644
--- a/pkg/models/private_photo.go
+++ b/pkg/models/private_photo.go
@@ -1,6 +1,7 @@
package models
import (
+ "errors"
"fmt"
"strings"
"time"
@@ -125,6 +126,43 @@ func (u *User) AllPhotoIDs() ([]uint64, error) {
return photoIDs, nil
}
+/*
+ShouldShowPrivateUnlockPrompt determines whether the current user should be shown a prompt, when viewing
+the other user's gallery, to unlock their private photos for that user.
+
+This function verifies that the source user actually has a private photo to share, and that the target
+user doesn't have a privacy setting enabled that should block the private photo unlock request.
+*/
+func ShouldShowPrivateUnlockPrompt(sourceUser, targetUser *User) (bool, error) {
+
+ // If the current user doesn't even have a private photo to share, no prompt.
+ if CountUserPhotosByVisibility(sourceUser.ID, PhotoPrivate) == 0 {
+ return false, errors.New("you do not currently have a private photo on your gallery to share")
+ }
+
+ // Does the target user have a privacy setting enabled?
+ if pp := targetUser.GetProfileField("private_photo_gate"); pp != "" {
+ areFriends := AreFriends(sourceUser.ID, targetUser.ID)
+
+ switch pp {
+ case "nobody":
+ return false, errors.New("they decline all private photo sharing")
+ case "friends":
+ if areFriends {
+ return true, nil
+ }
+ return false, errors.New("they are only accepting private photos from their friends")
+ case "messaged":
+ if areFriends || HasSentAMessage(targetUser, sourceUser) {
+ return true, nil
+ }
+ return false, errors.New("they are only accepting private photos from their friends or from people they have sent a DM to")
+ }
+ }
+
+ return true, nil
+}
+
// IsPrivateUnlocked quickly sees if sourceUserID has unlocked private photos for targetUserID to see.
func IsPrivateUnlocked(sourceUserID, targetUserID uint64) bool {
pb := &PrivatePhoto{}
diff --git a/web/templates/account/settings.html b/web/templates/account/settings.html
index b722943..3fcd4af 100644
--- a/web/templates/account/settings.html
+++ b/web/templates/account/settings.html
@@ -835,7 +835,7 @@
logged-out browser, they are prompted to log in.