diff --git a/pkg/config/page_sizes.go b/pkg/config/page_sizes.go
index 4c7103e..be334b8 100644
--- a/pkg/config/page_sizes.go
+++ b/pkg/config/page_sizes.go
@@ -12,6 +12,7 @@ var (
PageSizeMemberSearch = 60
PageSizeFriends = 12
PageSizeBlockList = 12
+ PageSizeMuteList = PageSizeBlockList
PageSizePrivatePhotoGrantees = 12
PageSizeAdminCertification = 20
PageSizeAdminFeedback = 20
diff --git a/pkg/controller/mutelist/mute.go b/pkg/controller/mutelist/mute.go
new file mode 100644
index 0000000..6360b45
--- /dev/null
+++ b/pkg/controller/mutelist/mute.go
@@ -0,0 +1,166 @@
+package mutelist
+
+import (
+ "net/http"
+ "strings"
+
+ "code.nonshy.com/nonshy/website/pkg/config"
+ "code.nonshy.com/nonshy/website/pkg/models"
+ "code.nonshy.com/nonshy/website/pkg/session"
+ "code.nonshy.com/nonshy/website/pkg/templates"
+)
+
+// Muted User list: view the list of muted accounts.
+func MuteList() http.HandlerFunc {
+ tmpl := templates.Must("account/mute_list.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
+ }
+
+ // Get our mutelist.
+ pager := &models.Pagination{
+ PerPage: config.PageSizeMuteList,
+ Sort: "updated_at desc",
+ }
+ pager.ParsePage(r)
+ muted, err := models.PaginateMuteList(currentUser, pager)
+ if err != nil {
+ session.FlashError(w, r, "Couldn't paginate mute list: %s", err)
+ templates.Redirect(w, "/")
+ return
+ }
+
+ var vars = map[string]interface{}{
+ "MutedUsers": muted,
+ "Pager": pager,
+ }
+ if err := tmpl.Execute(w, r, vars); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ })
+}
+
+// AddUser to manually add someone to your mute list.
+func AddUser() http.HandlerFunc {
+ tmpl := templates.Must("account/mute_list_add.html")
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Query parameters.
+ var (
+ username = strings.ToLower(r.FormValue("username"))
+ next = r.FormValue("next")
+ context = models.MutedUserContext(r.FormValue("context"))
+ listName = "Site Gallery" // TODO: more as contexts are added
+ )
+
+ // Validate the Next URL.
+ if !strings.HasPrefix(next, "/") {
+ next = "/users/muted"
+ }
+
+ // Validate acceptable contexts.
+ if !models.IsValidMuteUserContext(context) {
+ session.FlashError(w, r, "Unsupported mute context.")
+ templates.Redirect(w, next)
+ return
+ }
+
+ // Get the target user.
+ user, err := models.FindUser(username)
+ if err != nil {
+ session.FlashError(w, r, "User Not Found")
+ templates.Redirect(w, next)
+ return
+ }
+
+ vars := map[string]interface{}{
+ "User": user,
+ "Next": next,
+ "Context": context,
+ "MuteListName": listName,
+ }
+ if err := tmpl.Execute(w, r, vars); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ })
+}
+
+// MuteUser controller: POST endpoint to add a mute.
+func MuteUser() http.HandlerFunc {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Form fields
+ var (
+ username = strings.ToLower(r.PostFormValue("username"))
+ next = r.PostFormValue("next")
+ context = models.MutedUserContext(r.PostFormValue("context"))
+ listName = "Site Gallery" // TODO: more as contexts are added
+ unmute = r.PostFormValue("unmute") == "true"
+ )
+
+ // Validate the Next URL.
+ if !strings.HasPrefix(next, "/") {
+ next = "/users/muted"
+ }
+
+ // Validate acceptable contexts.
+ if !models.IsValidMuteUserContext(context) {
+ session.FlashError(w, r, "Unsupported mute context.")
+ templates.Redirect(w, "/")
+ return
+ }
+
+ // 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, next)
+ return
+ }
+
+ // Unmuting?
+ if unmute {
+ if err := models.RemoveMutedUser(currentUser.ID, user.ID, context); err != nil {
+ session.FlashError(w, r, "Couldn't unmute this user: %s.", err)
+ } else {
+ session.Flash(w, r, "You have removed %s from your %s mute list.", user.Username, listName)
+
+ // Log the change.
+ models.LogDeleted(currentUser, nil, "muted_users", user.ID, "Unmuted user "+user.Username+" from "+listName+".", nil)
+ }
+ templates.Redirect(w, next)
+ return
+ }
+
+ // Can't mute yourself.
+ if currentUser.ID == user.ID {
+ session.FlashError(w, r, "You can't mute yourself!")
+ templates.Redirect(w, next)
+ return
+ }
+
+ // Mute the target user.
+ if err := models.AddMutedUser(currentUser.ID, user.ID, context); err != nil {
+ session.FlashError(w, r, "Couldn't mute this user: %s.", err)
+ } else {
+ session.Flash(w, r, "You have added %s to your %s mute list.", user.Username, listName)
+
+ // Log the change.
+ models.LogCreated(currentUser, "muted_users", user.ID, "Mutes user "+user.Username+" on list "+listName+".")
+ }
+
+ templates.Redirect(w, next)
+ })
+}
diff --git a/pkg/models/deletion/delete_user.go b/pkg/models/deletion/delete_user.go
index c41e4cf..822e47c 100644
--- a/pkg/models/deletion/delete_user.go
+++ b/pkg/models/deletion/delete_user.go
@@ -51,6 +51,7 @@ func DeleteUser(user *models.User) error {
{"Messages", DeleteUserMessages},
{"Friends", DeleteFriends},
{"Blocks", DeleteBlocks},
+ {"MutedUsers", DeleteMutedUsers},
{"Feedbacks", DeleteFeedbacks},
{"Two Factor", DeleteTwoFactor},
{"Profile Fields", DeleteProfile},
@@ -227,6 +228,16 @@ func DeleteBlocks(userID uint64) error {
return result.Error
}
+// DeleteMutedUsers scrubs data for deleting a user.
+func DeleteMutedUsers(userID uint64) error {
+ log.Error("DeleteUser: DeleteMutedUsers(%d)", userID)
+ result := models.DB.Where(
+ "source_user_id = ? OR target_user_id = ?",
+ userID, userID,
+ ).Delete(&models.MutedUser{})
+ return result.Error
+}
+
// DeleteFeedbacks scrubs data for deleting a user.
func DeleteFeedbacks(userID uint64) error {
log.Error("DeleteUser: DeleteFeedbacks(%d)", userID)
diff --git a/pkg/models/exporting/models.go b/pkg/models/exporting/models.go
index 53b89a2..0745bad 100644
--- a/pkg/models/exporting/models.go
+++ b/pkg/models/exporting/models.go
@@ -32,6 +32,7 @@ func ExportModels(zw *zip.Writer, user *models.User) error {
{"IPAddress", ExportIPAddressTable},
{"Like", ExportLikeTable},
{"Message", ExportMessageTable},
+ {"MutedUser", ExportMutedUserTable},
{"Notification", ExportNotificationTable},
{"ProfileField", ExportProfileFieldTable},
{"Photo", ExportPhotoTable},
@@ -189,6 +190,21 @@ func ExportBlockTable(zw *zip.Writer, user *models.User) error {
return ZipJson(zw, "blocks.json", items)
}
+func ExportMutedUserTable(zw *zip.Writer, user *models.User) error {
+ var (
+ items = []*models.MutedUser{}
+ query = models.DB.Model(&models.MutedUser{}).Where(
+ "source_user_id = ?",
+ user.ID,
+ ).Find(&items)
+ )
+ if query.Error != nil {
+ return query.Error
+ }
+
+ return ZipJson(zw, "muted_users.json", items)
+}
+
func ExportFeedbackTable(zw *zip.Writer, user *models.User) error {
var (
items = []*models.Feedback{}
diff --git a/pkg/models/models.go b/pkg/models/models.go
index f2664b6..3b97f07 100644
--- a/pkg/models/models.go
+++ b/pkg/models/models.go
@@ -24,6 +24,7 @@ func AutoMigrate() {
&IPAddress{}, // ✔
&Like{}, // ✔
&Message{}, // ✔
+ &MutedUser{}, // ✔
&Notification{}, // ✔
&ProfileField{}, // ✔
&Photo{}, // ✔
diff --git a/pkg/models/muted_users.go b/pkg/models/muted_users.go
new file mode 100644
index 0000000..095b19f
--- /dev/null
+++ b/pkg/models/muted_users.go
@@ -0,0 +1,109 @@
+package models
+
+import (
+ "time"
+
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+// MutedUser table, for users to mute one another's Site Gallery photos and similar.
+type MutedUser struct {
+ ID uint64 `gorm:"primaryKey"`
+ SourceUserID uint64 `gorm:"uniqueIndex:idx_muted_user"`
+ TargetUserID uint64 `gorm:"uniqueIndex:idx_muted_user"`
+ Context MutedUserContext `gorm:"uniqueIndex:idx_muted_user"`
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+type MutedUserContext string
+
+// Context options for MutedUser to specify what is being muted.
+const (
+ MutedUserContextSiteGallery MutedUserContext = "site_gallery" // hide a user's photos from the Site Gallery.
+)
+
+// IsValidMuteUserContext validates acceptable options for muting users.
+func IsValidMuteUserContext(ctx MutedUserContext) bool {
+ return ctx == MutedUserContextSiteGallery
+}
+
+// AddMutedUser is sourceUserId adding targetUserId to their mute list under the given context.
+func AddMutedUser(sourceUserID, targetUserID uint64, ctx MutedUserContext) error {
+ m := &MutedUser{
+ SourceUserID: sourceUserID,
+ TargetUserID: targetUserID,
+ Context: ctx,
+ }
+
+ // Upsert the mute.
+ res := DB.Model(&MutedUser{}).Clauses(
+ clause.OnConflict{
+ Columns: []clause.Column{
+ {Name: "source_user_id"},
+ {Name: "target_user_id"},
+ {Name: "context"},
+ },
+ DoNothing: true,
+ },
+ ).Create(m)
+ return res.Error
+}
+
+// MutedUserIDs returns all user IDs Muted by the user.
+func MutedUserIDs(user *User, context MutedUserContext) []uint64 {
+ var (
+ ms = []*MutedUser{}
+ userIDs = []uint64{}
+ )
+ DB.Where("source_user_id = ? AND context = ?", user.ID, context).Find(&ms)
+ for _, row := range ms {
+ userIDs = append(userIDs, row.TargetUserID)
+ }
+ return userIDs
+}
+
+// PaginateMuteList views a user's mute lists.
+func PaginateMuteList(user *User, pager *Pagination) ([]*User, error) {
+ // We paginate over the MutedUser table.
+ var (
+ ms = []*MutedUser{}
+ userIDs = []uint64{}
+ query *gorm.DB
+ )
+
+ query = DB.Where(
+ "source_user_id = ?",
+ user.ID,
+ )
+
+ query = query.Order(pager.Sort)
+ query.Model(&MutedUser{}).Count(&pager.Total)
+ result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&ms)
+ if result.Error != nil {
+ return nil, result.Error
+ }
+
+ // Now of these friends get their User objects.
+ for _, b := range ms {
+ userIDs = append(userIDs, b.TargetUserID)
+ }
+
+ return GetUsers(user, userIDs)
+}
+
+// RemoveMutedUser clears the muted user row.
+func RemoveMutedUser(sourceUserID, targetUserID uint64, ctx MutedUserContext) error {
+ result := DB.Where(
+ "source_user_id = ? AND target_user_id = ? AND context = ?",
+ sourceUserID, targetUserID, ctx,
+ ).Delete(&MutedUser{})
+ return result.Error
+}
+
+// Save photo.
+func (m *MutedUser) Save() error {
+ result := DB.Save(m)
+ return result.Error
+}
diff --git a/pkg/models/photo.go b/pkg/models/photo.go
index be7d8c3..c1dae2e 100644
--- a/pkg/models/photo.go
+++ b/pkg/models/photo.go
@@ -682,6 +682,7 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
explicitOK = user.Explicit // User opted-in for explicit content
blocklist = BlockedUserIDs(user)
+ mutelist = MutedUserIDs(user, MutedUserContextSiteGallery)
privateUserIDs = PrivateGrantedUserIDs(userID)
privateUserIDsAreFriends = PrivateGrantedUserIDsAreFriends(user)
wheres = []string{}
@@ -790,6 +791,12 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
placeholders = append(placeholders, blocklist)
}
+ // Filter Site Gallery muted users.
+ if len(mutelist) > 0 {
+ wheres = append(wheres, "photos.user_id NOT IN ?")
+ placeholders = append(placeholders, mutelist)
+ }
+
// Non-explicit pics unless the user opted in. Allow explicit filter setting to override.
if filterExplicit != "" {
wheres = append(wheres, "photos.explicit = ?")
diff --git a/pkg/router/router.go b/pkg/router/router.go
index 7fcafc1..ff8b794 100644
--- a/pkg/router/router.go
+++ b/pkg/router/router.go
@@ -17,6 +17,7 @@ import (
"code.nonshy.com/nonshy/website/pkg/controller/htmx"
"code.nonshy.com/nonshy/website/pkg/controller/inbox"
"code.nonshy.com/nonshy/website/pkg/controller/index"
+ "code.nonshy.com/nonshy/website/pkg/controller/mutelist"
"code.nonshy.com/nonshy/website/pkg/controller/photo"
"code.nonshy.com/nonshy/website/pkg/controller/poll"
"code.nonshy.com/nonshy/website/pkg/middleware"
@@ -78,6 +79,9 @@ func New() http.Handler {
mux.Handle("POST /users/block", middleware.LoginRequired(block.BlockUser()))
mux.Handle("GET /users/blocked", middleware.LoginRequired(block.Blocked()))
mux.Handle("GET /users/blocklist/add", middleware.LoginRequired(block.AddUser()))
+ mux.Handle("GET /users/muted", middleware.LoginRequired(mutelist.MuteList()))
+ mux.Handle("GET /users/mutelist/add", middleware.LoginRequired(mutelist.AddUser()))
+ mux.Handle("POST /users/mutelist/add", middleware.LoginRequired(mutelist.MuteUser()))
mux.Handle("/comments", middleware.LoginRequired(comment.PostComment()))
mux.Handle("GET /comments/subscription", middleware.LoginRequired(comment.Subscription()))
mux.Handle("GET /admin/unimpersonate", middleware.LoginRequired(admin.Unimpersonate()))
diff --git a/pkg/templates/template_funcs.go b/pkg/templates/template_funcs.go
index 9ee1d08..879f5de 100644
--- a/pkg/templates/template_funcs.go
+++ b/pkg/templates/template_funcs.go
@@ -35,6 +35,7 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
"FormatNumberCommas": FormatNumberCommas(),
"ComputeAge": utility.Age,
"Split": strings.Split,
+ "NewHashMap": NewHashMap,
"ToMarkdown": ToMarkdown,
"DeMarkify": markdown.DeMarkify,
"ToJSON": ToJSON,
@@ -264,6 +265,30 @@ func SubtractInt64(a, b int64) int64 {
return a - b
}
+// NewHashMap creates a key/value dict on the fly for Go templates.
+//
+// Use it like: {{$Vars := NewHashMap "username" .CurrentUser.Username "photoID" .Photo.ID}}
+//
+// It is useful for calling Go subtemplates that need custom parameters, e.g. a
+// mixin from current scope with other variables.
+func NewHashMap(upsert ...interface{}) map[string]interface{} {
+ // Map the positional arguments into a dictionary.
+ var params = map[string]interface{}{}
+ for i := 0; i < len(upsert); i += 2 {
+ var (
+ key = fmt.Sprintf("%v", upsert[i])
+ value interface{}
+ )
+ if len(upsert) > i {
+ value = upsert[i+1]
+ }
+
+ params[key] = value
+ }
+
+ return params
+}
+
// UrlEncode escapes a series of values (joined with no delimiter)
func UrlEncode(values ...interface{}) string {
var result string
diff --git a/web/templates/account/block_list.html b/web/templates/account/block_list.html
index 7323e54..2179084 100644
--- a/web/templates/account/block_list.html
+++ b/web/templates/account/block_list.html
@@ -5,7 +5,10 @@
Blocked Users
+
+
+ Blocked Users
+
+ + Add to {{.MuteListName}} Mute List +
+{{.User.NameOrUsername}}
++ + {{.User.Username}} +
++ View and manage your mute lists. +
+ +