Implement Direct Messaging

This commit is contained in:
Noah 2022-08-13 17:42:42 -07:00
parent f6d076f7c2
commit c8f6cf2e4d
17 changed files with 624 additions and 19 deletions

View File

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

View File

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

View File

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

89
pkg/models/message.go Normal file
View File

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

View File

@ -12,4 +12,5 @@ func AutoMigrate() {
DB.AutoMigrate(&ProfileField{})
DB.AutoMigrate(&Photo{})
DB.AutoMigrate(&CertificationPhoto{})
DB.AutoMigrate(&Message{})
}

View File

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

View File

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

View File

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

View File

@ -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]
},
}
}

View File

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

View File

@ -98,6 +98,12 @@
Edit Profile &amp; Settings
</a>
</li>
<li>
<a href="/photo/certification">
<span class="icon"><i class="fa fa-certificate"></i></span>
Certification Photo
</a>
</li>
<li>
<a href="/logout">
<span class="icon"><i class="fa fa-arrow-right-from-bracket"></i></span>

View File

@ -99,14 +99,14 @@
</p>
<p class="control">
<button type="button" class="button">
<a href="/messages/compose?to={{.User.Username}}" class="button">
<span class="icon-text">
<span class="icon">
<i class="fa fa-message"></i>
</span>
<span>Message</span>
</span>
</button>
</a>
</p>
<p class="control">

View File

@ -11,8 +11,6 @@
<div class="block content p-4">
<pre>{{.}}</pre>
{{if or .SkipEmailVerification (not .SignupToken)}}
<p>
I'm glad you're thinking about joining us here!

View File

@ -23,7 +23,7 @@
<ul class="menu-list">
<li>
<a href="/admin/photo/certification">
<span class="icon"><i class="fa fa-badge"></i></span>
<span class="icon"><i class="fa fa-certificate"></i></span>
Certification Photos
</a>
</li>

View File

@ -56,13 +56,13 @@
<a class="navbar-item" href="/friends">
<span class="icon"><i class="fa fa-user-group"></i></span>
<span>Friends</span>
<span class="tag is-warning">42</span>
<!-- <span class="tag is-warning">42</span> -->
</a>
<a class="navbar-item" href="/messages">
<span class="icon"><i class="fa fa-envelope"></i></span>
<span>Messages</span>
<span class="tag is-warning">42</span>
{{if .NavUnreadMessages}}<span class="tag is-warning">{{.NavUnreadMessages}}</span>{{end}}
</a>
{{end}}

View File

@ -0,0 +1,89 @@
{{define "title"}}Compose a Message{{end}}
{{define "content"}}
<div class="container">
<section class="hero is-info is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">
Write to: {{.User.Username}}
</h1>
</div>
</div>
</section>
<div class="block p-4">
<div class="columns is-centered">
<div class="column is-half">
<div class="card" style="width: 100%; max-width: 640px">
<header class="card-header has-background-link">
<p class="card-header-title has-text-light">
<span class="icon"><i class="fa fa-message"></i></span>
Compose a Message
</p>
</header>
<div class="card-content">
<div class="media block">
<div class="media-left">
<figure class="image is-64x64">
{{if .User.ProfilePhoto.ID}}
<img src="{{PhotoURL .User.ProfilePhoto.CroppedFilename}}">
{{else}}
<img src="/static/img/shy.png">
{{end}}
</figure>
</div>
<div class="media-content">
<p class="title is-4">{{or .User.Name "(no name)"}}</p>
<p class="subtitle is-6">
<span class="icon"><i class="fa fa-user"></i></span>
<a href="/u/{{.User.Username}}" target="_blank">{{.User.Username}}</a>
</p>
</div>
</div>
<form action="/messages/compose" method="POST">
{{InputCSRF}}
<input type="hidden" name="to" value="{{.User.Username}}">
<div class="field block">
<label for="message" class="label">Message</label>
<textarea class="textarea" cols="80" rows="8"
name="message"
id="message"
required
placeholder="Message"></textarea>
<p class="help">
Markdown formatting supported.
</p>
</div>
<div class="field has-text-centered">
<button type="submit" class="button is-success">
Send Message
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<script type="text/javascript">
window.addEventListener("DOMContentLoaded", (event) => {
let $file = document.querySelector("#file"),
$fileName = document.querySelector("#fileName");
$file.addEventListener("change", function() {
let file = this.files[0];
$fileName.innerHTML = file.name;
});
});
</script>
{{end}}

View File

