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