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.
This commit is contained in:
Noah Petherbridge 2024-10-19 12:44:47 -07:00
parent e146c09850
commit 1b3e8cb250
9 changed files with 266 additions and 27 deletions

View File

@ -224,6 +224,7 @@ func Settings() http.HandlerFunc {
var ( var (
visibility = models.UserVisibility(r.PostFormValue("visibility")) visibility = models.UserVisibility(r.PostFormValue("visibility"))
dmPrivacy = r.PostFormValue("dm_privacy") dmPrivacy = r.PostFormValue("dm_privacy")
ppPrivacy = r.PostFormValue("private_photo_gate")
) )
user.Visibility = models.UserVisibilityPublic user.Visibility = models.UserVisibilityPublic
@ -236,6 +237,7 @@ func Settings() http.HandlerFunc {
// Set profile field prefs. // Set profile field prefs.
user.SetProfileField("dm_privacy", dmPrivacy) user.SetProfileField("dm_privacy", dmPrivacy)
user.SetProfileField("private_photo_gate", ppPrivacy)
if err := user.Save(); err != nil { if err := user.Save(); err != nil {
session.FlashError(w, r, "Failed to save user to database: %s", err) session.FlashError(w, r, "Failed to save user to database: %s", err)

View File

@ -42,6 +42,12 @@ func Private() http.HandlerFunc {
return return
} }
// Collect user IDs for some mappings.
var userIDs = []uint64{}
for _, user := range users {
userIDs = append(userIDs, user.ID)
}
// Map reverse grantee statuses. // Map reverse grantee statuses.
var GranteeMap interface{} var GranteeMap interface{}
if isGrantee { if isGrantee {
@ -58,6 +64,12 @@ func Private() http.HandlerFunc {
"GranteeMap": GranteeMap, "GranteeMap": GranteeMap,
"Users": users, "Users": users,
"Pager": pager, "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 { if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
@ -129,6 +141,15 @@ func Share() http.HandlerFunc {
intent = r.PostFormValue("intent") 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 submitting, do it and redirect.
if intent == "submit" { if intent == "submit" {
models.UnlockPrivatePhotos(currentUser.ID, user.ID) models.UnlockPrivatePhotos(currentUser.ID, user.ID)
@ -164,6 +185,21 @@ func Share() http.HandlerFunc {
log.Error("RevokePrivatePhotoNotifications(%s): %s", currentUser.Username, err) log.Error("RevokePrivatePhotoNotifications(%s): %s", currentUser.Username, err)
} }
return 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 // The other intent is "preview" so the user gets the confirmation

View File

@ -195,12 +195,16 @@ func UserPhotos() http.HandlerFunc {
areNotificationsMuted = !v 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{}{ var vars = map[string]interface{}{
"IsOwnPhotos": currentUser.ID == user.ID, "IsOwnPhotos": currentUser.ID == user.ID,
"IsShyUser": isShy, "IsShyUser": isShy,
"IsShyFrom": isShyFrom, "IsShyFrom": isShyFrom,
"IsMyPrivateUnlockedFor": isGranted, // have WE granted THIS USER to see our private pics? "IsMyPrivateUnlockedFor": isGranted, // have WE granted THIS USER to see our private pics?
"AreWeGrantedPrivate": isGrantee, // have THEY granted US private photo access. "AreWeGrantedPrivate": isGrantee, // have THEY granted US private photo access.
"ShowPrivateUnlockPrompt": showPrivateUnlockPrompt,
"AreFriends": areFriends, "AreFriends": areFriends,
"AreNotificationsMuted": areNotificationsMuted, "AreNotificationsMuted": areNotificationsMuted,
"ProfilePictureHiddenVisibility": profilePictureHidden, "ProfilePictureHiddenVisibility": profilePictureHidden,

View File

@ -169,6 +169,16 @@ func HasMessageThread(a, b *User) (uint64, bool) {
return 0, false 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. // DeleteMessageThread removes all message history between two people.
func DeleteMessageThread(message *Message) error { func DeleteMessageThread(message *Message) error {
return DB.Where( return DB.Where(

View File

@ -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. // 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. // It's used on the member directory to show photo counts on each user card.
func MapPhotoCounts(users []*User) PhotoCountMap { 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 ( var (
userIDs = []uint64{} userIDs = []uint64{}
result = PhotoCountMap{} result = PhotoCountMap{}
@ -471,7 +476,7 @@ func MapPhotoCounts(users []*User) PhotoCountMap {
).Select( ).Select(
"user_id, count(id) AS photo_count", "user_id, count(id) AS photo_count",
).Where( ).Where(
"user_id IN ? AND visibility = ?", userIDs, PhotoPublic, "user_id IN ? AND visibility = ?", userIDs, visibility,
).Group("user_id").Scan(&groups); res.Error != nil { ).Group("user_id").Scan(&groups); res.Error != nil {
log.Error("CountPhotosForUsers: %s", res.Error) 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. // CountPublicPhotos returns the number of public photos on a user's page.
func CountPublicPhotos(userID uint64) int64 { 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( query := DB.Where(
"user_id = ? AND visibility = ?", "user_id = ? AND visibility = ?",
userID, userID,
PhotoPublic, visibility,
) )
var count int64 var count int64
result := query.Model(&Photo{}).Count(&count) result := query.Model(&Photo{}).Count(&count)
if result.Error != nil { if result.Error != nil {
log.Error("CountPublicPhotos(%d): %s", userID, result.Error) log.Error("CountUserPhotosByVisibility(%d, %s): %s", userID, visibility, result.Error)
} }
return count return count
} }

View File

@ -1,6 +1,7 @@
package models package models
import ( import (
"errors"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@ -125,6 +126,43 @@ func (u *User) AllPhotoIDs() ([]uint64, error) {
return photoIDs, nil 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. // IsPrivateUnlocked quickly sees if sourceUserID has unlocked private photos for targetUserID to see.
func IsPrivateUnlocked(sourceUserID, targetUserID uint64) bool { func IsPrivateUnlocked(sourceUserID, targetUserID uint64) bool {
pb := &PrivatePhoto{} pb := &PrivatePhoto{}

View File

@ -835,7 +835,7 @@
logged-out browser, they are prompted to log in. logged-out browser, they are prompted to log in.
</p> </p>
<label class="checkbox mt-2"> <label class="checkbox mt-3">
<input type="radio" <input type="radio"
name="visibility" name="visibility"
value="external" value="external"
@ -850,7 +850,7 @@
<a href="/u/{{.CurrentUser.Username}}?view=external" target="_blank">Preview <i class="fa fa-external-link"></i></a> <a href="/u/{{.CurrentUser.Username}}?view=external" target="_blank">Preview <i class="fa fa-external-link"></i></a>
</p> </p>
<label class="checkbox mt-2"> <label class="checkbox mt-3">
<input type="radio" <input type="radio"
name="visibility" name="visibility"
value="private" value="private"
@ -871,15 +871,14 @@
<div class="field"> <div class="field">
<label class="label mb-0">Who can send me the first <i class="fa fa-envelope"></i> Message?</label> <label class="label mb-0">Who can send me the first <i class="fa fa-envelope"></i> Message?</label>
<div class="has-text-info ml-4"> <div class="has-text-info">
<small><em> <small><em>
Note: this refers to Direct Messages on the main website Note: this refers to Direct Messages on the main website
(not inside the chat room). (not inside the chat room).
</em></small> </em></small>
{{.CurrentUser.GetProfileField "dm_privacy"}}
</div> </div>
<label class="checkbox"> <label class="checkbox mt-3">
<input type="radio" <input type="radio"
name="dm_privacy" name="dm_privacy"
value="" value=""
@ -891,24 +890,26 @@
page (except for maybe <a href="/faq#shy-faqs" target="_blank">Shy Accounts</a>). page (except for maybe <a href="/faq#shy-faqs" target="_blank">Shy Accounts</a>).
</p> </p>
<label class="checkbox"> <label class="checkbox mt-3">
<input type="radio" <input type="radio"
name="dm_privacy" name="dm_privacy"
value="friends" value="friends"
{{if eq (.CurrentUser.GetProfileField "dm_privacy") "friends"}}checked{{end}}> {{if eq (.CurrentUser.GetProfileField "dm_privacy") "friends"}}checked{{end}}>
Only people on my Friends list Only people on my Friends list
<i class="fa fa-user-group has-text-warning ml-2"></i>
</label> </label>
<p class="help"> <p class="help">
Nobody can slide into your DMs except for friends (and admins if needed). Anybody Nobody can slide into your DMs except for friends (and admins if needed). Anybody
may <em>reply</em> to messages that you send to them. may <em>reply</em> to messages that you send to them.
</p> </p>
<label class="checkbox"> <label class="checkbox mt-3">
<input type="radio" <input type="radio"
name="dm_privacy" name="dm_privacy"
value="nobody" value="nobody"
{{if eq (.CurrentUser.GetProfileField "dm_privacy") "nobody"}}checked{{end}}> {{if eq (.CurrentUser.GetProfileField "dm_privacy") "nobody"}}checked{{end}}>
Nobody (close my DMs) Nobody (close my DMs)
<i class="fa fa-hand has-text-danger ml-2"></i>
</label> </label>
<p class="help"> <p class="help">
Nobody can start a Direct Message conversation with you on the main website Nobody can start a Direct Message conversation with you on the main website
@ -919,6 +920,77 @@
<hr> <hr>
<div class="field">
<label class="label mb-0">
Who can share their
<span class="has-text-private">
<i class="fa fa-eye"></i> Private Photos
</span>
with me?
<span class="tag is-success">New!</span>
</label>
<p class="help">
This setting can help you to be in control of who else on {{PrettyTitle}} is allowed
to unlock their private photo gallery for you.
</p>
<label class="checkbox mt-3">
<input type="radio"
name="private_photo_gate"
value=""
{{if eq (.CurrentUser.GetProfileField "private_photo_gate") ""}}checked{{end}}>
Anybody on the site
</label>
<p class="help">
Any member of the website is able to share their private photo gallery with you.
</p>
<label class="checkbox mt-3">
<input type="radio"
name="private_photo_gate"
value="friends"
{{if eq (.CurrentUser.GetProfileField "private_photo_gate") "friends"}}checked{{end}}>
Only people on my Friends list
<i class="fa fa-user-group has-text-warning ml-2"></i>
</label>
<p class="help">
Only people who you have accepted as a friend will have the ability to share their private
photo gallery with you.
</p>
<label class="checkbox mt-3">
<input type="radio"
name="private_photo_gate"
value="messaged"
{{if eq (.CurrentUser.GetProfileField "private_photo_gate") "messaged"}}checked{{end}}>
Only my friends and people I have sent a DM to
<i class="fa fa-user-group has-text-warning ml-2"></i>
<i class="fa fa-envelope has-text-link"></i>
</label>
<p class="help">
People on your friend list and people who <strong>you</strong> have sent a Direct Message to
(on the main website - not the chat room) will be able to share their private photos with you.
Note: for example, if somebody sends <em>you</em> an unsolicited DM and you did not respond,
that person can not share their private photos with you.
</p>
<label class="checkbox mt-3">
<input type="radio"
name="private_photo_gate"
value="nobody"
{{if eq (.CurrentUser.GetProfileField "private_photo_gate") "nobody"}}checked{{end}}>
Nobody <i class="fa fa-hand has-text-danger ml-2"></i>
</label>
<p class="help">
Nobody on the website will be allowed to share their private gallery with you. Note: this
will mean that you will have no method to see private photos on the site except those which
had already been shared with you in the past.
</p>
</div>
<hr>
<div class="field"> <div class="field">
<button type="submit" class="button is-primary"> <button type="submit" class="button is-primary">
<i class="fa fa-save mr-2"></i> Save Privacy Settings <i class="fa fa-save mr-2"></i> Save Privacy Settings

View File

@ -471,18 +471,21 @@
</a> </a>
</div> </div>
{{else if not .IsSiteGallery}} {{else if not .IsSiteGallery}}
<div class="block"> <!-- Private photo unlock/status prompt for this other user. -->
{{if not .IsMyPrivateUnlockedFor}} {{if .IsMyPrivateUnlockedFor}}
<a href="/photo/private/share?to={{.User.Username}}" class="has-text-private"> <div class="block">
<span class="icon"><i class="fa fa-unlock"></i></span> <span class="icon"><i class="fa fa-unlock has-text-private"></i></span>
<span>Grant <strong>{{.User.Username}}</strong> access to see <strong>my</strong> private photos</span> <span>You had granted <strong>{{.User.Username}}</strong> access to see <strong>your</strong> private photos.</span>
</a> <a href="/photo/private">Manage that here.</a>
{{else}} </div>
<span class="icon"><i class="fa fa-unlock has-text-private"></i></span> {{else if .ShowPrivateUnlockPrompt}}
<span>You had granted <strong>{{.User.Username}}</strong> access to see <strong>your</strong> private photos.</span> <div class="block">
<a href="/photo/private">Manage that here.</a> <a href="/photo/private/share?to={{.User.Username}}" class="has-text-private">
<span class="icon"><i class="fa fa-unlock"></i></span>
<span>Grant <strong>{{.User.Username}}</strong> access to see <strong>my</strong> private photos</span>
</a>
</div>
{{end}} {{end}}
</div>
{{end}} {{end}}
{{if .AreWeGrantedPrivate}} {{if .AreWeGrantedPrivate}}

View File

@ -50,6 +50,21 @@
(page {{.Pager.Page}} of {{.Pager.Pages}}). (page {{.Pager.Page}} of {{.Pager.Pages}}).
</div> </div>
<!-- On the Shared With Me page, let the user know about the new privacy option. -->
{{if .IsGrantee}}
<div class="block has-text-smaller">
<strong class="has-text-success">
<i class="fa fa-info-circle"></i>
Pro Tip:
</strong>
If you receive a lot of unsolicited private photo shares from people and you wish you could do something
about that, check out the <a href="/settings#privacy">Privacy Settings</a> for an option to limit who is allowed
to share their private gallery with you. For example, you can limit it to Friends only or to people who
<em>you</em> had previously sent a DM to.
</div>
{{end}}
{{if not .IsGrantee}} {{if not .IsGrantee}}
<div class="columns is-gapless is-centered"> <div class="columns is-gapless is-centered">
<div class="column is-narrow mx-1 my-2"> <div class="column is-narrow mx-1 my-2">
@ -86,27 +101,66 @@
<div class="media block"> <div class="media block">
<div class="media-left"> <div class="media-left">
{{template "avatar-64x64" .}} {{template "avatar-64x64" .}}
<!-- Friendship badge -->
{{if $Root.FriendMap.Get .ID}}
<div>
<span class="is-size-7 has-text-warning">
<i class="fa fa-user-group" title="Friends"></i>
Friends
</span>
</div>
{{end}}
<!-- Liked badge -->
{{$LikedStats := $Root.LikedMap.Get .ID}}
{{if $LikedStats.UserLikes}}
<div>
<span class="is-size-7">
<i class="fa fa-heart has-text-danger" title="Friends"></i>
Liked
</span>
</div>
{{end}}
</div> </div>
<div class="media-content"> <div class="media-content">
<p class="title is-4"> <p class="title is-4">
<a href="/u/{{.Username}}" class="has-text-dark">{{.NameOrUsername}}</a> <a href="/u/{{.Username}}" class="has-text-dark">
{{.NameOrUsername}}
</a>
{{if eq .Visibility "private"}}
<sup class="fa fa-mask is-size-7" title="Private Profile"></sup>
{{end}}
</p> </p>
<p class="subtitle is-6 mb-1"> <p class="subtitle is-6 mb-1">
<span class="icon"><i class="fa fa-user"></i></span> <span class="icon"><i class="fa fa-user"></i></span>
<a href="/u/{{.Username}}">{{.Username}}</a> <a href="/u/{{.Username}}">{{.Username}}</a>
<!-- Not Certified or Shy Account badge -->
{{if not .Certified}} {{if not .Certified}}
<span class="has-text-danger"> <span class="has-text-danger is-size-7">
<span class="icon"><i class="fa fa-certificate"></i></span> <i class="fa fa-certificate"></i>
<span>Not Certified!</span> <span>Not Certified!</span>
</span> </span>
{{else if $Root.ShyMap.Get .ID}}
<span class="has-text-danger is-size-7">
<i class="fa fa-ghost"></i>
<span>Shy Account</span>
</span>
{{end}} {{end}}
{{if .IsAdmin}} {{if .IsAdmin}}
<span class="has-text-danger"> <span class="tag is-danger is-light p-1" style="font-size: x-small">
<span class="icon"><i class="fa fa-peace"></i></span> <i class="fa fa-peace mr-1"></i>
<span>Admin</span> <span>Admin</span>
</span> </span>
{{end}} {{end}}
<!-- Photo count pulled to the right -->
<a href="/u/{{.Username}}/photos?visibility=private" class="tag is-private is-light is-pulled-right">
<i class="fa fa-camera mr-2"></i>
{{$Root.PhotoCountMap.Get .ID}}
</a>
</p> </p>
<!-- Indicator if they are sharing back --> <!-- Indicator if they are sharing back -->
@ -144,7 +198,17 @@
</div> </div>
</div> </div>
</div> </div>
{{if not $Root.IsGrantee}}
<!-- Card Footers -->
{{if $Root.IsGrantee}}
<footer class="card-footer">
<button type="submit" name="intent" value="decline" class="card-footer-item button is-danger is-outlined"
onclick="return confirm('Do you want to decline access to this person\'s private photos? Doing so will remove them from your Shared With Me list and you will no longer see their private photos unless they share with you again in the future.')">
<span class="icon"><i class="fa fa-thumbs-down"></i></span>
<span>Decline</span>
</button>
</footer>
{{else}}
<footer class="card-footer"> <footer class="card-footer">
<button type="submit" name="intent" value="revoke" class="card-footer-item button is-danger is-outlined" <button type="submit" name="intent" value="revoke" class="card-footer-item button is-danger is-outlined"
onclick="return confirm('Are you sure you want to revoke private photo access to this user?')"> onclick="return confirm('Are you sure you want to revoke private photo access to this user?')">