More Private User Avatars

* Users who set their Profile Picture to "friends only" or "private" can have
  their avatar be private all over the website to users who are not their
  friends or not granted access.
* Users who are not your friends see a yellow placeholder avatar, and users
  not granted access to a private Profile Pic sees a purple avatar.
* Admin users see these same placeholder avatars most places too (on search,
  forums, comments, etc.) if the user did not friend or grant the admin. But
  admins ALWAYS see it on their Profile Page directly, for ability to moderate.
* Fix marking Notifications as read: clicking the link in an unread notification
  now will wait on the ajax request to finish before allowing the redirect.
* Update the FAQ
This commit is contained in:
Noah 2022-09-08 21:42:20 -07:00
parent 0fe538fd87
commit 6c91c67c97
36 changed files with 429 additions and 183 deletions

View File

@ -65,6 +65,16 @@ func Profile() http.HandlerFunc {
return return
} }
// Inject relationship booleans for profile picture display.
models.SetUserRelationships(currentUser, []*models.User{user})
// Admin user can always see the profile pic - but only on this page. Other avatar displays
// will show the yellow or pink shy.png if the admin is not friends or not granted.
if currentUser.IsAdmin {
user.UserRelationship.IsFriend = true
user.UserRelationship.IsPrivateGranted = true
}
var isSelf = currentUser.ID == user.ID var isSelf = currentUser.ID == user.ID
// Banned or disabled? Only admin can view then. // Banned or disabled? Only admin can view then.

View File

@ -72,7 +72,7 @@ func Search() http.HandlerFunc {
} }
pager.ParsePage(r) pager.ParsePage(r)
users, err := models.SearchUsers(currentUser.ID, &models.UserSearch{ users, err := models.SearchUsers(currentUser, &models.UserSearch{
EmailOrUsername: username, EmailOrUsername: username,
Gender: gender, Gender: gender,
Orientation: orientation, Orientation: orientation,

View File

@ -156,7 +156,7 @@ func Feedback() http.HandlerFunc {
userIDs = append(userIDs, p.UserID) userIDs = append(userIDs, p.UserID)
} }
} }
userMap, err := models.MapUsers(userIDs) userMap, err := models.MapUsers(currentUser, userIDs)
if err != nil { if err != nil {
session.FlashError(w, r, "Couldn't map user IDs: %s", err) session.FlashError(w, r, "Couldn't map user IDs: %s", err)
} }

View File

@ -27,7 +27,7 @@ func Blocked() http.HandlerFunc {
Sort: "updated_at desc", Sort: "updated_at desc",
} }
pager.ParsePage(r) pager.ParsePage(r)
blocked, err := models.PaginateBlockList(currentUser.ID, pager) blocked, err := models.PaginateBlockList(currentUser, pager)
if err != nil { if err != nil {
session.FlashError(w, r, "Couldn't paginate block list: %s", err) session.FlashError(w, r, "Couldn't paginate block list: %s", err)
templates.Redirect(w, "/") templates.Redirect(w, "/")

View File

@ -32,13 +32,16 @@ func Friends() http.HandlerFunc {
Sort: "updated_at desc", Sort: "updated_at desc",
} }
pager.ParsePage(r) pager.ParsePage(r)
friends, err := models.PaginateFriends(currentUser.ID, isRequests, isPending, pager) friends, err := models.PaginateFriends(currentUser, isRequests, isPending, pager)
if err != nil { if err != nil {
session.FlashError(w, r, "Couldn't paginate friends: %s", err) session.FlashError(w, r, "Couldn't paginate friends: %s", err)
templates.Redirect(w, "/") templates.Redirect(w, "/")
return return
} }
// Inject relationship booleans.
models.SetUserRelationships(currentUser, friends)
var vars = map[string]interface{}{ var vars = map[string]interface{}{
"IsRequests": isRequests, "IsRequests": isRequests,
"IsPending": isPending, "IsPending": isPending,

View File

@ -116,7 +116,7 @@ func Inbox() http.HandlerFunc {
userIDs = append(userIDs, m.SourceUserID, m.TargetUserID) userIDs = append(userIDs, m.SourceUserID, m.TargetUserID)
} }
} }
userMap, err := models.MapUsers(userIDs) userMap, err := models.MapUsers(currentUser, userIDs)
if err != nil { if err != nil {
session.FlashError(w, r, "Couldn't map users: %s", err) session.FlashError(w, r, "Couldn't map users: %s", err)
} }

View File

