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 (
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)

View File

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

View File

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

View File

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

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.
// 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
}

View File

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

View File

@ -835,7 +835,7 @@
logged-out browser, they are prompted to log in.
</p>
<label class="checkbox mt-2">
<label class="checkbox mt-3">
<input type="radio"
name="visibility"
value="external"
@ -850,7 +850,7 @@
<a href="/u/{{.CurrentUser.Username}}?view=external" target="_blank">Preview <i class="fa fa-external-link"></i></a>
</p>
<label class="checkbox mt-2">
<label class="checkbox mt-3">
<input type="radio"
name="visibility"
value="private"
@ -871,15 +871,14 @@
<div class="field">
<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>
Note: this refers to Direct Messages on the main website
(not inside the chat room).
</em></small>
{{.CurrentUser.GetProfileField "dm_privacy"}}
</div>
<label class="checkbox">
<label class="checkbox mt-3">
<input type="radio"
name="dm_privacy"
value=""
@ -891,24 +890,26 @@
page (except for maybe <a href="/faq#shy-faqs" target="_blank">Shy Accounts</a>).
</p>
<label class="checkbox">
<label class="checkbox mt-3">
<input type="radio"
name="dm_privacy"
value="friends"
{{if eq (.CurrentUser.GetProfileField "dm_privacy") "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">
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.
</p>
<label class="checkbox">
<label class="checkbox mt-3">
<input type="radio"
name="dm_privacy"
value="nobody"
{{if eq (.CurrentUser.GetProfileField "dm_privacy") "nobody"}}checked{{end}}>
Nobody (close my DMs)
<i class="fa fa-hand has-text-danger ml-2"></i>
</label>
<p class="help">
Nobody can start a Direct Message conversation with you on the main website
@ -919,6 +920,77 @@
<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">
<button type="submit" class="button is-primary">
<i class="fa fa-save mr-2"></i> Save Privacy Settings

View File

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

View File

@ -50,6 +50,21 @@
(page {{.Pager.Page}} of {{.Pager.Pages}}).
</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}}
<div class="columns is-gapless is-centered">
<div class="column is-narrow mx-1 my-2">
@ -86,27 +101,66 @@
<div class="media block">
<div class="media-left">
{{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 class="media-content">
<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 class="subtitle is-6 mb-1">
<span class="icon"><i class="fa fa-user"></i></span>
<a href="/u/{{.Username}}">{{.Username}}</a>
<!-- Not Certified or Shy Account badge -->
{{if not .Certified}}
<span class="has-text-danger">
<span class="icon"><i class="fa fa-certificate"></i></span>
<span class="has-text-danger is-size-7">
<i class="fa fa-certificate"></i>
<span>Not Certified!</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}}
{{if .IsAdmin}}
<span class="has-text-danger">
<span class="icon"><i class="fa fa-peace"></i></span>
<span class="tag is-danger is-light p-1" style="font-size: x-small">
<i class="fa fa-peace mr-1"></i>
<span>Admin</span>
</span>
{{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>
<!-- Indicator if they are sharing back -->
@ -144,7 +198,17 @@
</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">
<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?')">