Mute Users from the Site Gallery

This commit is contained in:
Noah Petherbridge 2024-12-05 21:49:19 -08:00
parent 3416d647fc
commit 9db4dbd1e7
15 changed files with 537 additions and 1 deletions

View File

@ -12,6 +12,7 @@ var (
PageSizeMemberSearch = 60
PageSizeFriends = 12
PageSizeBlockList = 12
PageSizeMuteList = PageSizeBlockList
PageSizePrivatePhotoGrantees = 12
PageSizeAdminCertification = 20
PageSizeAdminFeedback = 20

View File

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

View File

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

View File

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

View File

@ -24,6 +24,7 @@ func AutoMigrate() {
&IPAddress{}, // ✔
&Like{}, // ✔
&Message{}, // ✔
&MutedUser{}, // ✔
&Notification{}, // ✔
&ProfileField{}, // ✔
&Photo{}, // ✔

109
pkg/models/muted_users.go Normal file
View File

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

View File

@ -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 = ?")

View File

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

View File

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

View File

@ -5,7 +5,10 @@
<section class="hero is-link is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">Blocked Users</h1>
<h1 class="title">
<i class="fa fa-hand mr-2"></i>
Blocked Users
</h1>
</div>
</div>
</section>

View File

@ -234,6 +234,12 @@
Blocked Users
</a>
</li>
<li>
<a href="/users/muted">
<span class="icon"><i class="fa fa-eye-slash"></i></span>
Muted Users
</a>
</li>
<li>
<a href="/logout">
<span class="icon"><i class="fa fa-arrow-right-from-bracket"></i></span>

View File

@ -0,0 +1,74 @@
{{define "title"}}Muted Users{{end}}
{{define "content"}}
<div class="container">
{{$Root := .}}
<section class="hero is-link is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">
<i class="fa fa-eye-slash mr-2"></i>
Muted Users
</h1>
</div>
</div>
</section>
<div class="p-4">
<div class="block">
This page lists members of {{PrettyTitle}} who you have "muted" so that their content
will not appear to you in certain areas of the site. Currently, only <strong>Site Gallery</strong>
mutes exist: so you can hide somebody's pictures from ever appearing to you on the Site Gallery.
In the future, more kinds of mute lists may be available to manage from this page.
</div>
<div class="block">
You have muted {{.Pager.Total}} user{{Pluralize64 .Pager.Total}}
(page {{.Pager.Page}} of {{.Pager.Pages}}).
</div>
<div class="block">
{{SimplePager .Pager}}
</div>
<div class="columns is-multiline">
{{range .MutedUsers}}
<div class="column is-half-tablet is-one-third-desktop">
<form action="/users/mutelist/add" method="POST">
{{InputCSRF}}
<input type="hidden" name="username" value="{{.Username}}">
<input type="hidden" name="context" value="site_gallery">
<div class="card">
<div class="card-content">
<div class="media block">
<div class="media-left">
{{template "avatar-64x64" .}}
</div>
<div class="media-content">
<p class="title is-4">{{.NameOrUsername}}</p>
<p class="subtitle is-6">
<span class="icon"><i class="fa fa-user"></i></span>
<a href="/u/{{.Username}}">{{.Username}}</a>
</p>
</div>
</div>
</div>
<footer class="card-footer">
<button type="submit" name="unmute" value="true" class="card-footer-item button is-danger">
<span class="icon"><i class="fa fa-xmark"></i></span>
<span>Unmute User</span>
</button>
</footer>
</div>
</form>
</div>
{{end}}<!-- range .MutedUsers -->
</div>
</div>
</div>
{{end}}

View File

@ -0,0 +1,77 @@
{{define "title"}}Add to {{.MuteListName}} Mute List{{end}}
{{define "content"}}
<div class="container">
<section class="hero is-link is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">
<i class="fa fa-eye-slash mr-2"></i>
Add to {{.MuteListName}} Mute List
</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 mr-2"><i class="fa fa-eye-slash"></i></span>
<span>Add to {{.MuteListName}} Mute List</span>
</p>
</header>
<div class="card-content">
<div class="block">
Confirm that you wish to add <strong>{{.User.Username}}</strong>
to your <strong>{{.MuteListName}}</strong> mute list by clicking the button below.
</div>
<div class="media block">
<div class="media-left">
{{template "avatar-64x64" .User}}
</div>
<div class="media-content">
<p class="title is-4">{{.User.NameOrUsername}}</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="/users/mutelist/add" method="POST">
{{InputCSRF}}
<input type="hidden" name="username" value="{{.User.Username}}">
<input type="hidden" name="context" value="{{.Context}}">
<input type="hidden" name="next" value="{{.Next}}">
<!-- Explain what will happen -->
{{if eq .Context "site_gallery"}}
<div class="block">
By continuing, <strong>you will no longer see <a href="/u/{{.User.Username}}">{{.User.Username}}</a>'s photos appear on the site-wide Photo Gallery.</strong>
You may still see their photos when looking at their own Photo Gallery directly, from their profile page.
</div>
{{end}}
<div class="field has-text-centered">
<button type="submit" class="button is-success">
Continue
</button>
<a href="{{.Next}}" class="button">
Cancel
</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}

View File

@ -131,6 +131,15 @@
</a>
</li>
<li>
<a href="/users/muted">
<strong><i class="fa fa-eye-slash mr-1"></i> Muted Users</strong>
<p class="help">
View and manage your mute lists.
</p>
</a>
</li>
<li>
<a href="/notes/me">
<strong><i class="fa fa-pen-to-square mr-1"></i> My User Notes</strong>

View File

@ -88,6 +88,19 @@
</a>
{{end}}
<!-- Reusable "mute site gallery" component -->
<!-- Parameter: the photo owner User object -->
{{define "mute-site-gallery"}}
<div class="columns is-centered is-mobile">
<div class="column is-narrow">
<a href="/users/mutelist/add?username={{.User.Username}}&context=site_gallery&next={{.Request.URL.String}}" class="has-text-grey is-size-7">
<i class="fa fa-eye-slash"></i>
Don't show {{.User.Username}}'s photos
</a>
</div>
</div>
{{end}}
<!-- Main content template -->
{{define "content"}}
{{if not .IsSiteGallery}}
@ -615,6 +628,13 @@
</div>
</div>
{{end}}
<!-- Mute this user from Site Gallery -->
{{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}}
</div>
<footer class="card-footer">
@ -740,6 +760,13 @@
</div>
</div>
{{end}}
<!-- Mute this user from Site Gallery -->
{{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}}
</div>
<footer class="card-footer">