@ -174,6 +174,12 @@ func AdminCertification() http.HandlerFunc {
view = "pending" view = "pending"
} }
// Get the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Couldn't get CurrentUser: %s", err)
}
// Short circuit the GET view for username/email search (exact match) // Short circuit the GET view for username/email search (exact match)
if username := r.FormValue("username"); username != "" { if username := r.FormValue("username"); username != "" {
user, err := models.FindUser(username) user, err := models.FindUser(username)
@ -341,7 +347,7 @@ func AdminCertification() http.HandlerFunc {
for _, p := range photos { for _, p := range photos {
userIDs = append(userIDs, p.UserID) userIDs = append(userIDs, p.UserID)
} }
userMap, err := models.MapUsers(userIDs) userMap, err := models.MapUsers(currentUser, userIDs)
if err != nil { if err != nil {
session.FlashError(w, r, "Couldn't map user IDs: %s", err) session.FlashError(w, r, "Couldn't map user IDs: %s", err)
} }

View File

@ -35,7 +35,7 @@ func Private() http.HandlerFunc {
Sort: "updated_at desc", Sort: "updated_at desc",
} }
pager.ParsePage(r) pager.ParsePage(r)
users, err := models.PaginatePrivatePhotoList(currentUser.ID, isGrantee, pager) users, err := models.PaginatePrivatePhotoList(currentUser, isGrantee, pager)
if err != nil { if err != nil {
session.FlashError(w, r, "Couldn't paginate users: %s", err) session.FlashError(w, r, "Couldn't paginate users: %s", err)
templates.Redirect(w, "/") templates.Redirect(w, "/")

View File

@ -67,7 +67,7 @@ func SiteGallery() http.HandlerFunc {
for _, photo := range photos { for _, photo := range photos {
userIDs = append(userIDs, photo.UserID) userIDs = append(userIDs, photo.UserID)
} }
userMap, err := models.MapUsers(userIDs) userMap, err := models.MapUsers(currentUser, userIDs)
if err != nil { if err != nil {
session.FlashError(w, r, "Failed to MapUsers: %s", err) session.FlashError(w, r, "Failed to MapUsers: %s", err)
} }

View File

@ -63,7 +63,7 @@ func IsBlocked(sourceUserID, targetUserID uint64) bool {
} }
// PaginateBlockList views a user's blocklist. // PaginateBlockList views a user's blocklist.
func PaginateBlockList(userID uint64, pager *Pagination) ([]*User, error) { func PaginateBlockList(user *User, pager *Pagination) ([]*User, error) {
// We paginate over the Block table. // We paginate over the Block table.
var ( var (
bs = []*Block{} bs = []*Block{}
@ -73,7 +73,7 @@ func PaginateBlockList(userID uint64, pager *Pagination) ([]*User, error) {
query = DB.Where( query = DB.Where(
"source_user_id = ?", "source_user_id = ?",
userID, user.ID,
) )
query = query.Order(pager.Sort) query = query.Order(pager.Sort)
@ -88,7 +88,7 @@ func PaginateBlockList(userID uint64, pager *Pagination) ([]*User, error) {
userIDs = append(userIDs, b.TargetUserID) userIDs = append(userIDs, b.TargetUserID)
} }
return GetUsers(userIDs) return GetUsers(user, userIDs)
} }
// BlockedUserIDs returns all user IDs blocked by the user. // BlockedUserIDs returns all user IDs blocked by the user.

View File

@ -79,6 +79,10 @@ func PaginateComments(user *User, tableName string, tableID uint64, pager *Pagin
query.Model(&Comment{}).Count(&pager.Total) query.Model(&Comment{}).Count(&pager.Total)
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&cs) result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&cs)
// Inject user relationships into these comments' authors.
SetUserRelationshipsInComments(user, cs)
return cs, result.Error return cs, result.Error
} }

View File

@ -131,14 +131,22 @@ func PaginateRecentPosts(user *User, categories []string, pager *Pagination) ([]
forums, _ = GetForums(forumIDs) forums, _ = GetForums(forumIDs)
} }
// Collect comments so we can inject UserRelationships in efficiently.
var (
coms = []*Comment{}
thrs = []*Thread{}
)
// Merge all the objects back in. // Merge all the objects back in.
for _, rc := range result { for _, rc := range result {
if com, ok := comments[rc.CommentID]; ok { if com, ok := comments[rc.CommentID]; ok {
rc.Comment = com rc.Comment = com
coms = append(coms, com)
} }
if thr, ok := threads[rc.ThreadID]; ok { if thr, ok := threads[rc.ThreadID]; ok {
rc.Thread = thr rc.Thread = thr
thrs = append(thrs, thr)
} else { } else {
log.Error("RecentPosts: didn't find thread ID %d in map!") log.Error("RecentPosts: didn't find thread ID %d in map!")
} }
@ -148,5 +156,9 @@ func PaginateRecentPosts(user *User, categories []string, pager *Pagination) ([]
} }
} }
// Inject user relationships into all comment users now.
SetUserRelationshipsInComments(user, coms)
SetUserRelationshipsInThreads(user, thrs)
return result, nil return result, nil
} }

View File

@ -123,7 +123,7 @@ The `requests` and `sent` bools are mutually exclusive (use only one, or neither
asks for unanswered friend requests to you, and `sent` returns the friend requests that you asks for unanswered friend requests to you, and `sent` returns the friend requests that you
have sent and have not been answered. have sent and have not been answered.
*/ */
func PaginateFriends(userID uint64, requests bool, sent bool, pager *Pagination) ([]*User, error) { func PaginateFriends(user *User, requests bool, sent bool, pager *Pagination) ([]*User, error) {
// We paginate over the Friend table. // We paginate over the Friend table.
var ( var (
fs = []*Friend{} fs = []*Friend{}
@ -138,17 +138,17 @@ func PaginateFriends(userID uint64, requests bool, sent bool, pager *Pagination)
if requests { if requests {
query = DB.Where( query = DB.Where(
"target_user_id = ? AND approved = ?", "target_user_id = ? AND approved = ?",
userID, false, user.ID, false,
) )
} else if sent { } else if sent {
query = DB.Where( query = DB.Where(
"source_user_id = ? AND approved = ?", "source_user_id = ? AND approved = ?",
userID, false, user.ID, false,
) )
} else { } else {
query = DB.Where( query = DB.Where(
"source_user_id = ? AND approved = ?", "source_user_id = ? AND approved = ?",
userID, true, user.ID, true,
) )
} }
@ -168,7 +168,7 @@ func PaginateFriends(userID uint64, requests bool, sent bool, pager *Pagination)
} }
} }
return GetUsers(userIDs) return GetUsers(user, userIDs)
} }
// GetFriendRequests returns all pending friend requests for a user. // GetFriendRequests returns all pending friend requests for a user.

View File

