From c8f6cf2e4ddb639a06908c367a50582cddd75056 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sat, 13 Aug 2022 17:42:42 -0700 Subject: [PATCH] Implement Direct Messaging --- pkg/controller/inbox/compose.go | 73 +++++++++++ pkg/controller/inbox/inbox.go | 135 ++++++++++++++++++++ pkg/controller/photo/edit_delete.go | 10 +- pkg/models/message.go | 89 +++++++++++++ pkg/models/models.go | 1 + pkg/models/pagination.go | 9 +- pkg/models/user.go | 17 ++- pkg/router/router.go | 4 + pkg/templates/template_funcs.go | 6 + pkg/templates/template_vars.go | 10 ++ web/templates/account/dashboard.html | 6 + web/templates/account/profile.html | 4 +- web/templates/account/signup.html | 2 - web/templates/admin/dashboard.html | 2 +- web/templates/base.html | 4 +- web/templates/inbox/compose.html | 89 +++++++++++++ web/templates/inbox/inbox.html | 182 +++++++++++++++++++++++++++ 17 files changed, 624 insertions(+), 19 deletions(-) create mode 100644 pkg/controller/inbox/compose.go create mode 100644 pkg/controller/inbox/inbox.go create mode 100644 pkg/models/message.go create mode 100644 web/templates/inbox/compose.html create mode 100644 web/templates/inbox/inbox.html diff --git a/pkg/controller/inbox/compose.go b/pkg/controller/inbox/compose.go new file mode 100644 index 0000000..b9ad097 --- /dev/null +++ b/pkg/controller/inbox/compose.go @@ -0,0 +1,73 @@ +package inbox + +import ( + "fmt" + "net/http" + + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" + "git.kirsle.net/apps/gosocial/pkg/templates" +) + +// Compose a new chat coming from a user's profile page. +func Compose() http.HandlerFunc { + tmpl := templates.Must("inbox/compose.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // To whom? + username := r.FormValue("to") + user, err := models.FindUser(username) + if err != nil { + templates.NotFoundPage(w, r) + return + } + + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Unexpected error: could not get currentUser.") + templates.Redirect(w, "/") + return + } + + if currentUser.ID == user.ID { + session.FlashError(w, r, "You cannot send a message to yourself.") + templates.Redirect(w, "/messages") + return + } + + // POSTing? + if r.Method == http.MethodPost { + var ( + message = r.FormValue("message") + from = r.FormValue("from") // e.g. "inbox", default "profile", where to redirect to + ) + if len(message) == 0 { + session.FlashError(w, r, "A message is required.") + templates.Redirect(w, r.URL.Path+"?to="+username) + return + } + + // Post it! + m, err := models.SendMessage(currentUser.ID, user.ID, message) + if err != nil { + session.FlashError(w, r, "Failed to create the message in the database: %s", err) + templates.Redirect(w, r.URL.Path+"?to="+username) + return + } + + session.Flash(w, r, "Your message has been delivered!") + if from == "inbox" { + templates.Redirect(w, fmt.Sprintf("/messages/read/%d", m.ID)) + } + templates.Redirect(w, "/messages") + return + } + + var vars = map[string]interface{}{ + "User": user, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/inbox/inbox.go b/pkg/controller/inbox/inbox.go new file mode 100644 index 0000000..702745d --- /dev/null +++ b/pkg/controller/inbox/inbox.go @@ -0,0 +1,135 @@ +package inbox + +import ( + "net/http" + "regexp" + "strconv" + + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" + "git.kirsle.net/apps/gosocial/pkg/templates" +) + +var ReadURLRegexp = regexp.MustCompile(`^/messages/read/(\d+)$`) + +// Inbox is where users receive direct messages. +func Inbox() http.HandlerFunc { + tmpl := templates.Must("inbox/inbox.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Unexpected error: could not get currentUser.") + templates.Redirect(w, "/") + return + } + + // Default is inbox, what about sentbox? + var showSent = r.FormValue("box") == "sent" + + // Are we reading a specific message? + var viewThread []*models.Message + var threadPager *models.Pagination + var composeToUsername string + if uri := ReadURLRegexp.FindStringSubmatch(r.URL.Path); uri != nil { + msgId, _ := strconv.Atoi(uri[1]) + if msg, err := models.GetMessage(uint64(msgId)); err != nil { + session.FlashError(w, r, "Message not found.") + templates.Redirect(w, "/messages") + return + } else { + // We must be a party to this thread. + if msg.SourceUserID != currentUser.ID && msg.TargetUserID != currentUser.ID { + templates.ForbiddenPage(w, r) + return + } + + // Find the other party in this thread. + var senderUserID = msg.SourceUserID + if senderUserID == currentUser.ID { + senderUserID = msg.TargetUserID + } + + // Look up the sender's username to compose a response to them. + sender, err := models.GetUser(senderUserID) + if err != nil { + session.FlashError(w, r, "Couldn't get sender of that message: %s", err) + } + composeToUsername = sender.Username + + // Get the full chat thread (paginated). + threadPager = &models.Pagination{ + PerPage: 5, + Sort: "created_at desc", + } + threadPager.ParsePage(r) + thread, err := models.GetMessageThread(msg.SourceUserID, msg.TargetUserID, threadPager) + if err != nil { + session.FlashError(w, r, "Couldn't get chat history: %s", err) + } + + viewThread = thread + + // Mark all these messages as read if the recipient sees them. + for _, m := range viewThread { + if m.TargetUserID == currentUser.ID && !m.Read { + m.Read = true + if err := m.Save(); err != nil { + session.FlashError(w, r, "Couldn't mark message as read: %s", err) + } + } + } + } + } + + // Get the inbox list of messages. + pager := &models.Pagination{ + Page: 1, + PerPage: 5, + Sort: "created_at desc", + } + if viewThread == nil { + // On the main inbox view, ?page= params page thru the message list, not a thread. + pager.ParsePage(r) + } + messages, err := models.GetMessages(currentUser.ID, showSent, pager) + if err != nil { + session.FlashError(w, r, "Couldn't get your messages from DB: %s", err) + } + + // How many unreads? + unread, err := models.CountUnreadMessages(currentUser.ID) + if err != nil { + session.FlashError(w, r, "Couldn't get your unread message count from DB: %s", err) + } + + // Map sender data on these messages. + var userIDs = []uint64{} + for _, m := range messages { + userIDs = append(userIDs, m.SourceUserID, m.TargetUserID) + } + if viewThread != nil { + for _, m := range viewThread { + userIDs = append(userIDs, m.SourceUserID, m.TargetUserID) + } + } + userMap, err := models.MapUsers(userIDs) + if err != nil { + session.FlashError(w, r, "Couldn't map users: %s", err) + } + + var vars = map[string]interface{}{ + "Messages": messages, + "UserMap": userMap, + "Unread": unread, + "Pager": pager, + "IsSentBox": showSent, + "ViewThread": viewThread, // nil on inbox page + "ThreadPager": threadPager, + "ReplyTo": composeToUsername, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/photo/edit_delete.go b/pkg/controller/photo/edit_delete.go index 885d86d..2a43b84 100644 --- a/pkg/controller/photo/edit_delete.go +++ b/pkg/controller/photo/edit_delete.go @@ -87,10 +87,12 @@ func Edit() http.HandlerFunc { } // Set their profile pic to this one. - currentUser.ProfilePhoto = *photo - log.Error("Set user ProfilePhotoID=%d", photo.ID) - if err := currentUser.Save(); err != nil { - session.FlashError(w, r, "Couldn't save user: %s", err) + if setProfilePic { + currentUser.ProfilePhoto = *photo + log.Error("Set user ProfilePhotoID=%d", photo.ID) + if err := currentUser.Save(); err != nil { + session.FlashError(w, r, "Couldn't save user: %s", err) + } } // Flash success. diff --git a/pkg/models/message.go b/pkg/models/message.go new file mode 100644 index 0000000..1e01f29 --- /dev/null +++ b/pkg/models/message.go @@ -0,0 +1,89 @@ +package models + +import ( + "time" +) + +// Message table. +type Message struct { + ID uint64 `gorm:"primaryKey"` + SourceUserID uint64 `gorm:"index"` + TargetUserID uint64 `gorm:"index"` + Read bool `gorm:"index"` + Message string + CreatedAt time.Time + UpdatedAt time.Time +} + +// GetMessage by ID. +func GetMessage(id uint64) (*Message, error) { + m := &Message{} + result := DB.First(&m, id) + return m, result.Error +} + +// GetMessages for a user. +func GetMessages(userID uint64, sent bool, pager *Pagination) ([]*Message, error) { + var ( + m = []*Message{} + where = "target_user_id = ?" + ) + if sent { + where = "source_user_id" + } + + query := DB.Where( + where, userID, + ).Order(pager.Sort) + + query.Model(&Message{}).Count(&pager.Total) + result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&m) + return m, result.Error +} + +// GetMessageThread returns paginated message history between two people. +func GetMessageThread(sourceUserID, targetUserID uint64, pager *Pagination) ([]*Message, error) { + var m = []*Message{} + + query := DB.Where( + "(source_user_id = ? AND target_user_id = ?) OR (source_user_id = ? AND target_user_id = ?)", + sourceUserID, targetUserID, + targetUserID, sourceUserID, + ).Order(pager.Sort) + + query.Model(&Message{}).Count(&pager.Total) + result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&m) + return m, result.Error +} + +// CountUnreadMessages gets the count of unread messages for a user. +func CountUnreadMessages(userID uint64) (int64, error) { + query := DB.Where( + "target_user_id = ? AND read = ?", + userID, + false, + ) + + var count int64 + result := query.Model(&Message{}).Count(&count) + return count, result.Error +} + +// SendMessage from a source to a target user. +func SendMessage(sourceUserID, targetUserID uint64, message string) (*Message, error) { + m := &Message{ + SourceUserID: sourceUserID, + TargetUserID: targetUserID, + Message: message, + Read: false, + } + + result := DB.Create(m) + return m, result.Error +} + +// Save message. +func (m *Message) Save() error { + result := DB.Save(m) + return result.Error +} diff --git a/pkg/models/models.go b/pkg/models/models.go index e9d1332..11b1713 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -12,4 +12,5 @@ func AutoMigrate() { DB.AutoMigrate(&ProfileField{}) DB.AutoMigrate(&Photo{}) DB.AutoMigrate(&CertificationPhoto{}) + DB.AutoMigrate(&Message{}) } diff --git a/pkg/models/pagination.go b/pkg/models/pagination.go index b908c1a..f505f1d 100644 --- a/pkg/models/pagination.go +++ b/pkg/models/pagination.go @@ -4,8 +4,6 @@ import ( "math" "net/http" "strconv" - - "git.kirsle.net/apps/gosocial/pkg/log" ) // Pagination result object. @@ -26,15 +24,14 @@ type Page struct { func (p *Pagination) ParsePage(r *http.Request) { raw := r.FormValue("page") a, err := strconv.Atoi(raw) - log.Debug("ParsePage: %s %d err=%s", raw, a, err) if err == nil { - if a < 0 { + if a <= 0 { a = 1 } p.Page = a - log.Warn("set page1=%+v =XXXXX%d", p, a) + } else { + p.Page = 1 } - log.Warn("set page=%+v", p) } // Iter the pages, for templates. diff --git a/pkg/models/user.go b/pkg/models/user.go index d5fac36..56378db 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -101,11 +101,24 @@ type UserMap map[uint64]*User // 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. func MapUsers(userIDs []uint64) (UserMap, error) { - var usermap = UserMap{} + var ( + usermap = UserMap{} + set = map[uint64]interface{}{} + distinct = []uint64{} + ) + + // Uniqueify users. + for _, uid := range userIDs { + if _, ok := set[uid]; ok { + continue + } + set[uid] = nil + distinct = append(distinct, uid) + } var ( users = []*User{} - result = (&User{}).Preload().Where("id IN ?", userIDs).Find(&users) + result = (&User{}).Preload().Where("id IN ?", distinct).Find(&users) ) if result.Error == nil { diff --git a/pkg/router/router.go b/pkg/router/router.go index d7ac204..1ac9ff9 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/inbox" "git.kirsle.net/apps/gosocial/pkg/controller/index" "git.kirsle.net/apps/gosocial/pkg/controller/photo" "git.kirsle.net/apps/gosocial/pkg/middleware" @@ -31,6 +32,9 @@ func New() http.Handler { mux.Handle("/photo/edit", middleware.LoginRequired(photo.Edit())) mux.Handle("/photo/delete", middleware.LoginRequired(photo.Delete())) mux.Handle("/photo/certification", middleware.LoginRequired(photo.Certification())) + mux.Handle("/messages", middleware.LoginRequired(inbox.Inbox())) + mux.Handle("/messages/read/", middleware.LoginRequired(inbox.Inbox())) + mux.Handle("/messages/compose", middleware.LoginRequired(inbox.Compose())) // Certification Required. Pages that only full (verified) members can access. mux.Handle("/photo/gallery", middleware.CertRequired(photo.SiteGallery())) diff --git a/pkg/templates/template_funcs.go b/pkg/templates/template_funcs.go index 8e7bb6e..7ed857b 100644 --- a/pkg/templates/template_funcs.go +++ b/pkg/templates/template_funcs.go @@ -41,6 +41,12 @@ func TemplateFuncs(r *http.Request) template.FuncMap { return labels[1] } }, + "Substring": func(value string, n int) string { + if n > len(value) { + return value + } + return value[:n] + }, } } diff --git a/pkg/templates/template_vars.go b/pkg/templates/template_vars.go index 75bbec8..23a3620 100644 --- a/pkg/templates/template_vars.go +++ b/pkg/templates/template_vars.go @@ -5,6 +5,8 @@ import ( "time" "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" ) @@ -26,6 +28,7 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) { // Defaults m["LoggedIn"] = false m["CurrentUser"] = nil + m["NavUnreadMessages"] = 0 if r == nil { return @@ -34,5 +37,12 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) { if user, err := session.CurrentUser(r); err == nil { m["LoggedIn"] = true m["CurrentUser"] = user + + // Get unread message count. + if count, err := models.CountUnreadMessages(user.ID); err == nil { + m["NavUnreadMessages"] = count + } else { + log.Error("MergeUserVars: couldn't CountUnreadMessages for %d: %s", user.ID, err) + } } } diff --git a/web/templates/account/dashboard.html b/web/templates/account/dashboard.html index 3b7248a..03206e0 100644 --- a/web/templates/account/dashboard.html +++ b/web/templates/account/dashboard.html @@ -98,6 +98,12 @@ Edit Profile & Settings +
  • + + + Certification Photo + +
  • diff --git a/web/templates/account/profile.html b/web/templates/account/profile.html index 7cfc21c..2ef81f8 100644 --- a/web/templates/account/profile.html +++ b/web/templates/account/profile.html @@ -99,14 +99,14 @@

    - +

    diff --git a/web/templates/account/signup.html b/web/templates/account/signup.html index de45a4d..3fec865 100644 --- a/web/templates/account/signup.html +++ b/web/templates/account/signup.html @@ -11,8 +11,6 @@

    -
    {{.}}
    - {{if or .SkipEmailVerification (not .SignupToken)}}

    I'm glad you're thinking about joining us here! diff --git a/web/templates/admin/dashboard.html b/web/templates/admin/dashboard.html index ba8158d..1296a9b 100644 --- a/web/templates/admin/dashboard.html +++ b/web/templates/admin/dashboard.html @@ -23,7 +23,7 @@