Spit and Polish

* Friends: can now see your sent requests awaiting approval too.
* Site Gallery: you may see Friends-only photos on the Gallery if you are
  friends with the owner and the pic is opted-in for the Gallery.
* Site Gallery: show color coded visibility icons in card headers.
* Improve appearance of Upload Photo page.
* Update FAQ
This commit is contained in:
Noah 2022-08-22 20:58:35 -07:00
parent 13aa5a8544
commit e06bf2f9c4
8 changed files with 269 additions and 81 deletions

View File

@ -13,7 +13,11 @@ import (
func Friends() http.HandlerFunc { func Friends() http.HandlerFunc {
tmpl := templates.Must("friend/friends.html") tmpl := templates.Must("friend/friends.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
isRequests := r.FormValue("view") == "requests" var (
view = r.FormValue("view")
isRequests = view == "requests"
isPending = view == "pending"
)
currentUser, err := session.CurrentUser(r) currentUser, err := session.CurrentUser(r)
if err != nil { if err != nil {
@ -28,7 +32,7 @@ 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, pager) friends, err := models.PaginateFriends(currentUser.ID, 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, "/")
@ -37,6 +41,7 @@ func Friends() http.HandlerFunc {
var vars = map[string]interface{}{ var vars = map[string]interface{}{
"IsRequests": isRequests, "IsRequests": isRequests,
"IsPending": isPending,
"Friends": friends, "Friends": friends,
"Pager": pager, "Pager": pager,
} }

View File

@ -91,6 +91,19 @@ func FriendStatus(sourceUserID, targetUserID uint64) string {
return "none" return "none"
} }
// FriendIDs returns all user IDs with approved friendship to the user.
func FriendIDs(userId uint64) []uint64 {
var (
fs = []*Friend{}
userIDs = []uint64{}
)
DB.Where("source_user_id = ? AND approved = ?", userId, true).Find(&fs)
for _, row := range fs {
userIDs = append(userIDs, row.TargetUserID)
}
return userIDs
}
// CountFriendRequests gets a count of pending requests for the user. // CountFriendRequests gets a count of pending requests for the user.
func CountFriendRequests(userID uint64) (int64, error) { func CountFriendRequests(userID uint64) (int64, error) {
var count int64 var count int64
@ -102,9 +115,15 @@ func CountFriendRequests(userID uint64) (int64, error) {
return count, result.Error return count, result.Error
} }
// PaginateFriends gets a page of friends (or pending friend requests) as User objects ordered /*
// by friendship date. PaginateFriends gets a page of friends (or pending friend requests) as User objects ordered
func PaginateFriends(userID uint64, requests bool, pager *Pagination) ([]*User, error) { by friendship date.
The `requests` and `sent` bools are mutually exclusive (use only one, or neither). `requests`
asks for unanswered friend requests to you, and `sent` returns the friend requests that you
have sent and have not been answered.
*/
func PaginateFriends(userID uint64, 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{}
@ -112,17 +131,24 @@ func PaginateFriends(userID uint64, requests bool, pager *Pagination) ([]*User,
query *gorm.DB query *gorm.DB
) )
if requests && sent {
return nil, errors.New("requests and sent are mutually exclusive options, use one or neither")
}
if requests { if requests {
query = DB.Where( query = DB.Where(
"target_user_id = ? AND approved = ?", "target_user_id = ? AND approved = ?",
userID, userID, false,
false, )
} else if sent {
query = DB.Where(
"source_user_id = ? AND approved = ?",
userID, false,
) )
} else { } else {
query = DB.Where( query = DB.Where(
"source_user_id = ? AND approved = ?", "source_user_id = ? AND approved = ?",
userID, userID, true,
true,
) )
} }

View File

@ -33,12 +33,22 @@ const (
PhotoPrivate = "private" // private PhotoPrivate = "private" // private
) )
var PhotoVisibilityAll = []PhotoVisibility{ // PhotoVisibility preset settings.
var (
PhotoVisibilityAll = []PhotoVisibility{
PhotoPublic, PhotoPublic,
PhotoFriends, PhotoFriends,
PhotoPrivate, PhotoPrivate,
} }
// Site Gallery visibility for when your friends show up in the gallery.
// Or: "Friends + Gallery" photos can appear to your friends in the Site Gallery.
PhotoVisibilityFriends = []string{
string(PhotoPublic),
string(PhotoFriends),
}
)
// CreatePhoto with most of the settings you want (not ID or timestamps) in the database. // CreatePhoto with most of the settings you want (not ID or timestamps) in the database.
func CreatePhoto(tmpl Photo) (*Photo, error) { func CreatePhoto(tmpl Photo) (*Photo, error) {
if tmpl.UserID == 0 { if tmpl.UserID == 0 {
@ -127,13 +137,25 @@ func PaginateGalleryPhotos(userID uint64, adminView bool, explicitOK bool, pager
p = []*Photo{} p = []*Photo{}
query *gorm.DB query *gorm.DB
blocklist = BlockedUserIDs(userID) blocklist = BlockedUserIDs(userID)
friendIDs = FriendIDs(userID)
wheres = []string{} wheres = []string{}
placeholders = []interface{}{} placeholders = []interface{}{}
) )
// Universal filters: public + gallery photos only. // Include ourself in our friend IDs.
wheres = append(wheres, "visibility = ?", "gallery = ?") friendIDs = append(friendIDs, userID)
placeholders = append(placeholders, PhotoPublic, true)
// You can see friends' Friend photos but only public for non-friends.
wheres = append(wheres,
"(user_id IN ? AND visibility IN ?) OR (user_id NOT IN ? AND visibility = ?)",
)
placeholders = append(placeholders,
friendIDs, PhotoVisibilityFriends, friendIDs, PhotoPublic,
)
// Gallery photos only.
wheres = append(wheres, "gallery = ?")
placeholders = append(placeholders, true)
// Filter blocked users. // Filter blocked users.
if len(blocklist) > 0 { if len(blocklist) > 0 {

View File

@ -33,6 +33,13 @@
background-color: #FFEEFF; background-color: #FFEEFF;
} }
.has-text-private {
color: #CC00CC;
}
.has-text-private-light {
color: #FF99FF;
}
/* Mobile: notification badge near the hamburger menu */ /* Mobile: notification badge near the hamburger menu */
.nonshy-mobile-notification { .nonshy-mobile-notification {
position: absolute; position: absolute;
@ -45,3 +52,8 @@
display: none; display: none;
} }
} }
/* Bulma hack: full-width columns in photo card headers */
.nonshy-fullwidth {
width: 100%;
}

View File

@ -69,6 +69,27 @@
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>
<p>
The "<strong><i class="fa fa-image"></i> Gallery</strong>" link on the site nav bar goes to the Site-wide
Photo Gallery page. Here is shown all of the <strong>public</strong> photos uploaded by
all (certified) users, if those pictures are also opted-in to appear on the Gallery in
their settings.
</p>
<p>
If you have friends on here, you may also see their "Friends-only" photos on the Site
Gallery. This way, you don't miss any updates if your friends add a new picture (so
long as they allow their picture to appear on the Gallery).
</p>
<p>
When you upload a picture you may opt it in or out of the Gallery by checking a box on
its settings page. For example, you can upload a Public photo but opt it <em>out</em> of
the Gallery -- it will then only appear on your profile page.
</p>
<h3>What is considered "explicit" in photos?</h3> <h3>What is considered "explicit" in photos?</h3>
<p> <p>
@ -96,6 +117,48 @@
You can enable a setting on your profile if you are comfortable with seeing explicit You can enable a setting on your profile if you are comfortable with seeing explicit
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>
<h1>Technical FAQs</h1>
<h3>Why did you build a custom website?</h3>
<p>
Other variants on this question might be: why not just run a
<a href="https://joinmastodon.org" target="_blank">Mastodon</a> instance? Or why
this website and not a Discord server or MeWe group or <em>insert off-the-shelf
free software or hosted web service here</em>?
</p>
<p>
It certainly would've been simpler to just use an off-the-shelf open source app
such as Mastodon (a decentralized, Twitter-like app) or similar. These apps though
have a scalability problem: users with their infinitely long timelines will upload
infinite photos until your server runs out of disk space and not enough of them may
donate to cover the costs. And the Fediverse feature (Mastodon is like e-mail and
users from all servers can like, follow and comment on one another across the entire
network) is a double edged sword too: all my members would need to tag even their
"normal nudes" as NSFW or else other servers would ban ours (meaning we have to follow
rules imposed by the wider Internet community), and conversely it is difficult to
moderate incoming content from other servers showing up on my users' timelines.
It's not a good fit for the vision I had in mind.
</p>
<p>
And on just using a service like Discord or MeWe to host my community: that's still
putting us in the hands of a corporation which can one day decide to ban all NSFW
users. Many people run nudist Discords and MeWe groups, but I needed something whose
fate is kept in my own hands.
</p>
<h3>Is this website open source?</h3>
<p>
Yes! The source code is currently hosted on the author's personal Git server. It
will eventually have a GitHub mirror and accept pull requests from the community.
In the mean time, contact the site owner to get a link to the public git repo.
This site is programmed in the Go language and released under the GNU General Public
License.
</p>
</div> </div>
</div> </div>
{{end}} {{end}}

View File

@ -15,7 +15,7 @@
<div class="level-item"> <div class="level-item">
<div class="tabs is-toggle"> <div class="tabs is-toggle">
<ul> <ul>
<li{{if not .IsRequests}} class="is-active"{{end}}> <li{{if and (not .IsRequests) (not .IsPending)}} class="is-active"{{end}}>
<a href="/friends">My Friends</a> <a href="/friends">My Friends</a>
</li> </li>
<li{{if .IsRequests}} class="is-active"{{end}}> <li{{if .IsRequests}} class="is-active"{{end}}>
@ -24,6 +24,11 @@
{{if .NavFriendRequests}}<span class="tag is-warning ml-2">{{.NavFriendRequests}}</span>{{end}} {{if .NavFriendRequests}}<span class="tag is-warning ml-2">{{.NavFriendRequests}}</span>{{end}}
</a> </a>
</li> </li>
<li{{if .IsPending}} class="is-active"{{end}}>
<a href="/friends?view=pending">
Sent
</a>
</li>
</ul> </ul>
</div> </div>
</div> </div>
@ -33,8 +38,13 @@
<div class="p-4"> <div class="p-4">
<div class="block"> <div class="block">
{{if .IsPending}}
You have sent {{.Pager.Total}} friend request{{Pluralize64 .Pager.Total}} which
{{Pluralize64 .Pager.Total "has" "have"}} not been approved yet.
{{else}}
You have {{.Pager.Total}} friend{{if .IsRequests}} request{{end}}{{Pluralize64 .Pager.Total}} You have {{.Pager.Total}} friend{{if .IsRequests}} request{{end}}{{Pluralize64 .Pager.Total}}
(page {{.Pager.Page}} of {{.Pager.Pages}}). (page {{.Pager.Page}} of {{.Pager.Pages}}).
{{end}}
</div> </div>
<div class="block"> <div class="block">
@ -121,7 +131,7 @@
<button type="submit" name="verdict" value="remove" class="card-footer-item button is-danger" <button type="submit" name="verdict" value="remove" class="card-footer-item button is-danger"
onclick="return confirm('Are you sure you want to remove this friendship?')"> onclick="return confirm('Are you sure you want to remove this friendship?')">
<span class="icon"><i class="fa fa-xmark"></i></span> <span class="icon"><i class="fa fa-xmark"></i></span>
<span>Remove</span> <span>{{if $Root.IsPending}}Cancel{{else}}Remove{{end}}</span>
</button> </button>
</footer> </footer>
{{end}} {{end}}

View File

@ -35,14 +35,14 @@
</span> </span>
{{else if eq .Visibility "friends"}} {{else if eq .Visibility "friends"}}
<span class="tag is-warning is-light"> <span class="tag is-warning is-light">
<span class="icon"><i class="fa fa-eye"></i></span> <span class="icon"><i class="fa fa-user-group"></i></span>
<span> <span>
Friends Friends
</span> </span>
</span> </span>
{{else}} {{else}}
<span class="tag is-private is-light"> <span class="tag is-private is-light">
<span class="icon"><i class="fa fa-eye"></i></span> <span class="icon"><i class="fa fa-lock"></i></span>
<span> <span>
Private Private
</span> </span>
@ -203,7 +203,7 @@
<div class="card-header-title has-text-light"> <div class="card-header-title has-text-light">
{{if $Root.UserMap.Has .UserID}} {{if $Root.UserMap.Has .UserID}}
{{$Owner := $Root.UserMap.Get .UserID}} {{$Owner := $Root.UserMap.Get .UserID}}
<div class="columns is-mobile is-gapless"> <div class="columns is-mobile is-gapless nonshy-fullwidth">
<div class="column is-narrow"> <div class="column is-narrow">
<figure class="image is-24x24 mr-2"> <figure class="image is-24x24 mr-2">
{{if gt $Owner.ProfilePhoto.ID 0}} {{if gt $Owner.ProfilePhoto.ID 0}}
@ -219,6 +219,17 @@
<i class="fa fa-external-link ml-2"></i> <i class="fa fa-external-link ml-2"></i>
</a> </a>
</div> </div>
<div class="column is-narrow">
<span class="icon">
{{if eq .Visibility "friends"}}
<i class="fa fa-user-group has-text-warning" title="Friends"></i>
{{else if eq .Visibility "private"}}
<i class="fa fa-lock has-text-private-light" title="Private"></i>
{{else}}
<i class="fa fa-eye has-text-link-light" title="Public"></i>
{{end}}
</span>
</div>
</div> </div>
{{else}} {{else}}
<span class="fa fa-user mr-2"></span> <span class="fa fa-user mr-2"></span>
@ -278,7 +289,7 @@
<div class="card-header-title has-text-light"> <div class="card-header-title has-text-light">
{{if $Root.UserMap.Has .UserID}} {{if $Root.UserMap.Has .UserID}}
{{$Owner := $Root.UserMap.Get .UserID}} {{$Owner := $Root.UserMap.Get .UserID}}
<div class="columns is-mobile is-gapless"> <div class="columns is-mobile is-gapless nonshy-fullwidth">
<div class="column is-narrow"> <div class="column is-narrow">
<figure class="image is-24x24 mr-2"> <figure class="image is-24x24 mr-2">
{{if gt $Owner.ProfilePhoto.ID 0}} {{if gt $Owner.ProfilePhoto.ID 0}}
@ -294,6 +305,17 @@
<i class="fa fa-external-link ml-2"></i> <i class="fa fa-external-link ml-2"></i>
</a> </a>
</div> </div>
<div class="column is-narrow">
<span class="icon">
{{if eq .Visibility "friends"}}
<i class="fa fa-user-group has-text-warning" title="Friends"></i>
{{else if eq .Visibility "private"}}
<i class="fa fa-lock has-text-private-light" title="Private"></i>
{{else}}
<i class="fa fa-eye has-text-link-light" title="Public"></i>
{{end}}
</span>
</div>
</div> </div>
{{else}} {{else}}
<span class="fa fa-user mr-2"></span> <span class="fa fa-user mr-2"></span>

View File

@ -197,7 +197,7 @@
<div class="card-content"> <div class="card-content">
<div class="field"> <div class="field mb-5">
<label class="label" for="caption">Caption</label> <label class="label" for="caption">Caption</label>
<input type="text" class="input" <input type="text" class="input"
name="caption" name="caption"
@ -206,8 +206,89 @@
value="{{.EditPhoto.Caption}}"> value="{{.EditPhoto.Caption}}">
</div> </div>
<div class="field"> <div class="field mb-5">
<label class="label">Explicit Content</label> <label class="label">Photo Visibility</label>
<div>
<label class="radio">
<input type="radio"
name="visibility"
value="public"
{{if or (not .EditPhoto) (eq .EditPhoto.Visibility "public")}}checked{{end}}>
<strong class="has-text-link">
<span>Public</span>
<span class="icon"><i class="fa fa-eye"></i></span>
</strong>
</label>
<p class="help">
This photo will appear on your profile page and can be seen by any
logged-in user account. It may also appear on the site-wide Photo
Gallery if that option is enabled, below.
</p>
</div>
<div>
<label class="radio">
<input type="radio"
name="visibility"
value="friends"
{{if eq .EditPhoto.Visibility "friends"}}checked{{end}}>
<strong class="has-text-warning-dark">
<span>Friends only</span>
<span class="icon"><i class="fa fa-user-group"></i></span>
</strong>
</label>
<p class="help">
Only users you have accepted as a friend can see this photo on your
profile page and on the site-wide Photo Gallery if that option is
enabled, below.
</p>
</div>
<div>
<label class="radio">
<input type="radio"
name="visibility"
value="private"
{{if eq .EditPhoto.Visibility "private"}}checked{{end}}>
<strong class="has-text-private">
<span>Private</span>
<span class="icon"><i class="fa fa-lock"></i></span>
</strong>
</label>
<p class="help">
This photo is visible only to you and to users for whom you have
granted access (the latter feature is coming soon!)
</p>
</div>
</div>
<div class="field mb-5">
<label class="label">
<span>Site Photo Gallery</span>
<span class="icon"><i class="fa fa-image"></i></span>
</label>
<label class="checkbox">
<input type="checkbox"
name="gallery"
value="true"
checked
{{if .EditPhoto.Gallery}}checked{{end}}>
Show this photo in the site-wide Photo Gallery
</label>
<p class="help">
Leave this box checked and your photo can appear in the site's Photo Gallery
page. Mainly your <strong class="has-text-link">Public</strong> photos will appear
on the Gallery, and your approved friends may see your
<strong class="has-text-warning-dark">Friends-only</strong> photos there as well.
<strong class="has-text-private">Private</strong> photos may appear in
the gallery to users whom you have granted access. If this is undesirable,
un-check the Gallery box to skip the Site Gallery.
</p>
</div>
<div class="field mb-5">
<label class="label has-text-danger">
<span>Explicit Content</span>
<span class="icon"><i class="fa fa-fire"></i></span>
</label>
{{if eq .Intent "profile_pic"}} {{if eq .Intent "profile_pic"}}
<span class="has-text-danger"> <span class="has-text-danger">
Your default profile picture should Your default profile picture should
@ -238,59 +319,6 @@
{{end}} {{end}}
</div> </div>
<div class="field">
<label class="label">Photo Visibility</label>
<div>
<label class="radio">
<input type="radio"
name="visibility"
value="public"
{{if or (not .EditPhoto) (eq .EditPhoto.Visibility "public")}}checked{{end}}>
<strong>Public:</strong> this photo will appear on your profile page
and can be seen by any logged-in user account. It may also appear
on the site-wide Photo Gallery if that option is enabled, below.
</label>
</div>
<div>
<label class="radio">
<input type="radio"
name="visibility"
value="friends"
{{if eq .EditPhoto.Visibility "friends"}}checked{{end}}>
<strong>Friends only:</strong> only users you have added as a friend
can see this photo on your profile page.
</label>
</div>
<div>
<label class="radio">
<input type="radio"
name="visibility"
value="private"
{{if eq .EditPhoto.Visibility "private"}}checked{{end}}>
<strong>Private:</strong> this photo is not visible to anybody except
for people whom you allow to see your private pictures (not implemented yet!)
</label>
</div>
</div>
<div class="field">
<label class="label">Site Photo Gallery</label>
<label class="checkbox">
<input type="checkbox"
name="gallery"
value="true"
checked
{{if .EditPhoto.Gallery}}checked{{end}}>
Show this photo in the site-wide Photo Gallery (public photos only)
</label>
<p class="help">
Leave this box checked and your (public only) photo can appear in the site's
Photo Gallery page. If you uncheck this box, your (public) photo will still
appear on your profile page but not on the site photo gallery. Friends-only
or private photos never appear in the gallery even if this box is checked.
</p>
</div>
{{if not .EditPhoto}} {{if not .EditPhoto}}
<div class="field"> <div class="field">
<label class="label">Confirm Upload</label> <label class="label">Confirm Upload</label>