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 @@ diff --git a/web/templates/account/dashboard.html b/web/templates/account/dashboard.html index 95dcfe8..af4334c 100644 --- a/web/templates/account/dashboard.html +++ b/web/templates/account/dashboard.html @@ -234,6 +234,12 @@ Blocked Users +
  • + + + Muted Users + +
  • diff --git a/web/templates/account/mute_list.html b/web/templates/account/mute_list.html new file mode 100644 index 0000000..5441020 --- /dev/null +++ b/web/templates/account/mute_list.html @@ -0,0 +1,74 @@ +{{define "title"}}Muted Users{{end}} +{{define "content"}} +
    + {{$Root := .}} + + + +
    +{{end}} diff --git a/web/templates/account/mute_list_add.html b/web/templates/account/mute_list_add.html new file mode 100644 index 0000000..b7f4ebb --- /dev/null +++ b/web/templates/account/mute_list_add.html @@ -0,0 +1,77 @@ +{{define "title"}}Add to {{.MuteListName}} Mute List{{end}} +{{define "content"}} +
    + + + + +
    +{{end}} diff --git a/web/templates/account/settings.html b/web/templates/account/settings.html index a4f842b..acdfcf9 100644 --- a/web/templates/account/settings.html +++ b/web/templates/account/settings.html @@ -131,6 +131,15 @@
  • +
  • + + Muted Users +

    + View and manage your mute lists. +

    +
    +
  • +
  • My User Notes diff --git a/web/templates/photo/gallery.html b/web/templates/photo/gallery.html index f8facf3..4b9102f 100644 --- a/web/templates/photo/gallery.html +++ b/web/templates/photo/gallery.html @@ -88,6 +88,19 @@ {{end}} + + +{{define "mute-site-gallery"}} +
    + +
    +{{end}} + {{define "content"}} {{if not .IsSiteGallery}} @@ -615,6 +628,13 @@ {{end}} + + + {{if and $Root.IsSiteGallery (ne $Root.CurrentUser.ID .UserID) ($Root.UserMap.Has .UserID)}} + {{$Owner := $Root.UserMap.Get .UserID}} + {{$SubVariables := NewHashMap "User" $Owner "Request" $Root.Request}} + {{template "mute-site-gallery" $SubVariables}} + {{end}}