From 5543fd53400037dc8ae22d4919b7085c46177ce1 Mon Sep 17 00:00:00 2001
From: tobi <31960611+tsmethurst@users.noreply.github.com>
Date: Mon, 9 Sep 2024 18:07:25 +0200
Subject: [PATCH] [feature/frontend] Add options to include Unlisted posts or
hide all posts (#3272)
* [feature/frontend] Add options to include Unlisted posts or hide all posts
* finish up
* swagger
* move invalidate call into bundb package, avoid invalidating if not necessary
* rename show_web_statuses => web_visibility
* don't use ptr for webvisibility
* last bits
---
docs/api/swagger.yaml | 16 +
docs/user_guide/settings.md | 20 +
internal/api/client/accounts/accountupdate.go | 12 +-
internal/api/model/account.go | 3 +
internal/api/model/source.go | 5 +
internal/api/model/status.go | 2 +
internal/db/account.go | 7 +-
internal/db/bundb/account.go | 67 +++-
.../20240906144432_unauthed_visibility.go.go | 69 ++++
internal/filter/visibility/account.go | 2 +-
internal/filter/visibility/filter.go | 4 +-
internal/filter/visibility/home_timeline.go | 2 +-
internal/filter/visibility/public_timeline.go | 2 +-
internal/filter/visibility/status.go | 64 +++-
internal/gtsmodel/accountsettings.go | 5 +-
internal/gtsmodel/status.go | 3 +
internal/processing/account/rss.go | 2 +-
internal/processing/account/statuses.go | 14 +-
internal/processing/account/update.go | 356 +++++++++++-------
internal/typeutils/frontendtointernal.go | 2 +
internal/typeutils/internaltofrontend.go | 1 +
internal/typeutils/internaltofrontend_test.go | 2 +
testrig/testmodels.go | 4 +
web/source/settings/views/user/profile.tsx | 20 +-
24 files changed, 523 insertions(+), 161 deletions(-)
create mode 100644 internal/db/bundb/migrations/20240906144432_unauthed_visibility.go.go
diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml
index 8d11ba7da..07c939188 100644
--- a/docs/api/swagger.yaml
+++ b/docs/api/swagger.yaml
@@ -157,6 +157,14 @@ definitions:
description: The default posting content type for new statuses.
type: string
x-go-name: StatusContentType
+ web_visibility:
+ description: |-
+ Visibility level(s) of posts to show for this account via the web api.
+ "public" = default, show only Public visibility posts on the web.
+ "unlisted" = show Public *and* Unlisted visibility posts on the web.
+ "none" = show no posts on the web, not even Public ones.
+ type: string
+ x-go-name: WebVisibility
title: Source represents display or publishing preferences of user's own account.
type: object
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
@@ -4400,6 +4408,14 @@ paths:
in: formData
name: hide_collections
type: boolean
+ - description: |-
+ Posts to show on the web view of the account.
+ "public": default, show only Public visibility posts on the web.
+ "unlisted": show Public *and* Unlisted visibility posts on the web.
+ "none": show no posts on the web, not even Public ones.
+ in: formData
+ name: web_visibility
+ type: string
- description: Name of 1st profile field to be added to this account's profile. (The index may be any string; add more indexes to send more fields.)
in: formData
name: fields_attributes[0][name]
diff --git a/docs/user_guide/settings.md b/docs/user_guide/settings.md
index ed069c19c..66452578d 100644
--- a/docs/user_guide/settings.md
+++ b/docs/user_guide/settings.md
@@ -76,6 +76,26 @@ Some examples:
### Visibility and Privacy
+#### Visibility Level of Posts to Show on Your Profile
+
+Using this dropdown, you can choose what visibility level(s) of posts should be shown on the public web view of your profile, and served in your RSS feed (if you have enabled RSS).
+
+**By default, GoToSocial shows only Public visibility posts on the web view of your profile, not Unlisted.** You can adjust this setting to also show Unlisted visibility posts on your profile, which is similar to the default for other ActivityPub softwares like Mastodon etc.
+
+You can also choose to show no posts at all on the web view of your profile. This allows you to write posts without having to worry about scrapers, rubberneckers, and other nosy parkers visiting your web profile and looking at your posts.
+
+This setting does not affect visibility of your posts over the ActivityPub protocol, so even if you choose to show no posts on your public web profile, others will be able to see your posts in their client if they follow you, and/or have your posts boosted onto their timeline, use a link to search a post of yours, etc.
+
+!!! warning
+ Be aware that changes to this setting also apply retroactively.
+
+ That is, if you previously made a post on Unlisted visibility, while set to show only Public posts on your profile, and you change this setting to show Public and Unlisted, then the Unlisted post you previously made will be visible on your profile alongside your Public posts.
+
+ Likewise, if you change this setting to show no posts, then all your posts will be hidden from your profile, regardless of when you created them, and what this option was set to at the time. This will apply until you change this setting again.
+
+!!! tip
+ Alongside (domain-)blocking, this is a good "emergency" setting to use if you're facing harassment from people trawling through your public posts. It won't hide your posts from people who can see them in their clients, via ActivityPub, but it will at least prevent them from being able to click through your posts in their browser with no authentication, and easily share them with others with a URL.
+
#### Manually Approve Follow Requests (aka Lock Your Account)
This checkbox allows you to decide whether or not you want to manually review follow requests to your account.
diff --git a/internal/api/client/accounts/accountupdate.go b/internal/api/client/accounts/accountupdate.go
index f81f54db0..5d3a3da5f 100644
--- a/internal/api/client/accounts/accountupdate.go
+++ b/internal/api/client/accounts/accountupdate.go
@@ -145,6 +145,15 @@
// description: Hide the account's following/followers collections.
// type: boolean
// -
+// name: web_visibility
+// in: formData
+// description: |-
+// Posts to show on the web view of the account.
+// "public": default, show only Public visibility posts on the web.
+// "unlisted": show Public *and* Unlisted visibility posts on the web.
+// "none": show no posts on the web, not even Public ones.
+// type: string
+// -
// name: fields_attributes[0][name]
// in: formData
// description: Name of 1st profile field to be added to this account's profile.
@@ -339,7 +348,8 @@ func parseUpdateAccountForm(c *gin.Context) (*apimodel.UpdateCredentialsRequest,
form.Theme == nil &&
form.CustomCSS == nil &&
form.EnableRSS == nil &&
- form.HideCollections == nil) {
+ form.HideCollections == nil &&
+ form.WebVisibility == nil) {
return nil, errors.New("empty form submitted")
}
diff --git a/internal/api/model/account.go b/internal/api/model/account.go
index 0eaf52734..d34d7d519 100644
--- a/internal/api/model/account.go
+++ b/internal/api/model/account.go
@@ -227,6 +227,9 @@ type UpdateCredentialsRequest struct {
EnableRSS *bool `form:"enable_rss" json:"enable_rss"`
// Hide this account's following/followers collections.
HideCollections *bool `form:"hide_collections" json:"hide_collections"`
+ // Visibility of statuses to show via the web view.
+ // "none", "public" (default), or "unlisted" (which includes public as well).
+ WebVisibility *string `form:"web_visibility" json:"web_visibility"`
}
// UpdateSource is to be used specifically in an UpdateCredentialsRequest.
diff --git a/internal/api/model/source.go b/internal/api/model/source.go
index 3b57f8565..cc3eb78ee 100644
--- a/internal/api/model/source.go
+++ b/internal/api/model/source.go
@@ -26,6 +26,11 @@ type Source struct {
// private = Followers-only post
// direct = Direct post
Privacy Visibility `json:"privacy"`
+ // Visibility level(s) of posts to show for this account via the web api.
+ // "public" = default, show only Public visibility posts on the web.
+ // "unlisted" = show Public *and* Unlisted visibility posts on the web.
+ // "none" = show no posts on the web, not even Public ones.
+ WebVisibility Visibility `json:"web_visibility"`
// Whether new statuses should be marked sensitive by default.
Sensitive bool `json:"sensitive"`
// The default posting language for new statuses.
diff --git a/internal/api/model/status.go b/internal/api/model/status.go
index d0acafae8..9b83fa582 100644
--- a/internal/api/model/status.go
+++ b/internal/api/model/status.go
@@ -232,6 +232,8 @@ type StatusCreateRequest struct {
type Visibility string
const (
+ // VisibilityNone is visible to nobody. This is only used for the visibility of web statuses.
+ VisibilityNone Visibility = "none"
// VisibilityPublic is visible to everyone, and will be available via the web even for nonauthenticated users.
VisibilityPublic Visibility = "public"
// VisibilityUnlisted is visible to everyone, but only on home timelines, lists, etc.
diff --git a/internal/db/account.go b/internal/db/account.go
index 45a4ccc09..225c8e1d2 100644
--- a/internal/db/account.go
+++ b/internal/db/account.go
@@ -117,12 +117,11 @@ type Account interface {
// In the case of no statuses, this function will return db.ErrNoEntries.
GetAccountPinnedStatuses(ctx context.Context, accountID string) ([]*gtsmodel.Status, error)
- // GetAccountWebStatuses is similar to GetAccountStatuses, but it's specifically for returning statuses that
- // should be visible via the web view of an account. So, only public, federated statuses that aren't boosts
- // or replies.
+ // GetAccountWebStatuses is similar to GetAccountStatuses, but it's specifically for
+ // returning statuses that should be visible via the web view of a *LOCAL* account.
//
// In the case of no statuses, this function will return db.ErrNoEntries.
- GetAccountWebStatuses(ctx context.Context, accountID string, limit int, maxID string) ([]*gtsmodel.Status, error)
+ GetAccountWebStatuses(ctx context.Context, account *gtsmodel.Account, limit int, maxID string) ([]*gtsmodel.Status, error)
// SetAccountHeaderOrAvatar sets the header or avatar for the given accountID to the given media attachment.
SetAccountHeaderOrAvatar(ctx context.Context, mediaAttachment *gtsmodel.MediaAttachment, accountID string) error
diff --git a/internal/db/bundb/account.go b/internal/db/bundb/account.go
index d8ec26291..1569af9cb 100644
--- a/internal/db/bundb/account.go
+++ b/internal/db/bundb/account.go
@@ -1047,7 +1047,18 @@ func (a *accountDB) GetAccountPinnedStatuses(ctx context.Context, accountID stri
return a.state.DB.GetStatusesByIDs(ctx, statusIDs)
}
-func (a *accountDB) GetAccountWebStatuses(ctx context.Context, accountID string, limit int, maxID string) ([]*gtsmodel.Status, error) {
+func (a *accountDB) GetAccountWebStatuses(
+ ctx context.Context,
+ account *gtsmodel.Account,
+ limit int,
+ maxID string,
+) ([]*gtsmodel.Status, error) {
+ // Check for an easy case: account exposes no statuses via the web.
+ webVisibility := account.Settings.WebVisibility
+ if webVisibility == gtsmodel.VisibilityNone {
+ return nil, db.ErrNoEntries
+ }
+
// Ensure reasonable
if limit < 0 {
limit = 0
@@ -1061,14 +1072,36 @@ func (a *accountDB) GetAccountWebStatuses(ctx context.Context, accountID string,
TableExpr("? AS ?", bun.Ident("statuses"), bun.Ident("status")).
// Select only IDs from table
Column("status.id").
- Where("? = ?", bun.Ident("status.account_id"), accountID).
+ Where("? = ?", bun.Ident("status.account_id"), account.ID).
// Don't show replies or boosts.
Where("? IS NULL", bun.Ident("status.in_reply_to_uri")).
- Where("? IS NULL", bun.Ident("status.boost_of_id")).
+ Where("? IS NULL", bun.Ident("status.boost_of_id"))
+
+ // Select statuses for this account according
+ // to their web visibility preference.
+ switch webVisibility {
+
+ case gtsmodel.VisibilityPublic:
// Only Public statuses.
- Where("? = ?", bun.Ident("status.visibility"), gtsmodel.VisibilityPublic).
- // Don't show local-only statuses on the web view.
- Where("? = ?", bun.Ident("status.federated"), true)
+ q = q.Where("? = ?", bun.Ident("status.visibility"), gtsmodel.VisibilityPublic)
+
+ case gtsmodel.VisibilityUnlocked:
+ // Public or Unlocked.
+ visis := []gtsmodel.Visibility{
+ gtsmodel.VisibilityPublic,
+ gtsmodel.VisibilityUnlocked,
+ }
+ q = q.Where("? IN (?)", bun.Ident("status.visibility"), bun.In(visis))
+
+ default:
+ return nil, gtserror.Newf(
+ "unrecognized web visibility for account %s: %s",
+ account.ID, webVisibility,
+ )
+ }
+
+ // Don't show local-only statuses on the web view.
+ q = q.Where("? = ?", bun.Ident("status.federated"), true)
// return only statuses LOWER (ie., older) than maxID
if maxID == "" {
@@ -1145,10 +1178,30 @@ func (a *accountDB) UpdateAccountSettings(
) error {
return a.state.Caches.DB.AccountSettings.Store(settings, func() error {
settings.UpdatedAt = time.Now()
- if len(columns) > 0 {
+
+ switch {
+
+ case len(columns) != 0:
// If we're updating by column,
// ensure "updated_at" is included.
columns = append(columns, "updated_at")
+
+ // If we're updating web_visibility we should
+ // fall through + invalidate visibility cache.
+ if !slices.Contains(columns, "web_visibility") {
+ break // No need to invalidate.
+ }
+
+ // Fallthrough
+ // to invalidate.
+ fallthrough
+
+ case len(columns) == 0:
+ // Status visibility may be changing for this account.
+ // Clear the visibility cache for unauthed requesters.
+ //
+ // todo: invalidate JUST this account's statuses.
+ defer a.state.Caches.Visibility.Clear()
}
if _, err := a.db.
diff --git a/internal/db/bundb/migrations/20240906144432_unauthed_visibility.go.go b/internal/db/bundb/migrations/20240906144432_unauthed_visibility.go.go
new file mode 100644
index 000000000..473783790
--- /dev/null
+++ b/internal/db/bundb/migrations/20240906144432_unauthed_visibility.go.go
@@ -0,0 +1,69 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see
tags. - fieldFormatValueResult := p.formatter.FromPlainNoParagraph(ctx, p.parseMention, account.ID, "", fieldRaw.Value) - field.Value = fieldFormatValueResult.HTML - - // Retrieve field emojis. - for _, emoji := range fieldFormatValueResult.Emojis { - emojis[emoji.ID] = emoji - } - - // We're done, append the shiny new field. - account.Fields = append(account.Fields, field) - } - - emojisCount := len(emojis) - account.Emojis = make([]*gtsmodel.Emoji, 0, emojisCount) - account.EmojiIDs = make([]string, 0, emojisCount) - - for id, emoji := range emojis { - account.Emojis = append(account.Emojis, emoji) - account.EmojiIDs = append(account.EmojiIDs, id) - } + if textChanged { + // Process display name, note, fields, + // and any concomitant emoji changes. + p.processAccountText(ctx, account) + acctColumns = append(acctColumns, "emojis") } if form.AvatarDescription != nil { desc := text.SanitizeToPlaintext(*form.AvatarDescription) - form.AvatarDescription = util.Ptr(desc) + form.AvatarDescription = &desc } if form.Avatar != nil && form.Avatar.Size != 0 { @@ -220,7 +160,7 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form } account.AvatarMediaAttachmentID = avatarInfo.ID account.AvatarMediaAttachment = avatarInfo - log.Tracef(ctx, "new avatar info for account %s is %+v", account.ID, avatarInfo) + acctColumns = append(acctColumns, "avatar_media_attachment_id") } else if form.AvatarDescription != nil && account.AvatarMediaAttachment != nil { // Update just existing description if possible. account.AvatarMediaAttachment.Description = *form.AvatarDescription @@ -250,7 +190,7 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form } account.HeaderMediaAttachmentID = headerInfo.ID account.HeaderMediaAttachment = headerInfo - log.Tracef(ctx, "new header info for account %s is %+v", account.ID, headerInfo) + acctColumns = append(acctColumns, "header_media_attachment_id") } else if form.HeaderDescription != nil && account.HeaderMediaAttachment != nil { // Update just existing description if possible. account.HeaderMediaAttachment.Description = *form.HeaderDescription @@ -264,29 +204,32 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form } } - if form.Locked != nil { - account.Locked = form.Locked - } + // Account settings flags. if form.Source != nil { if form.Source.Language != nil { language, err := validate.Language(*form.Source.Language) if err != nil { - return nil, gtserror.NewErrorBadRequest(err) + return nil, gtserror.NewErrorBadRequest(err, err.Error()) } + account.Settings.Language = language + settingsColumns = append(settingsColumns, "language") } if form.Source.Sensitive != nil { account.Settings.Sensitive = form.Source.Sensitive + settingsColumns = append(settingsColumns, "sensitive") } if form.Source.Privacy != nil { if err := validate.Privacy(*form.Source.Privacy); err != nil { - return nil, gtserror.NewErrorBadRequest(err) + return nil, gtserror.NewErrorBadRequest(err, err.Error()) } - privacy := typeutils.APIVisToVis(apimodel.Visibility(*form.Source.Privacy)) - account.Settings.Privacy = privacy + + priv := apimodel.Visibility(*form.Source.Privacy) + account.Settings.Privacy = typeutils.APIVisToVis(priv) + settingsColumns = append(settingsColumns, "privacy") } if form.Source.StatusContentType != nil { @@ -295,6 +238,7 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form } account.Settings.StatusContentType = *form.Source.StatusContentType + settingsColumns = append(settingsColumns, "status_content_type") } } @@ -312,6 +256,7 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form } account.Settings.Theme = theme } + settingsColumns = append(settingsColumns, "theme") } if form.CustomCSS != nil { @@ -319,25 +264,54 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form if err := validate.CustomCSS(customCSS); err != nil { return nil, gtserror.NewErrorBadRequest(err, err.Error()) } + account.Settings.CustomCSS = text.SanitizeToPlaintext(customCSS) + settingsColumns = append(settingsColumns, "custom_css") } if form.EnableRSS != nil { account.Settings.EnableRSS = form.EnableRSS + settingsColumns = append(settingsColumns, "enable_rss") } if form.HideCollections != nil { account.Settings.HideCollections = form.HideCollections + settingsColumns = append(settingsColumns, "hide_collections") } - if err := p.state.DB.UpdateAccount(ctx, account); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("could not update account %s: %s", account.ID, err)) + if form.WebVisibility != nil { + apiVis := apimodel.Visibility(*form.WebVisibility) + webVisibility := typeutils.APIVisToVis(apiVis) + if webVisibility != gtsmodel.VisibilityPublic && + webVisibility != gtsmodel.VisibilityUnlocked && + webVisibility != gtsmodel.VisibilityNone { + const text = "web_visibility must be one of public, unlocked, or none" + err := errors.New(text) + return nil, gtserror.NewErrorBadRequest(err, text) + } + + account.Settings.WebVisibility = webVisibility + settingsColumns = append(settingsColumns, "web_visibility") } - if err := p.state.DB.UpdateAccountSettings(ctx, account.Settings); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("could not update account settings %s: %s", account.ID, err)) + // We've parsed + set everything, do + // necessary database updates now. + + if len(acctColumns) > 0 { + if err := p.state.DB.UpdateAccount(ctx, account, acctColumns...); err != nil { + err := gtserror.Newf("db error updating account %s: %w", account.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } } + if len(settingsColumns) > 0 { + if err := p.state.DB.UpdateAccountSettings(ctx, account.Settings, settingsColumns...); err != nil { + err := gtserror.Newf("db error updating account settings %s: %w", account.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } + } + + // Send out Update message over the s2s (fedi) API. p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ APObjectType: ap.ActorPerson, APActivityType: ap.ActivityUpdate, @@ -347,11 +321,133 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form acctSensitive, err := p.converter.AccountToAPIAccountSensitive(ctx, account) if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("could not convert account into apisensitive account: %s", err)) + err := gtserror.Newf("error converting account: %w", err) + return nil, gtserror.NewErrorInternalError(err) } + return acctSensitive, nil } +// updateFields sets FieldsRaw on the given +// account, and resets account.Fields to an +// empty slice, ready for further processing. +func (p *Processor) updateFields( + account *gtsmodel.Account, + fieldsAttributes []apimodel.UpdateField, +) gtserror.WithCode { + var ( + fieldsLen = len(fieldsAttributes) + fieldsRaw = make([]*gtsmodel.Field, 0, fieldsLen) + ) + + for _, updateField := range fieldsAttributes { + if updateField.Name == nil || updateField.Value == nil { + continue + } + + var ( + name string = *updateField.Name + value string = *updateField.Value + ) + + if name == "" || value == "" { + continue + } + + // Sanitize raw field values. + fieldRaw := >smodel.Field{ + Name: text.SanitizeToPlaintext(name), + Value: text.SanitizeToPlaintext(value), + } + fieldsRaw = append(fieldsRaw, fieldRaw) + } + + // Check length of parsed raw fields. + if err := validate.ProfileFields(fieldsRaw); err != nil { + return gtserror.NewErrorBadRequest(err, err.Error()) + } + + // OK, new raw fields are valid. + account.FieldsRaw = fieldsRaw + account.Fields = make([]*gtsmodel.Field, 0, fieldsLen) + return nil +} + +// processAccountText processes the raw versions of the given +// account's display name, note, and fields, and sets those +// processed versions on the account, while also updating the +// account's emojis entry based on the results of the processing. +func (p *Processor) processAccountText( + ctx context.Context, + account *gtsmodel.Account, +) { + // Use map to deduplicate emojis by their ID. + emojis := make(map[string]*gtsmodel.Emoji) + + // Retrieve display name emojis. + for _, emoji := range p.formatter.FromPlainEmojiOnly( + ctx, + p.parseMention, + account.ID, + "", + account.DisplayName, + ).Emojis { + emojis[emoji.ID] = emoji + } + + // Format + set note according to user prefs. + f := p.selectNoteFormatter(account.Settings.StatusContentType) + formatNoteResult := f(ctx, p.parseMention, account.ID, "", account.NoteRaw) + account.Note = formatNoteResult.HTML + + // Retrieve note emojis. + for _, emoji := range formatNoteResult.Emojis { + emojis[emoji.ID] = emoji + } + + // Process raw fields. + account.Fields = make([]*gtsmodel.Field, 0, len(account.FieldsRaw)) + for _, fieldRaw := range account.FieldsRaw { + field := >smodel.Field{} + + // Name stays plain, but we still need to + // see if there are any emojis set in it. + field.Name = fieldRaw.Name + for _, emoji := range p.formatter.FromPlainEmojiOnly( + ctx, + p.parseMention, + account.ID, + "", + fieldRaw.Name, + ).Emojis { + emojis[emoji.ID] = emoji + } + + // Value can be HTML, but we don't want + // to wrap the result in
tags. + fieldFormatValueResult := p.formatter.FromPlainNoParagraph(ctx, p.parseMention, account.ID, "", fieldRaw.Value) + field.Value = fieldFormatValueResult.HTML + + // Retrieve field emojis. + for _, emoji := range fieldFormatValueResult.Emojis { + emojis[emoji.ID] = emoji + } + + // We're done, append the shiny new field. + account.Fields = append(account.Fields, field) + } + + // Update the account's emojis. + emojisCount := len(emojis) + account.Emojis = make([]*gtsmodel.Emoji, 0, emojisCount) + account.EmojiIDs = make([]string, 0, emojisCount) + + for id, emoji := range emojis { + account.Emojis = append(account.Emojis, emoji) + account.EmojiIDs = append(account.EmojiIDs, id) + } +} + // UpdateAvatar does the dirty work of checking the avatar // part of an account update form, parsing and checking the // media, and doing the necessary updates in the database diff --git a/internal/typeutils/frontendtointernal.go b/internal/typeutils/frontendtointernal.go index 8ced14d58..1f7d1877e 100644 --- a/internal/typeutils/frontendtointernal.go +++ b/internal/typeutils/frontendtointernal.go @@ -38,6 +38,8 @@ func APIVisToVis(m apimodel.Visibility) gtsmodel.Visibility { return gtsmodel.VisibilityMutualsOnly case apimodel.VisibilityDirect: return gtsmodel.VisibilityDirect + case apimodel.VisibilityNone: + return gtsmodel.VisibilityNone } return "" } diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 07a4c0836..5cbed62e0 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -134,6 +134,7 @@ func (c *Converter) AccountToAPIAccountSensitive(ctx context.Context, a *gtsmode apiAccount.Source = &apimodel.Source{ Privacy: c.VisToAPIVis(ctx, a.Settings.Privacy), + WebVisibility: c.VisToAPIVis(ctx, a.Settings.WebVisibility), Sensitive: *a.Settings.Sensitive, Language: a.Settings.Language, StatusContentType: statusContentType, diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index 307b5f163..651ff867d 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -120,6 +120,7 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendAliasedAndMoved() "fields": [], "source": { "privacy": "public", + "web_visibility": "unlisted", "sensitive": false, "language": "en", "status_content_type": "text/plain", @@ -304,6 +305,7 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendSensitive() { "fields": [], "source": { "privacy": "public", + "web_visibility": "unlisted", "sensitive": false, "language": "en", "status_content_type": "text/plain", diff --git a/testrig/testmodels.go b/testrig/testmodels.go index 26cc47f7d..171851d09 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -658,6 +658,7 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings { Language: "en", EnableRSS: util.Ptr(false), HideCollections: util.Ptr(false), + WebVisibility: gtsmodel.VisibilityPublic, }, "admin_account": { AccountID: "01F8MH17FWEB39HZJ76B6VXSKF", @@ -668,6 +669,7 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings { Language: "en", EnableRSS: util.Ptr(true), HideCollections: util.Ptr(false), + WebVisibility: gtsmodel.VisibilityPublic, }, "local_account_1": { AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", @@ -678,6 +680,7 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings { Language: "en", EnableRSS: util.Ptr(true), HideCollections: util.Ptr(false), + WebVisibility: gtsmodel.VisibilityUnlocked, }, "local_account_2": { AccountID: "01F8MH5NBDF2MV7CTC4Q5128HF", @@ -688,6 +691,7 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings { Language: "fr", EnableRSS: util.Ptr(false), HideCollections: util.Ptr(true), + WebVisibility: gtsmodel.VisibilityPublic, }, } } diff --git a/web/source/settings/views/user/profile.tsx b/web/source/settings/views/user/profile.tsx index 18c96e869..4e5fb627f 100644 --- a/web/source/settings/views/user/profile.tsx +++ b/web/source/settings/views/user/profile.tsx @@ -115,6 +115,7 @@ function UserProfileForm({ data: profile }) { discoverable: useBoolInput("discoverable", { source: profile}), enableRSS: useBoolInput("enable_rss", { source: profile }), hideCollections: useBoolInput("hide_collections", { source: profile }), + webVisibility: useTextInput("web_visibility", { source: profile, valueSelector: (p) => p.source?.web_visibility }), fields: useFieldArrayInput("fields_attributes", { defaultValue: profile?.source?.fields, length: instanceConfig.maxPinnedFields @@ -233,21 +234,32 @@ function UserProfileForm({ data: profile }) { Learn more about these settings (opens in a new tab) +