diff --git a/pkg/config/page_sizes.go b/pkg/config/page_sizes.go new file mode 100644 index 0000000..9d71b01 --- /dev/null +++ b/pkg/config/page_sizes.go @@ -0,0 +1,12 @@ +package config + +// Pagination sizes per page. +var ( + PageSizeMemberSearch = 60 + PageSizeFriends = 12 + PageSizeAdminCertification = 20 + PageSizeSiteGallery = 18 + PageSizeUserGallery = 18 + PageSizeInboxList = 20 // sidebar list + PageSizeInboxThread = 20 // conversation view +) diff --git a/pkg/controller/account/profile.go b/pkg/controller/account/profile.go index 3f69387..d51d90f 100644 --- a/pkg/controller/account/profile.go +++ b/pkg/controller/account/profile.go @@ -5,6 +5,7 @@ import ( "regexp" "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" "git.kirsle.net/apps/gosocial/pkg/templates" ) @@ -21,6 +22,14 @@ func Profile() http.HandlerFunc { username = m[1] } + // Get the current user. + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Couldn't get CurrentUser: %s", err) + templates.Redirect(w, "/") + return + } + // Find this user. user, err := models.FindUser(username) if err != nil { @@ -29,7 +38,8 @@ func Profile() http.HandlerFunc { } vars := map[string]interface{}{ - "User": user, + "User": user, + "IsFriend": models.FriendStatus(currentUser.ID, user.ID), } if err := tmpl.Execute(w, r, vars); err != nil { diff --git a/pkg/controller/account/search.go b/pkg/controller/account/search.go new file mode 100644 index 0000000..15f0eeb --- /dev/null +++ b/pkg/controller/account/search.go @@ -0,0 +1,64 @@ +package account + +import ( + "net/http" + + "git.kirsle.net/apps/gosocial/pkg/config" + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" + "git.kirsle.net/apps/gosocial/pkg/templates" +) + +// Search controller. +func Search() http.HandlerFunc { + tmpl := templates.Must("account/search.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Search filters. + var ( + isCertified = r.FormValue("certified") + username = r.FormValue("username") // email or username + gender = r.FormValue("gender") + orientation = r.FormValue("orientation") + maritalStatus = r.FormValue("marital_status") + ) + + // Default + if isCertified == "" { + isCertified = "true" + } + + pager := &models.Pagination{ + PerPage: config.PageSizeMemberSearch, + } + pager.ParsePage(r) + + users, err := models.SearchUsers(&models.UserSearch{ + EmailOrUsername: username, + Gender: gender, + Orientation: orientation, + MaritalStatus: maritalStatus, + Certified: isCertified == "true", + }, pager) + if err != nil { + session.FlashError(w, r, "Couldn't search users: %s", err) + } + + var vars = map[string]interface{}{ + "Users": users, + "Pager": pager, + "Enum": config.ProfileEnums, + + // Search filter values. + "Certified": isCertified, + "Gender": gender, + "Orientation": orientation, + "MaritalStatus": maritalStatus, + "EmailOrUsername": username, + } + + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/friend/friends.go b/pkg/controller/friend/friends.go new file mode 100644 index 0000000..fce3b0e --- /dev/null +++ b/pkg/controller/friend/friends.go @@ -0,0 +1,48 @@ +package friend + +import ( + "net/http" + + "git.kirsle.net/apps/gosocial/pkg/config" + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" + "git.kirsle.net/apps/gosocial/pkg/templates" +) + +// Friends list and pending friend request endpoint. +func Friends() http.HandlerFunc { + tmpl := templates.Must("friend/friends.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + isRequests := r.FormValue("view") == "requests" + + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Unexpected error: could not get currentUser.") + templates.Redirect(w, "/") + return + } + + // Get our friends. + pager := &models.Pagination{ + PerPage: config.PageSizeFriends, + Sort: "updated_at desc", + } + pager.ParsePage(r) + friends, err := models.PaginateFriends(currentUser.ID, isRequests, pager) + if err != nil { + session.FlashError(w, r, "Couldn't paginate friends: %s", err) + templates.Redirect(w, "/") + return + } + + var vars = map[string]interface{}{ + "IsRequests": isRequests, + "Friends": friends, + "Pager": pager, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/friend/request.go b/pkg/controller/friend/request.go new file mode 100644 index 0000000..0163e47 --- /dev/null +++ b/pkg/controller/friend/request.go @@ -0,0 +1,89 @@ +package friend + +import ( + "fmt" + "net/http" + "strings" + + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" + "git.kirsle.net/apps/gosocial/pkg/templates" +) + +// AddFriend controller to send a friend request. +func AddFriend() http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // POST only. + if r.Method != http.MethodPost { + session.FlashError(w, r, "Unacceptable Request Method") + templates.Redirect(w, "/") + return + } + + // Form fields + var ( + username = strings.ToLower(r.PostFormValue("username")) + verdict = r.PostFormValue("verdict") + ) + + // Get the current user. + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Couldn't get CurrentUser: %s", err) + templates.Redirect(w, "/") + return + } + + // Get the target user. + user, err := models.FindUser(username) + if err != nil { + session.FlashError(w, r, "User Not Found") + templates.Redirect(w, "/") + return + } + + // Can't friend yourself. + if currentUser.ID == user.ID { + session.FlashError(w, r, "You can't send a friend request to yourself!") + templates.Redirect(w, "/u/"+username) + return + } + + // Are we adding, or rejecting+removing? + if verdict == "reject" || verdict == "remove" { + err := models.RemoveFriend(currentUser.ID, user.ID) + if err != nil { + session.FlashError(w, r, "Failed to remove friend: %s", err) + templates.Redirect(w, "/u/"+username) + return + } + + var message string + if verdict == "reject" { + message = fmt.Sprintf("Friend request from %s has been rejected.", username) + } else { + message = fmt.Sprintf("Removed friendship with %s.", username) + } + + session.Flash(w, r, message) + if verdict == "reject" { + templates.Redirect(w, "/friends?view=requests") + } + templates.Redirect(w, "/friends") + } else { + // Post the friend request. + if err := models.AddFriend(currentUser.ID, user.ID); err != nil { + session.FlashError(w, r, "Couldn't send friend request: %s.", err) + } else { + if verdict == "approve" { + session.Flash(w, r, "You accepted the friend request from %s!", username) + templates.Redirect(w, "/friends?view=requests") + return + } + session.Flash(w, r, "Friend request sent!") + } + } + + templates.Redirect(w, "/u/"+username) + }) +} diff --git a/pkg/controller/inbox/inbox.go b/pkg/controller/inbox/inbox.go index 702745d..82191b0 100644 --- a/pkg/controller/inbox/inbox.go +++ b/pkg/controller/inbox/inbox.go @@ -5,6 +5,7 @@ import ( "regexp" "strconv" + "git.kirsle.net/apps/gosocial/pkg/config" "git.kirsle.net/apps/gosocial/pkg/models" "git.kirsle.net/apps/gosocial/pkg/session" "git.kirsle.net/apps/gosocial/pkg/templates" @@ -58,7 +59,7 @@ func Inbox() http.HandlerFunc { // Get the full chat thread (paginated). threadPager = &models.Pagination{ - PerPage: 5, + PerPage: config.PageSizeInboxThread, Sort: "created_at desc", } threadPager.ParsePage(r) @@ -84,7 +85,7 @@ func Inbox() http.HandlerFunc { // Get the inbox list of messages. pager := &models.Pagination{ Page: 1, - PerPage: 5, + PerPage: config.PageSizeInboxList, Sort: "created_at desc", } if viewThread == nil { diff --git a/pkg/controller/photo/certification.go b/pkg/controller/photo/certification.go index 43fb7e7..70a342c 100644 --- a/pkg/controller/photo/certification.go +++ b/pkg/controller/photo/certification.go @@ -271,7 +271,7 @@ func AdminCertification() http.HandlerFunc { // Get the pending photos. pager := &models.Pagination{ Page: 1, - PerPage: 20, + PerPage: config.PageSizeAdminCertification, Sort: "updated_at desc", } pager.ParsePage(r) diff --git a/pkg/controller/photo/site_gallery.go b/pkg/controller/photo/site_gallery.go index 5231494..1e2d930 100644 --- a/pkg/controller/photo/site_gallery.go +++ b/pkg/controller/photo/site_gallery.go @@ -3,6 +3,7 @@ package photo import ( "net/http" + "git.kirsle.net/apps/gosocial/pkg/config" "git.kirsle.net/apps/gosocial/pkg/models" "git.kirsle.net/apps/gosocial/pkg/session" "git.kirsle.net/apps/gosocial/pkg/templates" @@ -29,7 +30,7 @@ func SiteGallery() http.HandlerFunc { // Get the page of photos. pager := &models.Pagination{ Page: 1, - PerPage: 8, + PerPage: config.PageSizeSiteGallery, Sort: "created_at desc", } pager.ParsePage(r) diff --git a/pkg/controller/photo/user_gallery.go b/pkg/controller/photo/user_gallery.go index b99f458..795f737 100644 --- a/pkg/controller/photo/user_gallery.go +++ b/pkg/controller/photo/user_gallery.go @@ -4,6 +4,7 @@ import ( "net/http" "regexp" + "git.kirsle.net/apps/gosocial/pkg/config" "git.kirsle.net/apps/gosocial/pkg/log" "git.kirsle.net/apps/gosocial/pkg/models" "git.kirsle.net/apps/gosocial/pkg/session" @@ -49,6 +50,8 @@ func UserPhotos() http.HandlerFunc { visibility := []models.PhotoVisibility{models.PhotoPublic} if isOwnPhotos || currentUser.IsAdmin { visibility = append(visibility, models.PhotoFriends, models.PhotoPrivate) + } else if models.AreFriends(user.ID, currentUser.ID) { + visibility = append(visibility, models.PhotoFriends) } // Explicit photo filter? @@ -60,7 +63,7 @@ func UserPhotos() http.HandlerFunc { // Get the page of photos. pager := &models.Pagination{ Page: 1, - PerPage: 8, + PerPage: config.PageSizeUserGallery, Sort: "created_at desc", } pager.ParsePage(r) diff --git a/pkg/models/friend.go b/pkg/models/friend.go new file mode 100644 index 0000000..d171254 --- /dev/null +++ b/pkg/models/friend.go @@ -0,0 +1,175 @@ +package models + +import ( + "errors" + "time" + + "gorm.io/gorm" +) + +// Friend table. +type Friend struct { + ID uint64 `gorm:"primaryKey"` + SourceUserID uint64 `gorm:"index"` + TargetUserID uint64 `gorm:"index"` + Approved bool `gorm:"index"` + CreatedAt time.Time + UpdatedAt time.Time +} + +// AddFriend sends a friend request or accepts one if there was already a pending one. +func AddFriend(sourceUserID, targetUserID uint64) error { + // Did we already send a friend request? + f := &Friend{} + forward := DB.Where( + "source_user_id = ? AND target_user_id = ?", + sourceUserID, targetUserID, + ).First(&f).Error + + // Is there a reverse friend request pending? + rev := &Friend{} + reverse := DB.Where( + "source_user_id = ? AND target_user_id = ?", + targetUserID, sourceUserID, + ).First(&rev).Error + + // If the reverse exists (requested us) but not the forward, this completes the friendship. + if reverse == nil && forward != nil { + // Approve the reverse. + rev.Approved = true + rev.Save() + + // Add the matching forward. + f = &Friend{ + SourceUserID: sourceUserID, + TargetUserID: targetUserID, + Approved: true, + } + return DB.Create(f).Error + } + + // If the forward already existed, error. + if forward == nil { + if f.Approved { + return errors.New("you are already friends") + } + return errors.New("a friend request had already been sent") + } + + // Create the pending forward request. + f = &Friend{ + SourceUserID: sourceUserID, + TargetUserID: targetUserID, + Approved: false, + } + return DB.Create(f).Error +} + +// AreFriends quickly checks if two user IDs are friends. +func AreFriends(sourceUserID, targetUserID uint64) bool { + f := &Friend{} + DB.Where( + "source_user_id = ? AND target_user_id = ?", + sourceUserID, targetUserID, + ).First(&f) + return f.Approved +} + +// FriendStatus returns an indicator of friendship status: "none", "pending", "approved" +func FriendStatus(sourceUserID, targetUserID uint64) string { + f := &Friend{} + result := DB.Where( + "source_user_id = ? AND target_user_id = ?", + sourceUserID, targetUserID, + ).First(&f) + if result.Error == nil { + if f.Approved { + return "approved" + } + return "pending" + } + return "none" +} + +// CountFriendRequests gets a count of pending requests for the user. +func CountFriendRequests(userID uint64) (int64, error) { + var count int64 + result := DB.Where( + "target_user_id = ? AND approved = ?", + userID, + false, + ).Model(&Friend{}).Count(&count) + return count, result.Error +} + +// PaginateFriends gets a page of friends (or pending friend requests) as User objects ordered +// by friendship date. +func PaginateFriends(userID uint64, requests bool, pager *Pagination) ([]*User, error) { + // We paginate over the Friend table. + var ( + fs = []*Friend{} + userIDs = []uint64{} + query *gorm.DB + ) + + if requests { + query = DB.Where( + "target_user_id = ? AND approved = ?", + userID, + false, + ) + } else { + query = DB.Where( + "source_user_id = ? AND approved = ?", + userID, + true, + ) + } + + query = query.Order(pager.Sort) + query.Model(&Friend{}).Count(&pager.Total) + result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs) + if result.Error != nil { + return nil, result.Error + } + + // Now of these friends get their User objects. + for _, friend := range fs { + if requests { + userIDs = append(userIDs, friend.SourceUserID) + } else { + userIDs = append(userIDs, friend.TargetUserID) + } + } + + return GetUsers(userIDs) +} + +// GetFriendRequests returns all pending friend requests for a user. +func GetFriendRequests(userID uint64) ([]*Friend, error) { + var fs = []*Friend{} + result := DB.Where( + "target_user_id = ? AND approved = ?", + userID, + false, + ).Find(&fs) + return fs, result.Error +} + +// RemoveFriend severs a friend connection both directions, used when +// rejecting a request or removing a friend. +func RemoveFriend(sourceUserID, targetUserID uint64) error { + result := DB.Where( + "(source_user_id = ? AND target_user_id = ?) OR "+ + "(target_user_id = ? AND source_user_id = ?)", + sourceUserID, targetUserID, + sourceUserID, targetUserID, + ).Delete(&Friend{}) + return result.Error +} + +// Save photo. +func (f *Friend) Save() error { + result := DB.Save(f) + return result.Error +} diff --git a/pkg/models/models.go b/pkg/models/models.go index 11b1713..877c952 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -13,4 +13,5 @@ func AutoMigrate() { DB.AutoMigrate(&Photo{}) DB.AutoMigrate(&CertificationPhoto{}) DB.AutoMigrate(&Message{}) + DB.AutoMigrate(&Friend{}) } diff --git a/pkg/models/user.go b/pkg/models/user.go index 56378db..34bb7eb 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -79,6 +79,24 @@ func GetUser(userId uint64) (*User, error) { return user, result.Error } +// GetUsers queries for multiple user IDs and returns users in the same order. +func GetUsers(userIDs []uint64) ([]*User, error) { + userMap, err := MapUsers(userIDs) + if err != nil { + return nil, err + } + + // Re-order them per the original sequence. + var users = []*User{} + for _, uid := range userIDs { + if user, ok := userMap[uid]; ok { + users = append(users, user) + } + } + + return users, nil +} + // FindUser by username or email. func FindUser(username string) (*User, error) { if username == "" { @@ -94,6 +112,78 @@ func FindUser(username string) (*User, error) { return u, result.Error } +// UserSearch config. +type UserSearch struct { + EmailOrUsername string + Gender string + Orientation string + MaritalStatus string + Certified bool +} + +// SearchUsers +func SearchUsers(search *UserSearch, pager *Pagination) ([]*User, error) { + if search == nil { + search = &UserSearch{} + } + + var ( + users = []*User{} + query *gorm.DB + wheres = []string{} + placeholders = []interface{}{} + ) + + if search.EmailOrUsername != "" { + ilike := "%" + strings.TrimSpace(strings.ToLower(search.EmailOrUsername)) + "%" + wheres = append(wheres, "(email LIKE ? OR username LIKE ?)") + placeholders = append(placeholders, ilike, ilike) + } + + if search.Gender != "" { + wheres = append(wheres, ` + EXISTS ( + SELECT 1 FROM profile_fields + WHERE user_id = users.id AND name = ? AND value = ? + ) + `) + placeholders = append(placeholders, "gender", search.Gender) + } + + if search.Orientation != "" { + wheres = append(wheres, ` + EXISTS ( + SELECT 1 FROM profile_fields + WHERE user_id = users.id AND name = ? AND value = ? + ) + `) + placeholders = append(placeholders, "orientation", search.Orientation) + } + + if search.MaritalStatus != "" { + wheres = append(wheres, ` + EXISTS ( + SELECT 1 FROM profile_fields + WHERE user_id = users.id AND name = ? AND value = ? + ) + `) + placeholders = append(placeholders, "marital_status", search.MaritalStatus) + } + + if search.Certified { + wheres = append(wheres, "certified = ?") + placeholders = append(placeholders, search.Certified) + } + + query = (&User{}).Preload().Where( + strings.Join(wheres, " AND "), + placeholders..., + ) + query.Model(&User{}).Count(&pager.Total) + result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&users) + return users, result.Error +} + // UserMap helps map a set of users to look up by ID. type UserMap map[uint64]*User diff --git a/pkg/router/router.go b/pkg/router/router.go index 1ac9ff9..1bf7ede 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -8,6 +8,7 @@ import ( "git.kirsle.net/apps/gosocial/pkg/controller/account" "git.kirsle.net/apps/gosocial/pkg/controller/admin" "git.kirsle.net/apps/gosocial/pkg/controller/api" + "git.kirsle.net/apps/gosocial/pkg/controller/friend" "git.kirsle.net/apps/gosocial/pkg/controller/inbox" "git.kirsle.net/apps/gosocial/pkg/controller/index" "git.kirsle.net/apps/gosocial/pkg/controller/photo" @@ -35,9 +36,12 @@ func New() http.Handler { mux.Handle("/messages", middleware.LoginRequired(inbox.Inbox())) mux.Handle("/messages/read/", middleware.LoginRequired(inbox.Inbox())) mux.Handle("/messages/compose", middleware.LoginRequired(inbox.Compose())) + mux.Handle("/friends", middleware.LoginRequired(friend.Friends())) + mux.Handle("/friends/add", middleware.LoginRequired(friend.AddFriend())) // Certification Required. Pages that only full (verified) members can access. mux.Handle("/photo/gallery", middleware.CertRequired(photo.SiteGallery())) + mux.Handle("/members", middleware.CertRequired(account.Search())) // Admin endpoints. mux.Handle("/admin", middleware.AdminRequired(admin.Dashboard())) diff --git a/pkg/templates/template_vars.go b/pkg/templates/template_vars.go index 23a3620..4ea5018 100644 --- a/pkg/templates/template_vars.go +++ b/pkg/templates/template_vars.go @@ -29,6 +29,7 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) { m["LoggedIn"] = false m["CurrentUser"] = nil m["NavUnreadMessages"] = 0 + m["NavFriendRequests"] = 0 if r == nil { return @@ -44,5 +45,12 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) { } else { log.Error("MergeUserVars: couldn't CountUnreadMessages for %d: %s", user.ID, err) } + + // Get friend request count. + if count, err := models.CountFriendRequests(user.ID); err == nil { + m["NavFriendRequests"] = count + } else { + log.Error("MergeUserVars: couldn't CountFriendRequests for %d: %s", user.ID, err) + } } } diff --git a/web/templates/account/profile.html b/web/templates/account/profile.html index 2ef81f8..c339f6a 100644 --- a/web/templates/account/profile.html +++ b/web/templates/account/profile.html @@ -87,16 +87,27 @@
-