@ -0,0 +1,182 @@
{{define "title"}}Message Inbox{{end}}
{{define "content"}}
<div class="container">
<section class="hero is-info is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">Messages</h1>
<h2 class="subtitle">Inbox</h2>
</div>
</div>
</section>
{{$UserMap := .UserMap}}
{{$Request := .Request}}
<div class="block p-4">
<div class="columns">
<div class="column">
<div class="card">
<header class="card-header has-background-link">
<p class="card-header-title has-text-light">
{{if .ViewThread}}Conversation with {{.ReplyTo}}{{else}}Inbox{{end}}
</p>
</header>
{{if .ViewThread}}
<div class="card-content">
<div class="block">
<form action="/messages/compose" method="POST">
{{InputCSRF}}
<input type="hidden" name="to" value="{{.ReplyTo}}">
<input type="hidden" name="from" value="inbox">
<textarea class="textarea block" cols="80" rows="4"
name="message"
placeholder="Write a response"></textarea>
<button type="submit" class="button is-success">Send Reply</button>
</form>
<hr>
</div>
{{range .ViewThread}}
<div class="media block">
{{$SourceUser := $UserMap.Get .SourceUserID}}
<div class="media-left">
<figure class="image is-64x64">
{{if $SourceUser.ProfilePhoto.ID}}
<img src="{{PhotoURL $SourceUser.ProfilePhoto.CroppedFilename}}">
{{else}}
<img src="/static/img/shy.png">
{{end}}
</figure>
</div>
<div class="media-content">
<p class="title is-4">{{or $SourceUser.Name "(no name)"}}</p>
<p class="subtitle is-6">
<span class="icon"><i class="fa fa-user"></i></span>
<a href="/u/{{$SourceUser.Username}}">{{$SourceUser.Username}}</a>
{{if not $SourceUser.Certified}}
<span class="has-text-danger">
<span class="icon"><i class="fa fa-certificate"></i></span>
<span>Not Certified!</span>
</span>
{{end}}
{{if $SourceUser.IsAdmin}}
<span class="has-text-danger">
<span class="icon"><i class="fa fa-gavel"></i></span>
<span>Admin</span>
</span>
{{end}}
</p>
</div>
</div>
<div class="block content">
{{ToMarkdown .Message}}
</div>
<div class="block">
<em>Sent on {{.CreatedAt.Format "2006-01-02 15:04:05"}}</em>
</div>
<hr class="block">
{{end}}
<!-- Pager footer -->
<div class="block">
<div class="level">
<div class="level-left">
<div>
Found <strong>{{.ThreadPager.Total}}</strong> message{{Pluralize64 .ThreadPager.Total}} in this thread
(page {{.ThreadPager.Page}} of {{.ThreadPager.Pages}}).
</div>
</div>
<div class="level-right">
{{if .ThreadPager.HasPrevious}}
<a href="{{$Request.URL.Path}}?page={{.ThreadPager.Previous}}" class="button">Previous</a>
{{end}}
{{if .ThreadPager.HasNext}}
<a href="{{$Request.URL.Path}}?page={{.ThreadPager.Next}}" class="button">Next Page</a>
{{end}}
</div>
</div>
</div>
</div>
{{else}}
<div class="card-content content">
<p>
You have <strong>{{.Unread}}</strong> unread message{{Pluralize64 .Unread}}. Select a message on the
other column to read the conversation here.
</p>
</div>
{{end}}
</div>
</div>
<div class="column is-one-third">
<div class="card block">
<header class="card-header has-background-link">
<p class="card-header-title has-text-light">Messages</p>
</header>
<div class="card-content">
<div class="tabs is-toggle">
<ul>
<li{{if not .IsSentBox}} class="is-active"{{end}}>
<a href="/messages">Inbox</a>
</li>
<li{{if .IsSentBox}} class="is-active"{{end}}>
<a href="/messages?box=sent">Sent</a>
</li>
</ul>
</div>
<ul class="menu-list block">
{{$IsSentBox := .IsSentBox}}
{{range .Messages}}
<li>
<a href="/messages/read/{{.ID}}">
<div>
{{if $IsSentBox}}
{{$User := $UserMap.Get .TargetUserID}}
<strong>Sent to {{$User.Username}}</strong>
{{else}}
{{$User := $UserMap.Get .SourceUserID}}
<strong>From {{$User.Username}}</strong>
{{end}}
{{if not .Read}}<span class="tag is-success">NEW</span>{{end}}
</div>
<div class="my-1">
<em>
{{Substring .Message 48}}…
</em>
</div>
<div>
Sent {{.CreatedAt.Format "2006-01-02 15:04:05"}}
</div>
</a>
</li>
{{end}}
</ul>
<!-- Pager footer -->
<div class="block">
<div>
Found <strong>{{.Pager.Total}}</strong> message{{Pluralize64 .Pager.Total}}
(page {{.Pager.Page}} of {{.Pager.Pages}}).
</div>
{{if .Pager.HasPrevious}}
<a href="/messages?{{if .IsSentBox}}box=sent&{{end}}page={{.Pager.Previous}}" class="button">Previous</a>
{{end}}
{{if .Pager.HasNext}}
<a href="/messages?{{if .IsSentBox}}box=sent&{{end}}page={{.Pager.Next}}" class="button">Next Page</a>
{{end}}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}