@ -96,7 +96,7 @@ If grantee is true, it returns the list of users who have granted YOU access to
private photos. If grantee is false, it returns the users that YOU have granted access to private photos. If grantee is false, it returns the users that YOU have granted access to
see YOUR OWN private photos. see YOUR OWN private photos.
*/ */
func PaginatePrivatePhotoList(userID uint64, grantee bool, pager *Pagination) ([]*User, error) { func PaginatePrivatePhotoList(user *User, grantee bool, pager *Pagination) ([]*User, error) {
var ( var (
pbs = []*PrivatePhoto{} pbs = []*PrivatePhoto{}
userIDs = []uint64{} userIDs = []uint64{}
@ -109,11 +109,11 @@ func PaginatePrivatePhotoList(userID uint64, grantee bool, pager *Pagination) ([
if grantee { if grantee {
// Return the private photo grants for whom YOU are the recipient. // Return the private photo grants for whom YOU are the recipient.
wheres = append(wheres, "target_user_id = ?") wheres = append(wheres, "target_user_id = ?")
placeholders = append(placeholders, userID) placeholders = append(placeholders, user.ID)
} else { } else {
// Return the users that YOU have granted access to YOUR private pictures. // Return the users that YOU have granted access to YOUR private pictures.
wheres = append(wheres, "source_user_id = ?") wheres = append(wheres, "source_user_id = ?")
placeholders = append(placeholders, userID) placeholders = append(placeholders, user.ID)
} }
query = DB.Where( query = DB.Where(
@ -137,7 +137,7 @@ func PaginatePrivatePhotoList(userID uint64, grantee bool, pager *Pagination) ([
} }
} }
return GetUsers(userIDs) return GetUsers(user, userIDs)
} }
// Save photo. // Save photo.

View File

@ -151,6 +151,10 @@ func PaginateThreads(user *User, forum *Forum, pager *Pagination) ([]*Thread, er
query.Model(&Thread{}).Count(&pager.Total) query.Model(&Thread{}).Count(&pager.Total)
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&ts) result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&ts)
// Inject user relationships into these threads' comments' users.
SetUserRelationshipsInThreads(user, ts)
return ts, result.Error return ts, result.Error
} }

View File

@ -34,6 +34,9 @@ type User struct {
ProfileField []ProfileField ProfileField []ProfileField
ProfilePhotoID *uint64 ProfilePhotoID *uint64
ProfilePhoto Photo `gorm:"foreignKey:profile_photo_id"` ProfilePhoto Photo `gorm:"foreignKey:profile_photo_id"`
// Current user's relationship to this user -- not stored in DB.
UserRelationship UserRelationship `gorm:"-"`
} }
type UserVisibility string type UserVisibility string
@ -97,8 +100,8 @@ func GetUser(userId uint64) (*User, error) {
} }
// GetUsers queries for multiple user IDs and returns users in the same order. // GetUsers queries for multiple user IDs and returns users in the same order.
func GetUsers(userIDs []uint64) ([]*User, error) { func GetUsers(currentUser *User, userIDs []uint64) ([]*User, error) {
userMap, err := MapUsers(userIDs) userMap, err := MapUsers(currentUser, userIDs)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -141,7 +144,7 @@ type UserSearch struct {
} }
// SearchUsers from the perspective of a given user. // SearchUsers from the perspective of a given user.
func SearchUsers(userID uint64, search *UserSearch, pager *Pagination) ([]*User, error) { func SearchUsers(user *User, search *UserSearch, pager *Pagination) ([]*User, error) {
if search == nil { if search == nil {
search = &UserSearch{} search = &UserSearch{}
} }
@ -151,7 +154,7 @@ func SearchUsers(userID uint64, search *UserSearch, pager *Pagination) ([]*User,
query *gorm.DB query *gorm.DB
wheres = []string{} wheres = []string{}
placeholders = []interface{}{} placeholders = []interface{}{}
blockedUserIDs = BlockedUserIDs(userID) blockedUserIDs = BlockedUserIDs(user.ID)
) )
if len(blockedUserIDs) > 0 { if len(blockedUserIDs) > 0 {
@ -218,6 +221,10 @@ func SearchUsers(userID uint64, search *UserSearch, pager *Pagination) ([]*User,
).Order(pager.Sort) ).Order(pager.Sort)
query.Model(&User{}).Count(&pager.Total) query.Model(&User{}).Count(&pager.Total)
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&users) result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&users)
// Inject relationship booleans.
SetUserRelationships(user, users)
return users, result.Error return users, result.Error
} }
@ -227,7 +234,7 @@ type UserMap map[uint64]*User
// MapUsers looks up a set of user IDs in bulk and returns a UserMap suitable for templates. // MapUsers looks up a set of user IDs in bulk and returns a UserMap suitable for templates.
// Useful to avoid circular reference issues with Photos especially; the Site Gallery queries // Useful to avoid circular reference issues with Photos especially; the Site Gallery queries
// photos of ALL users and MapUsers helps stitch them together for the frontend. // photos of ALL users and MapUsers helps stitch them together for the frontend.
func MapUsers(userIDs []uint64) (UserMap, error) { func MapUsers(user *User, userIDs []uint64) (UserMap, error) {
var ( var (
usermap = UserMap{} usermap = UserMap{}
set = map[uint64]interface{}{} set = map[uint64]interface{}{}
@ -248,6 +255,11 @@ func MapUsers(userIDs []uint64) (UserMap, error) {
result = (&User{}).Preload().Where("id IN ?", distinct).Find(&users) result = (&User{}).Preload().Where("id IN ?", distinct).Find(&users)
) )
// Inject user relationships.
if user != nil {
SetUserRelationships(user, users)
}
if result.Error == nil { if result.Error == nil {
for _, row := range users { for _, row := range users {
usermap[row.ID] = row usermap[row.ID] = row

View File

@ -0,0 +1,87 @@
package models
// UserRelationship fields - how a target User relates to the CurrentUser, especially
// with regards to whether the User's friends-only or private profile picture should show.
// The zero-values should fail safely: in case the UserRelationship isn't populated correctly,
// private profile pics show as private by default.
type UserRelationship struct {
IsFriend bool // if true, a friends-only profile pic can show
IsPrivateGranted bool // if true, a private profile pic can show
}
// SetUserRelationships updates a set of User objects to populate their UserRelationships in
// relationship to the current user who is looking.
func SetUserRelationships(user *User, users []*User) error {
// Collect the current user's Friendships and Private Grants.
var (
friendIDs = FriendIDs(user.ID)
privateGrants = PrivateGrantedUserIDs(user.ID)
)
// Map them for easier lookup.
var (
friendMap = map[uint64]interface{}{}
privateMap = map[uint64]interface{}{}
)
for _, id := range friendIDs {
friendMap[id] = nil
}
for _, id := range privateGrants {
privateMap[id] = nil
}
// Inject the UserRelationships.
for _, u := range users {
if u.ID == user.ID {
// Current user - set both bools to true - you can always see your own profile pic.
u.UserRelationship.IsFriend = true
u.UserRelationship.IsPrivateGranted = true
continue
}
if _, ok := friendMap[u.ID]; ok {
u.UserRelationship.IsFriend = true
}
if _, ok := privateMap[u.ID]; ok {
u.UserRelationship.IsPrivateGranted = true
}
}
return nil
}
// SetUserRelationshipsInComments takes a set of Comments and sets relationship booleans on their Users.
func SetUserRelationshipsInComments(user *User, comments []*Comment) {
// Gather and map the users.
var (
users = []*User{}
userMap = map[uint64]*User{}
)
for _, c := range comments {
users = append(users, &c.User)
userMap[c.User.ID] = &c.User
}
// Inject relationships.
SetUserRelationships(user, users)
}
// SetUserRelationshipsInThreads takes a set of Threads and sets relationship booleans on their Users.
func SetUserRelationshipsInThreads(user *User, threads []*Thread) {
// Gather and map the thread parent comments.
var (
comments = []*Comment{}
comMap = map[uint64]*Comment{}
)
for _, c := range threads {
comments = append(comments, &c.Comment)
comMap[c.Comment.ID] = &c.Comment
}
// Inject relationships into those comments' users.
SetUserRelationshipsInComments(user, comments)
}

View File

@ -30,7 +30,7 @@ func LoadTemplate(filename string) (*Template, error) {
filepath := config.TemplatePath + "/" + filename filepath := config.TemplatePath + "/" + filename
stat, err := os.Stat(filepath) stat, err := os.Stat(filepath)
if err != nil { if err != nil {
return nil, fmt.Errorf("LoadTemplate(%s): %s", err) return nil, fmt.Errorf("LoadTemplate(%s): %s", filename, err)
} }
files := templates(config.TemplatePath + "/" + filename) files := templates(config.TemplatePath + "/" + filename)
@ -118,6 +118,7 @@ func (t *Template) Reload() error {
// Base template layout. // Base template layout.
var baseTemplates = []string{ var baseTemplates = []string{
config.TemplatePath + "/base.html", config.TemplatePath + "/base.html",
config.TemplatePath + "/partials/user_avatar.html",
} }
// templates returns a template chain with the base templates preceding yours. // templates returns a template chain with the base templates preceding yours.

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

View File

@ -51,13 +51,7 @@
<div class="card-content"> <div class="card-content">
<div class="media block"> <div class="media block">
<div class="media-left"> <div class="media-left">
<figure class="image is-64x64"> {{template "avatar-64x64" .}}
{{if .ProfilePhoto.ID}}
<img src="{{PhotoURL .ProfilePhoto.CroppedFilename}}">
{{else}}
<img src="/static/img/shy.png">
{{end}}
</figure>
</div> </div>
<div class="media-content"> <div class="media-content">
<p class="title is-4">{{.NameOrUsername}}</p> <p class="title is-4">{{.NameOrUsername}}</p>

View File

@ -184,13 +184,7 @@
</div> </div>
{{end}} {{end}}
<a href="/u/{{.AboutUser.Username}}"> <a href="/u/{{.AboutUser.Username}}">
<figure class="image is-48x48 is-inline-block"> {{template "avatar-48x48" .AboutUser}}
{{if .AboutUser.ProfilePhoto.ID}}
<img src="{{PhotoURL .AboutUser.ProfilePhoto.CroppedFilename}}">
{{else}}
<img src="/static/img/shy.png">
{{end}}
</figure>
</a> </a>
</div> </div>
<div class="column"> <div class="column">
@ -355,13 +349,33 @@ document.addEventListener('DOMContentLoaded', () => {
// Bind to the notification table rows. // Bind to the notification table rows.
(document.querySelectorAll(".nonshy-notification-row") || []).forEach(node => { (document.querySelectorAll(".nonshy-notification-row") || []).forEach(node => {
node.addEventListener("click", (e) => {
if (busy) return;
let $newBadge = node.querySelector(".nonshy-notification-new"), let $newBadge = node.querySelector(".nonshy-notification-new"),
ID = node.dataset.notificationId; ID = node.dataset.notificationId;
// If the notification doesn't have a "NEW!" badge, no action needed.
if ($newBadge === null) return;
// Collect any hyperlinks in this row.
let links = Array.from(node.querySelectorAll("a"));
links.push(node);
// Apply a "click" handler to the notification row as a whole, and to all of the hyperlinks in it.
// For the hyperlinks: prevent the browser following the link UNTIL the successful ajax request to
// mark the notification "read" has run.
links.forEach(link => {
link.addEventListener("click", (e) => {
if (busy) return;
// In case it's a hyperlink, grab the href.
let href = link.attributes.href;
if (href !== undefined) {
e.preventDefault();
href = href.textContent;
}
$newBadge.style.display = "none"; $newBadge.style.display = "none";
busy = true;
return fetch("/v1/notifications/read", { return fetch("/v1/notifications/read", {
method: "POST", method: "POST",
mode: "same-origin", mode: "same-origin",
@ -381,8 +395,12 @@ document.addEventListener('DOMContentLoaded', () => {
window.alert(resp); window.alert(resp);
}).finally(() => { }).finally(() => {
busy = false; busy = false;
}) if (href !== undefined) {
window.location.href = href;
}
}); });
})
})
}); });
}); });
</script> </script>

View File

@ -8,9 +8,15 @@
<div class="column is-narrow has-text-centered"> <div class="column is-narrow has-text-centered">
<figure class="profile-photo is-inline-block"> <figure class="profile-photo is-inline-block">
{{if .User.ProfilePhoto.ID}} {{if .User.ProfilePhoto.ID}}
<img src="/static/photos/{{.User.ProfilePhoto.CroppedFilename}}" data-photo-id="{{.User.ProfilePhoto.ID}}"> {{if and (eq .User.ProfilePhoto.Visibility "private") (not .User.UserRelationship.IsPrivateGranted)}}
<img src="/static/img/shy-private.png" data-photo-id="{{.User.ProfilePhoto.ID}}">
{{else if and (eq .User.ProfilePhoto.Visibility "friends") (not .User.UserRelationship.IsFriend)}}
<img src="/static/img/shy-friends.png" data-photo-id="{{.User.ProfilePhoto.ID}}">
{{else}} {{else}}
<img class="is-rounded" src="/static/img/shy.png"> <img src="{{PhotoURL .User.ProfilePhoto.CroppedFilename}}" data-photo-id="{{.User.ProfilePhoto.ID}}">
{{end}}
{{else}}
<img src="/static/img/shy.png">
{{end}} {{end}}
<!-- CurrentUser can upload a new profile pic --> <!-- CurrentUser can upload a new profile pic -->

View File

@ -176,15 +176,7 @@
<div class="card-content"> <div class="card-content">
<div class="media block"> <div class="media block">
<div class="media-left"> <div class="media-left">
<figure class="image is-64x64"> {{template "avatar-64x64" .}}
<a href="/u/{{.Username}}" class="has-text-dark">
{{if .ProfilePhoto.ID}}
<img src="{{PhotoURL .ProfilePhoto.CroppedFilename}}">
{{else}}
<img src="/static/img/shy.png">
{{end}}
</a>
</figure>
</div> </div>
<div class="media-content"> <div class="media-content">
<p class="title is-4"> <p class="title is-4">

View File

@ -38,13 +38,7 @@
<div class="media block"> <div class="media block">
<div class="media-left"> <div class="media-left">
<figure class="image is-64x64"> {{template "avatar-64x64" .}}
{{if .User.ProfilePhoto.ID}}
<img src="{{PhotoURL .User.ProfilePhoto.CroppedFilename}}">
{{else}}
<img src="/static/img/shy.png">
{{end}}
</figure>
</div> </div>
<div class="media-content"> <div class="media-content">
<p class="title is-4">{{.NameOrUsername}}</p> <p class="title is-4">{{.NameOrUsername}}</p>

View File

@ -13,9 +13,48 @@
<div class="block p-4"> <div class="block p-4">
<div class="content"> <div class="content">
<h1>General FAQs</h1> <!-- Table of Contents -->
<ul>
<li>
<a href="#certification-faqs">Certification FAQs</a>
<ul>
<li><a href="#certification">What does <strong>certification</strong> mean, and what is a <strong>"verification selfie"</strong>?</a></li>
<li><a href="#need-certification">Do I <strong>need</strong> to send a "verification selfie"?</a></li>
<li><a href="#cannot-certify">Are there <strong>alternative options</strong> to becoming Certified?</a> <strong><span class="tag is-success is-light">NEW Sept. 8 2022</span></strong></li>
<li><a href="#private-avatar">Can my <strong>Profile Picture be kept private?</strong></a> <strong><span class="tag is-success is-light">NEW Sept. 8 2022</span></strong></li>
<li><a href="#uncertified">What can non-certified members do?</a></li>
<li><a href="#profile-visibility">What are the <strong>visibility options</strong> for my profile page?</a></li>
</ul>
</li>
<li>
<a href="#photo-faqs">Photo FAQs</a>
<ul>
<li><a href="#nudes-required">Do I have to post my nudes here?</a></li>
<li><a href="#face-in-nudes">Do I have to include my face in my nudes?</a></li>
<li><a href="#site-gallery">What appears on the Site Gallery?</a></li>
<li><a href="#define-explicit">What is considered "explicit" in photos?</a></li>
</ul>
</li>
<li>
<a href="#forum-faqs">Forum FAQs</a>
<ul>
<li><a href="#forum-badges">What do the various badges on the forum mean?</a></li>
<li><a href="#create-forums">Can I create my own forums?</a></li>
<h3>What does certification mean, and what is a "verification selfie"?</h3> </ul>
</li>
<li>
<a href="#technical-faqs">Technical FAQs</a>
<ul>
<li><a href="#why">Why did you build a custom website?</a></li>
<li><a href="#open-source">Is this website open source?</a></li>
</ul>
</li>
</ul>
<h1 id="certification-faqs">Certification FAQs</h1>
<h3 id="certification">What does certification mean, and what is a "verification selfie"?</h3>
<p> <p>
This website requires all members to be "certified" or proven to be real human beings This website requires all members to be "certified" or proven to be real human beings
@ -29,7 +68,7 @@
spam robots that plague other similar sites. spam robots that plague other similar sites.
</p> </p>
<h3>Do I need to send a "verification selfie"?</h3> <h3 id="need-certification">Do I need to send a "verification selfie"?</h3>
<p> <p>
Yes. Yes.
@ -42,7 +81,67 @@
until your profile has been certified. until your profile has been certified.
</p> </p>
<h3>What can non-certified members do?</h3> <p>
Your certification photo is <em>only</em> seen by site administrators and does not appear
on your profile page.
</p>
<h3 id="cannot-certify">Are there alternative options to becoming Certified?</h3>
<p>
<strong><span class="tag is-success is-light">NEW Sept. 8 2022</span></strong>
I understand that some nudists need to exercise a degree of discretion and will
not want to take a face pic with {{PrettyTitle}}'s name and upload that <em>anywhere at all</em> onto the Internet. For
example if you are a teacher or work in law enforcement or the clergy and you need to keep
careful control of your image online because the world isn't quite enlightened enough yet
when it comes to nudity and sexuality.
</p>
<p>
These are very valid concerns and can be handled on a case-by-case basis. The
main thing I'm looking for in certification is to prove that you're a real person,
you look like your pictures here and that you're not a minor. Send a DM to
<a href="/messages/compose?to=introvertnudist">u/introvertnudist</a>
to inquire about alternative verification methods, which may include just hopping on a
quick video call with a site admin.
</p>
<p>
Note, though: this is a social site for {{PrettyTitle}} nudists so you <strong>are</strong>
expected to post at least <em>some</em> pictures of yourself on your profile page. You
could post a face pic wearing a hat and sunglasses; or you could crop out half your face
showing only from the chin down; or you could post full body nudes with your face censored
out with an emoji character.
</p>
<h3 id="private-avatar">Can my Profile Picture be kept private?</h3>
<p>
<strong><span class="tag is-success is-light">NEW Sept. 8 2022</span></strong>
You <em>may</em> set your Profile Picture to be "Friends only" or "Private" visibility
if you wish to be more discreet about your face pictures.
</p>
<ul>
<li>
<strong class="has-text-warning-dark">Friends only</strong>
<i class="fa fa-users has-text-warning-dark"></i>:
your profile pic displays as a yellow
<img src="/static/img/shy-friends.png" width="16" height="16">
placeholder image for people who are not on your <a href="/friends">Friends</a> list.
</li>
<li>
<strong class="has-text-private">Private</strong>
<i class="fa fa-lock has-text-private"></i>:
your profile pic displays as a purple
<img src="/static/img/shy-private.png" width="16" height="16">
placeholder image for everybody except for people that you had
<a href="/photo/private">granted access</a> to see your
private photos.
</li>
</ul>
<h3 id="uncertified">What can non-certified members do?</h3>
<p> <p>
Before you have an approved certification photo, you can mainly only access and edit your Before you have an approved certification photo, you can mainly only access and edit your
@ -62,7 +161,7 @@
this is intentional to help guard against spam bots and creepy people. this is intentional to help guard against spam bots and creepy people.
</p> </p>
<h3>What are the visibility options for my profile page?</h3> <h3 id="profile-visibility">What are the visibility options for my profile page?</h3>
<p> <p>
There are currently three different choices for your profile visibility on your There are currently three different choices for your profile visibility on your
@ -93,9 +192,9 @@
</li> </li>
</ul> </ul>
<h1>Photo FAQs</h1> <h1 id="photo-faqs">Photo FAQs</h1>
<h3>Do I have to post my nudes here?</h3> <h3 id="nudes-required">Do I have to post my nudes here?</h3>
<p> <p>
You must be comfortable with doing so, yes. On some other nudist social websites, many You must be comfortable with doing so, yes. On some other nudist social websites, many
@ -105,7 +204,7 @@
feel more comfortable if you post some of your own nudes as well. feel more comfortable if you post some of your own nudes as well.
</p> </p>
<h3>Do I have to include my face in my nudes?</h3> <h3 id="face-in-nudes">Do I have to include my face in my nudes?</h3>
<p> <p>
You don't have to! I know many nudists are not comfortable with their face appearing You don't have to! I know many nudists are not comfortable with their face appearing
@ -120,7 +219,7 @@
want to see just dick pics everywhere. And don't set those as your default profile pic! want to see just dick pics everywhere. And don't set those as your default profile pic!
</p> </p>
<h3>What appears on the Site Gallery?</h3> <h3 id="site-gallery">What appears on the Site Gallery?</h3>
<p> <p>
The "<strong><i class="fa fa-image"></i> Gallery</strong>" link on the site nav bar goes to the Site-wide The "<strong><i class="fa fa-image"></i> Gallery</strong>" link on the site nav bar goes to the Site-wide
@ -141,7 +240,7 @@
the Gallery -- it will then only appear on your profile page. the Gallery -- it will then only appear on your profile page.
</p> </p>
<h3>What is considered "explicit" in photos?</h3> <h3 id="define-explicit">What is considered "explicit" in photos?</h3>
<p> <p>
On this website, I make a fairly common distinction between what's a "normal nude" and On this website, I make a fairly common distinction between what's a "normal nude" and
@ -169,7 +268,7 @@
content from other users -- by default this site is "normal nudes" friendly! content from other users -- by default this site is "normal nudes" friendly!
</p> </p>
<h3>Does this site prevent people from downloading my pictures?</h3> <h3 id="downloading">Does this site prevent people from downloading my pictures?</h3>
<p> <p>
This website does not go out of its way to prevent people from downloading pictures, and This website does not go out of its way to prevent people from downloading pictures, and
@ -214,16 +313,9 @@
</li> </li>
</ul> </ul>
<p>
Note that your square cropped Default Profile Picture that appears next to your username
on various parts of this website is always visible to logged-in user accounts. The "full size"
version may be Friends-only or Private, but the square crop that you chose for your Profile
Picture is currently displayed to all logged-in users.
</p>
<p> <p>
Additionally, your Profile Page altogether can <em>only</em> be seen by logged-in members Additionally, your Profile Page altogether can <em>only</em> be seen by logged-in members
of this site by default. You <em>may</em> tighten it further and mark your entire profile of this site by default. You <em>may</em> tighten it even further and mark your entire profile
as Private (and only approved friends can see your profile or <em>any</em> of your photos). as Private (and only approved friends can see your profile or <em>any</em> of your photos).
Note that your square cropped profile picture is still visible even so. Note that your square cropped profile picture is still visible even so.
</p> </p>
@ -237,7 +329,7 @@
<h1>Forum FAQs</h1> <h1>Forum FAQs</h1>
<h3>What do the various badges on the forum mean?</h3> <h3 id="forum-badges">What do the various badges on the forum mean?</h3>
<p> <p>
You may see some of these badges on the forums or their posts. These are their meanings: You may see some of these badges on the forums or their posts. These are their meanings:
@ -282,7 +374,7 @@
</li> </li>
</ul> </ul>
<h3>Can I create my own forums?</h3> <h3 id="create-forums">Can I create my own forums?</h3>
<p> <p>
This feature is coming soon! Users will be allowed to create their own forums and This feature is coming soon! Users will be allowed to create their own forums and
@ -306,9 +398,9 @@
</li> </li>
</ul> </ul>
<h1>Technical FAQs</h1> <h1 id="technical-faqs">Technical FAQs</h1>
<h3>Why did you build a custom website?</h3> <h3 id="why">Why did you build a custom website?</h3>
<p> <p>
Other variants on this question might be: why not just run a Other variants on this question might be: why not just run a
@ -338,7 +430,7 @@
fate is kept in my own hands. fate is kept in my own hands.
</p> </p>
<h3>Is this website open source?</h3> <h3 id="open-source">Is this website open source?</h3>
<p> <p>
Yes! The source code for this website is released as free software under the GNU Yes! The source code for this website is released as free software under the GNU

View File

@ -82,13 +82,7 @@
<div class="column is-2 has-text-centered pt-0 pb-1"> <div class="column is-2 has-text-centered pt-0 pb-1">
<div> <div>
<a href="/u/{{.Comment.User.Username}}"> <a href="/u/{{.Comment.User.Username}}">
<figure class="image is-64x64 is-inline-block"> {{template "avatar-64x64" .Comment.User}}
{{if .Comment.User.ProfilePhoto.ID}}
<img src="{{PhotoURL .Comment.User.ProfilePhoto.CroppedFilename}}">
{{else}}
<img src="/static/img/shy.png">
{{end}}
</figure>
</a> </a>
</div> </div>
<a href="/u/{{.Comment.User.Username}}">{{.Comment.User.Username}}</a> <a href="/u/{{.Comment.User.Username}}">{{.Comment.User.Username}}</a>

View File

@ -66,13 +66,7 @@
<div class="columns"> <div class="columns">
<div class="column is-narrow has-text-centered pt-0 pb-1"> <div class="column is-narrow has-text-centered pt-0 pb-1">
<a href="/u/{{$User.Username}}"> <a href="/u/{{$User.Username}}">
<figure class="image is-96x96 is-inline-block"> {{template "avatar-96x96" $User}}
{{if $User.ProfilePhoto.ID}}
<img src="{{PhotoURL $User.ProfilePhoto.CroppedFilename}}">
{{else}}
<img src="/static/img/shy.png">
{{end}}
</figure>
<div> <div>
<a href="/u/{{$User.Username}}" class="is-size-7">{{$User.Username}}</a> <a href="/u/{{$User.Username}}" class="is-size-7">{{$User.Username}}</a>
</div> </div>
@ -136,13 +130,7 @@
<div class="columns is-gapless is-mobile"> <div class="columns is-gapless is-mobile">
<div class="column is-narrow mx-2"> <div class="column is-narrow mx-2">
<a href="/u/{{.Comment.User.Username}}"> <a href="/u/{{.Comment.User.Username}}">
<figure class="image is-32x32 is-inline-block"> {{template "avatar-32x32" .Comment.User}}
{{if .Comment.User.ProfilePhoto.ID}}
<img src="{{PhotoURL .Comment.User.ProfilePhoto.CroppedFilename}}" class="is-rounded">
{{else}}
<img src="/static/photos/shy.png" class="is-rounded">
{{end}}
</figure>
</a> </a>
</div> </div>
<div class="column is-narrow"> <div class="column is-narrow">

View File

@ -125,13 +125,7 @@
<div class="column is-2 has-text-centered"> <div class="column is-2 has-text-centered">
<div> <div>
<a href="/u/{{.User.Username}}"> <a href="/u/{{.User.Username}}">
<figure class="image is-96x96 is-inline-block"> {{template "avatar-96x96" .User}}
{{if .User.ProfilePhoto.ID}}
<img src="{{PhotoURL .User.ProfilePhoto.CroppedFilename}}">
{{else}}
<img src="/static/img/shy.png">
{{end}}
</figure>
</a> </a>
</div> </div>
<a href="/u/{{.User.Username}}">{{.User.Username}}</a> <a href="/u/{{.User.Username}}">{{.User.Username}}</a>

View File

@ -81,15 +81,7 @@
<div class="card-content"> <div class="card-content">
<div class="media block"> <div class="media block">
<div class="media-left"> <div class="media-left">
<figure class="image is-64x64"> {{template "avatar-64x64" .}}
<a href="/u/{{.Username}}">
{{if .ProfilePhoto.ID}}
<img src="{{PhotoURL .ProfilePhoto.CroppedFilename}}">
{{else}}
<img src="/static/img/shy.png">
{{end}}
</a>
</figure>
</div> </div>
<div class="media-content"> <div class="media-content">
<p class="title is-4"> <p class="title is-4">

View File

@ -26,13 +26,7 @@
<div class="media block"> <div class="media block">
<div class="media-left"> <div class="media-left">
<figure class="image is-64x64"> {{template "avatar-64x64" .}}
{{if .User.ProfilePhoto.ID}}
<img src="{{PhotoURL .User.ProfilePhoto.CroppedFilename}}">
{{else}}
<img src="/static/img/shy.png">
{{end}}
</figure>
</div> </div>
<div class="media-content"> <div class="media-content">
<p class="title is-4">{{.NameOrUsername}}</p> <p class="title is-4">{{.NameOrUsername}}</p>

View File

@ -55,13 +55,7 @@
<div class="media block"> <div class="media block">
{{$SourceUser := $UserMap.Get .SourceUserID}} {{$SourceUser := $UserMap.Get .SourceUserID}}
<div class="media-left"> <div class="media-left">
<figure class="image is-64x64"> {{template "avatar-64x64" $SourceUser}}
{{if $SourceUser.ProfilePhoto.ID}}
<img src="{{PhotoURL $SourceUser.ProfilePhoto.CroppedFilename}}">
{{else}}
<img src="/static/img/shy.png">
{{end}}
</figure>
</div> </div>
<div class="media-content"> <div class="media-content">
<p class="title is-4">{{$SourceUser.NameOrUsername}}</p> <p class="title is-4">{{$SourceUser.NameOrUsername}}</p>

View File

@ -0,0 +1,77 @@
<!-- User avatar widgets -->
<!-- Parameter: .User -->
{{define "avatar-48x48"}}
<figure class="image is-48x48 is-inline-block">
<a href="/u/{{.Username}}" class="has-text-dark">
{{if .ProfilePhoto.ID}}
{{if and (eq .ProfilePhoto.Visibility "private") (not .UserRelationship.IsPrivateGranted)}}
<img src="/static/img/shy-private.png">
{{else if and (eq .ProfilePhoto.Visibility "friends") (not .UserRelationship.IsFriend)}}
<img src="/static/img/shy-friends.png">
{{else}}
<img src="{{PhotoURL .ProfilePhoto.CroppedFilename}}">
{{end}}
{{else}}
<img src="/static/img/shy.png">
{{end}}
</a>
</figure>
{{end}}
<!-- Parameter: .User -->
{{define "avatar-64x64"}}
<figure class="image is-64x64 is-inline-block">
<a href="/u/{{.Username}}" class="has-text-dark">
{{if .ProfilePhoto.ID}}
{{if and (eq .ProfilePhoto.Visibility "private") (not .UserRelationship.IsPrivateGranted)}}
<img src="/static/img/shy-private.png">
{{else if and (eq .ProfilePhoto.Visibility "friends") (not .UserRelationship.IsFriend)}}
<img src="/static/img/shy-friends.png">
{{else}}
<img src="{{PhotoURL .ProfilePhoto.CroppedFilename}}">
{{end}}
{{else}}
<img src="/static/img/shy.png">
{{end}}
</a>
</figure>
{{end}}
<!-- Parameter: .User -->
{{define "avatar-96x96"}}
<figure class="image is-96x96 is-inline-block">
<a href="/u/{{.Username}}" class="has-text-dark">
{{if .ProfilePhoto.ID}}
{{if and (eq .ProfilePhoto.Visibility "private") (not .UserRelationship.IsPrivateGranted)}}
<img src="/static/img/shy-private.png">
{{else if and (eq .ProfilePhoto.Visibility "friends") (not .UserRelationship.IsFriend)}}
<img src="/static/img/shy-friends.png">
{{else}}
<img src="{{PhotoURL .ProfilePhoto.CroppedFilename}}">
{{end}}
{{else}}
<img src="/static/img/shy.png">
{{end}}
</a>
</figure>
{{end}}
<!-- Parameter: .User -->
{{define "avatar-32x32"}}
<figure class="image is-32x32 is-inline-block">
<a href="/u/{{.Username}}" class="has-text-dark">
{{if .ProfilePhoto.ID}}
{{if and (eq .ProfilePhoto.Visibility "private") (not .UserRelationship.IsPrivateGranted)}}
<img class="is-rounded" src="/static/img/shy-private.png">
{{else if and (eq .ProfilePhoto.Visibility "friends") (not .UserRelationship.IsFriend)}}
<img class="is-rounded" src="/static/img/shy-friends.png">
{{else}}
<img class="is-rounded" src="{{PhotoURL .ProfilePhoto.CroppedFilename}}">
{{end}}
{{else}}
<img class="is-rounded" src="/static/img/shy.png">
{{end}}
</a>
</figure>
{{end}}

View File

@ -102,15 +102,7 @@
<div class="card-content"> <div class="card-content">
<div class="media block"> <div class="media block">
<div class="media-left"> <div class="media-left">
<figure class="image is-64x64"> {{template "avatar-64x64" .}}
<a href="/u/{{.Username}}">
{{if .ProfilePhoto.ID}}
<img src="{{PhotoURL .ProfilePhoto.CroppedFilename}}">
{{else}}
<img src="/static/img/shy.png">
{{end}}
</a>
</figure>
</div> </div>
<div class="media-content"> <div class="media-content">
<p class="title is-4"> <p class="title is-4">

View File

@ -47,16 +47,10 @@
</div> </div>
<div class="media block"> <div class="media block">
<div class="media-left"> <div class="media-left">
<figure class="image is-64x64"> {{template "avatar-64x64" .User}}
{{if .User.ProfilePhoto.ID}}
<img src="{{PhotoURL .User.ProfilePhoto.CroppedFilename}}">
{{else}}
<img src="/static/img/shy.png">
{{end}}
</figure>
</div> </div>
<div class="media-content"> <div class="media-content">
<p class="title is-4">{{.NameOrUsername}}</p> <p class="title is-4">{{.User.NameOrUsername}}</p>
<p class="subtitle is-6"> <p class="subtitle is-6">
<span class="icon"><i class="fa fa-user"></i></span> <span class="icon"><i class="fa fa-user"></i></span>
<a href="/u/{{.User.Username}}" target="_blank">{{.User.Username}}</a> <a href="/u/{{.User.Username}}" target="_blank">{{.User.Username}}</a>

View File

@ -262,14 +262,6 @@
</div> </div>
<div class="notification is-warning is-light p-2 is-size-7">
<i class="fa fa-warning"></i> <strong>Notice:</strong> the square cropped
thumbnail of your Default Profile Picture will always be visible on your
profile and displayed alongside your username elsewhere on the site. The above Visibility
setting <em>can</em> limit the full-size photo's visibility; but the square cropped
thumbnail is always seen to logged-in members.
</div>
<div class="field mb-5"> <div class="field mb-5">
<label class="label"> <label class="label">
<span>Site Photo Gallery</span> <span>Site Photo Gallery</span>