diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index 54d318111..ed21bd4e8 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -284,6 +284,12 @@ definitions: example: https://example.org/media/some_user/header/static/header.png type: string x-go-name: HeaderStatic + hide_boosts: + description: |- + Account has opted to hide boosts from their profile. + Key/value omitted if false. + type: boolean + x-go-name: HideBoosts hide_collections: description: |- Account has opted to hide their followers/following collections. @@ -2284,6 +2290,12 @@ definitions: example: https://example.org/media/some_user/header/static/header.png type: string x-go-name: HeaderStatic + hide_boosts: + description: |- + Account has opted to hide boosts from their profile. + Key/value omitted if false. + type: boolean + x-go-name: HideBoosts hide_collections: description: |- Account has opted to hide their followers/following collections. diff --git a/docs/user_guide/settings.md b/docs/user_guide/settings.md index 66452578d..5f1b6a013 100644 --- a/docs/user_guide/settings.md +++ b/docs/user_guide/settings.md @@ -88,9 +88,9 @@ This setting does not affect visibility of your posts over the ActivityPub proto !!! 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 @@ -134,6 +134,11 @@ This feed only includes posts set as 'Public' (see [Privacy Settings](./posts.md !!! warning Exposing your RSS feed allows *anyone* to subscribe to updates on your Public posts anonymously, bypassing follows and follow requests. +#### Hide boosts from your public page + +By default, GoToSocial will display posts boosted by you on your public web profile. If you do not wish to display them, You can hide them by checking this box. + + #### Hide Who You Follow / Are Followed By By default, GoToSocial shows your following/followers counts on your public web profile, and allows others to see who you follow and are followed by. This can be useful for account discovery purposes. However, for privacy + safety reasons you may wish to hide these counts, and to hide your following/followers lists from other accounts. You can do this by checking this box. @@ -196,7 +201,7 @@ If you want to reset all your policies to the initial defaults, you can click on !!! danger While GoToSocial respects interaction policies, it is not guaranteed that other server softwares will, and it is possible that accounts on other servers will still send out replies and boosts of your post to their followers, even if your instance forbids these interactions. - + As more ActivityPub servers roll out support for interaction policies, this issue will hopefully diminish, but in the meantime GoToSocial can offer only a "best effort" attempt to restrict interactions with your posts according to the policies you have set. ## Email & Password diff --git a/internal/api/client/accounts/accountupdate.go b/internal/api/client/accounts/accountupdate.go index 5d3a3da5f..81f27e655 100644 --- a/internal/api/client/accounts/accountupdate.go +++ b/internal/api/client/accounts/accountupdate.go @@ -348,6 +348,7 @@ func parseUpdateAccountForm(c *gin.Context) (*apimodel.UpdateCredentialsRequest, form.Theme == nil && form.CustomCSS == nil && form.EnableRSS == nil && + form.HideBoosts == 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 d34d7d519..5397ad544 100644 --- a/internal/api/model/account.go +++ b/internal/api/model/account.go @@ -104,6 +104,9 @@ type Account struct { // Account has enabled RSS feed. // Key/value omitted if false. EnableRSS bool `json:"enable_rss,omitempty"` + // Account has opted to hide boosts from their profile. + // Key/value omitted if false. + HideBoosts bool `json:"hide_boosts,omitempty"` // Account has opted to hide their followers/following collections. // Key/value omitted if false. HideCollections bool `json:"hide_collections,omitempty"` @@ -225,6 +228,8 @@ type UpdateCredentialsRequest struct { CustomCSS *string `form:"custom_css" json:"custom_css"` // Enable RSS feed of public toots for this account at /@[username]/feed.rss EnableRSS *bool `form:"enable_rss" json:"enable_rss"` + // Hide boosts from this account's profile page. + HideBoosts *bool `form:"hide_boosts" json:"hide_boosts"` // Hide this account's following/followers collections. HideCollections *bool `form:"hide_collections" json:"hide_collections"` // Visibility of statuses to show via the web view. diff --git a/internal/api/model/status.go b/internal/api/model/status.go index c29ab3e82..a2364e6a9 100644 --- a/internal/api/model/status.go +++ b/internal/api/model/status.go @@ -118,6 +118,10 @@ type WebStatus struct { // Override API account with web account. Account *WebAccount `json:"account"` + // Account that reblogged the status. + // needed to properly render reblogged statuses on profile pages. + ReblogAccount *WebAccount `json:"reblog_account"` + // Web version of media // attached to this status. MediaAttachments []*WebAttachment `json:"media_attachments"` diff --git a/internal/db/bundb/account.go b/internal/db/bundb/account.go index 16c82c08f..258c84807 100644 --- a/internal/db/bundb/account.go +++ b/internal/db/bundb/account.go @@ -1017,6 +1017,7 @@ func (a *accountDB) GetAccountWebStatuses( ) ([]*gtsmodel.Status, error) { // Check for an easy case: account exposes no statuses via the web. webVisibility := account.Settings.WebVisibility + hideBoosts := *account.Settings.HideBoosts if webVisibility == gtsmodel.VisibilityNone { return nil, db.ErrNoEntries } @@ -1035,9 +1036,12 @@ func (a *accountDB) GetAccountWebStatuses( // Select only IDs from table Column("status.id"). 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")) + // Don't show replies. + Where("? IS NULL", bun.Ident("status.in_reply_to_uri")) + + if hideBoosts { + q = q.Where("? IS NULL", bun.Ident("status.boost_of_id")) + } // Select statuses for this account according // to their web visibility preference. diff --git a/internal/db/bundb/migrations/20240922143734_add_hide_boosts.go b/internal/db/bundb/migrations/20240922143734_add_hide_boosts.go new file mode 100644 index 000000000..036f774e2 --- /dev/null +++ b/internal/db/bundb/migrations/20240922143734_add_hide_boosts.go @@ -0,0 +1,44 @@ +// 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 . + +package migrations + +import ( + "context" + "strings" + + "github.com/uptrace/bun" +) + +func init() { + up := func(ctx context.Context, db *bun.DB) error { + _, err := db.ExecContext(ctx, "ALTER TABLE ? ADD COLUMN ? BOOLEAN DEFAULT FALSE", bun.Ident("account_settings"), bun.Ident("hide_boosts")) + if err != nil && !(strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "duplicate column name") || strings.Contains(err.Error(), "SQLSTATE 42701")) { + return err + } + return nil + } + + down := func(ctx context.Context, db *bun.DB) error { + _, err := db.ExecContext(ctx, "ALTER TABLE ? DROP COLUMN ?", bun.Ident("account_settings"), bun.Ident("hide_boosts")) + return err + } + + if err := Migrations.Register(up, down); err != nil { + panic(err) + } +} diff --git a/internal/gtsmodel/accountsettings.go b/internal/gtsmodel/accountsettings.go index 3151ba5b7..2e37b3470 100644 --- a/internal/gtsmodel/accountsettings.go +++ b/internal/gtsmodel/accountsettings.go @@ -33,6 +33,7 @@ type AccountSettings struct { Theme string `bun:",nullzero"` // Preset CSS theme filename selected by this Account (empty string if nothing set). CustomCSS string `bun:",nullzero"` // Custom CSS that should be displayed for this Account's profile and statuses. EnableRSS *bool `bun:",nullzero,notnull,default:false"` // enable RSS feed subscription for this account's public posts at [URL]/feed + HideBoosts *bool `bun:",nullzero,notnull,default:false"` // Hide boosts from this accounts profile page. HideCollections *bool `bun:",nullzero,notnull,default:false"` // Hide this account's followers/following collections. WebVisibility Visibility `bun:",nullzero,notnull,default:public"` // Visibility level of statuses that visitors can view via the web profile. InteractionPolicyDirect *InteractionPolicy `bun:""` // Interaction policy to use for new direct visibility statuses by this account. If null, assume default policy. diff --git a/internal/processing/account/rss_test.go b/internal/processing/account/rss_test.go index e4706d3b7..4cf7575dd 100644 --- a/internal/processing/account/rss_test.go +++ b/internal/processing/account/rss_test.go @@ -42,6 +42,16 @@ func (suite *GetRSSTestSuite) TestGetAccountRSSAdmin() { Posts from @admin@localhost:8080 Wed, 20 Oct 2021 10:41:37 +0000 Wed, 20 Oct 2021 10:41:37 +0000 + + introduction post + http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY + @the_mighty_zork@localhost:8080 made a new post: "hello everyone!" + + @the_mighty_zork@localhost:8080 + http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY + Wed, 20 Oct 2021 10:40:37 +0000 + http://localhost:8080/@the_mighty_zork/feed.rss + open to see some puppies http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37 diff --git a/internal/processing/account/update.go b/internal/processing/account/update.go index 58e52a992..d9f00000c 100644 --- a/internal/processing/account/update.go +++ b/internal/processing/account/update.go @@ -274,6 +274,11 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form settingsColumns = append(settingsColumns, "enable_rss") } + if form.HideBoosts != nil { + account.Settings.HideBoosts = form.HideBoosts + settingsColumns = append(settingsColumns, "hide_boosts") + } + if form.HideCollections != nil { account.Settings.HideCollections = form.HideCollections settingsColumns = append(settingsColumns, "hide_collections") diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 4e76837cd..f9968e685 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -302,7 +302,7 @@ func (c *Converter) accountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A // Bits that vary between remote + local accounts: // - Account (acct) string. // - Role. - // - Settings things (enableRSS, theme, customCSS, hideCollections). + // - Settings things (enableRSS, theme, customCSS, hideBoosts ,hideCollections). var ( acct string @@ -310,6 +310,7 @@ func (c *Converter) accountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A enableRSS bool theme string customCSS string + hideBoosts bool hideCollections bool ) @@ -338,6 +339,7 @@ func (c *Converter) accountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A enableRSS = *a.Settings.EnableRSS theme = a.Settings.Theme customCSS = a.Settings.CustomCSS + hideBoosts = *a.Settings.HideBoosts hideCollections = *a.Settings.HideCollections } @@ -380,6 +382,7 @@ func (c *Converter) accountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A Theme: theme, CustomCSS: customCSS, EnableRSS: enableRSS, + HideBoosts: hideBoosts, HideCollections: hideCollections, Roles: roles, } @@ -1092,7 +1095,15 @@ func (c *Converter) StatusToWebStatus( ctx context.Context, s *gtsmodel.Status, ) (*apimodel.WebStatus, error) { - apiStatus, err := c.statusToFrontend(ctx, s, + + isBoost := s.BoostOf != nil + status := s + + if isBoost { + status = s.BoostOf + } + + apiStatus, err := c.statusToFrontend(ctx, status, nil, // No authed requester. statusfilter.FilterContextNone, // No filters. nil, // No filters. @@ -1103,7 +1114,7 @@ func (c *Converter) StatusToWebStatus( } // Convert status author to web model. - acct, err := c.AccountToWebAccount(ctx, s.Account) + acct, err := c.AccountToWebAccount(ctx, status.Account) if err != nil { return nil, err } @@ -1113,6 +1124,14 @@ func (c *Converter) StatusToWebStatus( Account: acct, } + if isBoost { + reblogAcct, err := c.AccountToWebAccount(ctx, s.Account) + if err != nil { + return nil, err + } + webStatus.ReblogAccount = reblogAcct + } + // Whack a newline before and after each "pre" to make it easier to outdent it. webStatus.Content = strings.ReplaceAll(webStatus.Content, "
", "\n
")
 	webStatus.Content = strings.ReplaceAll(webStatus.Content, "
", "
\n") diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index 61faf1c21..78329b46e 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -1402,6 +1402,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToWebStatus() { "emojis": [], "fields": [] }, + "reblog_account": null, "media_attachments": [ { "id": "01HE7Y3C432WRSNS10EZM86SA5", diff --git a/internal/typeutils/internaltorss.go b/internal/typeutils/internaltorss.go index 4f4b2b93a..8e7370632 100644 --- a/internal/typeutils/internaltorss.go +++ b/internal/typeutils/internaltorss.go @@ -39,6 +39,12 @@ const ( func (c *Converter) StatusToRSSItem(ctx context.Context, s *gtsmodel.Status) (*feeds.Item, error) { // see https://cyber.harvard.edu/rss/rss.html + // If status is a boost, + // display the boost instead. + if s.BoostOf != nil { + s = s.BoostOf + } + // Title -- The title of the item. // example: Venice Film Festival Tries to Quit Sinking var title string diff --git a/testrig/testmodels.go b/testrig/testmodels.go index ea3d96a04..049085fb2 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -657,6 +657,7 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings { Sensitive: util.Ptr(false), Language: "en", EnableRSS: util.Ptr(false), + HideBoosts: util.Ptr(false), HideCollections: util.Ptr(false), WebVisibility: gtsmodel.VisibilityPublic, }, @@ -668,6 +669,7 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings { Sensitive: util.Ptr(false), Language: "en", EnableRSS: util.Ptr(true), + HideBoosts: util.Ptr(false), HideCollections: util.Ptr(false), WebVisibility: gtsmodel.VisibilityPublic, }, @@ -679,6 +681,7 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings { Sensitive: util.Ptr(false), Language: "en", EnableRSS: util.Ptr(true), + HideBoosts: util.Ptr(false), HideCollections: util.Ptr(false), WebVisibility: gtsmodel.VisibilityUnlocked, }, @@ -690,6 +693,7 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings { Sensitive: util.Ptr(true), Language: "fr", EnableRSS: util.Ptr(false), + HideBoosts: util.Ptr(false), HideCollections: util.Ptr(true), WebVisibility: gtsmodel.VisibilityPublic, }, diff --git a/web/source/css/status.css b/web/source/css/status.css index 97713dae2..1486d4dec 100644 --- a/web/source/css/status.css +++ b/web/source/css/status.css @@ -41,6 +41,12 @@ main { text-decoration: none; } + .boosted { + padding: 0 0.75rem 0.75rem; + color: var(--fg-reduced); + font-weight: bold; + } + .status-header > address { /* Avoid stretching so wide that user @@ -59,17 +65,27 @@ main { "avatar author-strap author-strap"; gap: 0 0.5rem; font-style: normal; - + .avatar { grid-area: avatar; height: 3.5rem; width: 3.5rem; object-fit: cover; - + position: relative; + border: 0.15rem solid $avatar-border; border-radius: $br; overflow: hidden; /* hides corners from img overflowing */ - + + .boosted-avatar { + height: 50%; + width: 50%; + z-index: 10; + position: absolute; + bottom: 0; + inset-inline-end: 0; + } + img { height: 100%; width: 100%; @@ -77,7 +93,7 @@ main { background: $bg; } } - + .author-strap { grid-area: author-strap; display: grid; @@ -87,7 +103,7 @@ main { "display display" "user user"; gap: 0 0.5rem; - + .displayname, .username { justify-self: start; align-self: start; @@ -95,12 +111,12 @@ main { font-size: 1rem; line-height: 1.3rem; } - + .displayname { grid-area: display; font-weight: bold; } - + .username { grid-area: user; color: $link-fg; @@ -200,34 +216,34 @@ main { .poll { background-color: $gray2; z-index: 2; - + display: flex; flex-direction: column; border-radius: $br; padding: 0.5rem; margin: 0; gap: 1rem; - + .poll-options { margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1rem; - + .poll-option { display: flex; flex-direction: column; gap: 0.1rem; - + label { cursor: default; } - + meter { width: 100%; } - + .poll-vote-summary { display: flex; flex-wrap: wrap; @@ -236,7 +252,7 @@ main { } } } - + .poll-info { background-color: $gray4; display: flex; @@ -245,7 +261,7 @@ main { border-radius: $br-inner; padding: 0.25rem; gap: 0.25rem; - + span { justify-self: center; white-space: nowrap; @@ -301,12 +317,12 @@ main { width: 100%; z-index: 3; overflow: hidden; - + display: grid; padding: 1rem; grid-template-columns: 1fr auto 1fr; grid-template-rows: 1fr 1fr; - grid-template-areas: + grid-template-areas: "eye sensitive ." ". sensitive ."; @@ -369,7 +385,7 @@ main { height: 100%; padding: 0.8rem; border: 0.2rem dashed $white2; - + display: flex; flex-direction: column; align-items: center; @@ -518,4 +534,4 @@ main { .plyr { max-height: 100%; } -} \ No newline at end of file +} diff --git a/web/source/settings/views/user/profile.tsx b/web/source/settings/views/user/profile.tsx index 4e5fb627f..eaef94ca5 100644 --- a/web/source/settings/views/user/profile.tsx +++ b/web/source/settings/views/user/profile.tsx @@ -77,7 +77,7 @@ function UserProfileForm({ data: profile }) { maxPinnedFields: instance?.configuration?.accounts?.max_profile_fields ?? 6 }; }, [instance]); - + // Parse out available theme options into nice format. const { data: themes } = useAccountThemesQuery(); const themeOptions = useMemo(() => { @@ -114,6 +114,7 @@ function UserProfileForm({ data: profile }) { locked: useBoolInput("locked", { source: profile }), discoverable: useBoolInput("discoverable", { source: profile}), enableRSS: useBoolInput("enable_rss", { source: profile }), + hideBoosts: useBoolInput("hide_boosts", { source: profile }), hideCollections: useBoolInput("hide_collections", { source: profile }), webVisibility: useTextInput("web_visibility", { source: profile, valueSelector: (p) => p.source?.web_visibility }), fields: useFieldArrayInput("fields_attributes", { @@ -158,7 +159,7 @@ function UserProfileForm({ data: profile }) { autoCapitalize="sentences" /> - +
+ + {{- if .ReblogAccount }} +
+   + {{- if $.account.DisplayName }} + {{- emojify $.account.Emojis (escape $.account.DisplayName) }} boosted + {{- else }} + {{- $.account.Username }} boosted + {{- end }} +
+ {{- end }} {{- include "status.tmpl" . | indent 6 }} {{- end }} @@ -264,4 +274,4 @@
-{{- end }} \ No newline at end of file +{{- end }} diff --git a/web/template/status_header.tmpl b/web/template/status_header.tmpl index 01b73aea0..57a40152a 100644 --- a/web/template/status_header.tmpl +++ b/web/template/status_header.tmpl @@ -48,6 +48,16 @@ alt="Avatar for {{ .Username -}}" title="Avatar for {{ .Username -}}" > + {{ if $.ReblogAccount }} + Avatar for {{ $.ReblogAccount.Username -}} + {{ end }} + +
@@ -63,4 +73,4 @@ (open profile) -{{- end }} \ No newline at end of file +{{- end }}