- -

+ +

+

diff --git a/web/templates/account/search.html b/web/templates/account/search.html new file mode 100644 index 0000000..cd95acd --- /dev/null +++ b/web/templates/account/search.html @@ -0,0 +1,198 @@ +{{define "title"}}Friends{{end}} +{{define "content"}} +

+ {{$Root := .}} + + +
+
+ +
+
+ Found {{.Pager.Total}} user{{Pluralize64 .Pager.Total}} + (page {{.Pager.Page}} of {{.Pager.Pages}}). +
+
+ + +
+
+ +
+ +
+ + {{range .Users}} +
+ +
+
+
+ +
+

+ {{or .Name "(no name)"}} +

+

+ + {{.Username}} + {{if not .Certified}} + + + Not Certified! + + {{end}} + + {{if .IsAdmin}} + + + Admin + + {{end}} +

+ {{if .GetProfileField "city"}} +

+ {{.GetProfileField "city"}} +

+ {{end}} +

+ {{if not .Birthdate.IsZero }} + {{ComputeAge .Birthdate}}yo + {{end}} + + {{if .GetProfileField "gender"}} + {{.GetProfileField "gender"}} + {{end}} + + {{if .GetProfileField "pronouns"}} + {{.GetProfileField "pronouns"}} + {{end}} + + {{if .GetProfileField "orientation"}} + {{.GetProfileField "orientation"}} + {{end}} +

+
+
+
+
+ +
+ {{end}} +
+ +
+
+
+{{end}} \ No newline at end of file diff --git a/web/templates/base.html b/web/templates/base.html index 08389b6..743ae04 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -56,13 +56,13 @@ Friends - + {{if .NavFriendRequests}}{{.NavFriendRequests}}{{end}} Messages - {{if .NavUnreadMessages}}{{.NavUnreadMessages}}{{end}} + {{if .NavUnreadMessages}}{{.NavUnreadMessages}}{{end}} {{end}} @@ -72,6 +72,10 @@