diff options
Diffstat (limited to 'internal')
26 files changed, 626 insertions, 178 deletions
diff --git a/internal/api/client/accounts/accountupdate.go b/internal/api/client/accounts/accountupdate.go index 617031d79..50e6632f4 100644 --- a/internal/api/client/accounts/accountupdate.go +++ b/internal/api/client/accounts/accountupdate.go @@ -153,6 +153,14 @@ import ( // "none": show no posts on the web, not even Public ones. // type: string // - +// name: web_layout +// in: formData +// description: |- +// Layout to use for the web view of the account. +// "microblog": default, classic microblog layout. +// "gallery": gallery layout with media only. +// type: string +// - // name: fields_attributes[0][name] // in: formData // description: Name of 1st profile field to be added to this account's profile. @@ -351,7 +359,8 @@ func parseUpdateAccountForm(c *gin.Context) (*apimodel.UpdateCredentialsRequest, form.CustomCSS == nil && form.EnableRSS == nil && form.HideCollections == nil && - form.WebVisibility == nil) { + form.WebVisibility == nil && + form.WebLayout == nil) { return nil, errors.New("empty form submitted") } diff --git a/internal/api/client/accounts/search_test.go b/internal/api/client/accounts/search_test.go index 119900331..f5216d5b9 100644 --- a/internal/api/client/accounts/search_test.go +++ b/internal/api/client/accounts/search_test.go @@ -369,16 +369,16 @@ func (suite *AccountSearchTestSuite) TestSearchAFollowing() { suite.FailNow(err.Error()) } - if l := len(accounts); l != 5 { - suite.FailNow("", "expected length %d got %d", 5, l) + if l := len(accounts); l != 6 { + suite.FailNow("", "expected length %d got %d", 6, l) } - usernames := make([]string, 0, 5) + usernames := make([]string, 0, 6) for _, account := range accounts { usernames = append(usernames, account.Username) } - suite.EqualValues([]string{"her_fuckin_maj", "foss_satan", "1happyturtle", "the_mighty_zork", "admin"}, usernames) + suite.EqualValues([]string{"her_fuckin_maj", "media_mogul", "foss_satan", "1happyturtle", "the_mighty_zork", "admin"}, usernames) } func (suite *AccountSearchTestSuite) TestSearchANotFollowing() { diff --git a/internal/api/client/admin/accountsgetv2_test.go b/internal/api/client/admin/accountsgetv2_test.go index 0e3eb95e1..339c97431 100644 --- a/internal/api/client/admin/accountsgetv2_test.go +++ b/internal/api/client/admin/accountsgetv2_test.go @@ -223,6 +223,69 @@ func (suite *AccountsGetTestSuite) TestAccountsGetFromTop() { } }, { + "id": "01JPCMD83Y4WR901094YES3QC5", + "username": "media_mogul", + "domain": null, + "created_at": "2025-03-15T11:08:00.000Z", + "email": "media.mogul@example.org", + "ip": null, + "ips": [], + "locale": "en", + "invite_request": null, + "role": { + "id": "user", + "name": "user", + "color": "", + "permissions": "0", + "highlighted": false + }, + "confirmed": true, + "approved": true, + "disabled": false, + "silenced": false, + "suspended": false, + "account": { + "id": "01JPCMD83Y4WR901094YES3QC5", + "username": "media_mogul", + "acct": "media_mogul", + "display_name": "", + "locked": false, + "discoverable": false, + "bot": false, + "created_at": "2025-03-15T11:08:00.000Z", + "note": "<p>I'm a test account that posts a shitload of media and I have my account rendered in \"gallery\" mode</p>", + "url": "http://localhost:8080/@media_mogul", + "avatar": "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/avatar/original/01JPHQZ0ZHC2AXJK1JQNXRXQZN.jpeg", + "avatar_static": "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/avatar/small/01JPHQZ0ZHC2AXJK1JQNXRXQZN.jpeg", + "avatar_description": "DESCRIPTION_GOES_HERE", + "avatar_media_id": "01JPHQZ0ZHC2AXJK1JQNXRXQZN", + "header": "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/header/original/01JPHRB7F2RXPTEQFRYC85EPD9.png", + "header_static": "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/header/small/01JPHRB7F2RXPTEQFRYC85EPD9.webp", + "header_description": "DESCRIPTION_GOES_HERE", + "header_media_id": "01JPHRB7F2RXPTEQFRYC85EPD9", + "followers_count": 0, + "following_count": 0, + "statuses_count": 2, + "last_status_at": "2025-03-15", + "emojis": [], + "fields": [ + { + "name": "I'm going to post a lot of", + "value": "media!", + "verified_at": null + }, + { + "name": "and there's nothing", + "value": "you can do about it", + "verified_at": null + } + ], + "enable_rss": true, + "group": false + }, + "created_by_application_id": "01HT5P2YHDMPAAD500NDAY8JW1" + }, + { "id": "01F8MH1H7YV1Z7D2C8K2730QBF", "username": "the_mighty_zork", "domain": null, @@ -547,18 +610,18 @@ func (suite *AccountsGetTestSuite) TestAccountsMinID() { } link := recorder.Header().Get("Link") - suite.Equal(`<http://localhost:8080/api/v2/admin/accounts?limit=1&max_id=%2F%40localhost%3A8080>; rel="next", <http://localhost:8080/api/v2/admin/accounts?limit=1&min_id=%2F%40localhost%3A8080>; rel="prev"`, link) + suite.Equal(`<http://localhost:8080/api/v2/admin/accounts?limit=1&max_id=%2F%40media_mogul>; rel="next", <http://localhost:8080/api/v2/admin/accounts?limit=1&min_id=%2F%40media_mogul>; rel="prev"`, link) suite.Equal(`[ { - "id": "01AY6P665V14JJR0AFVRT7311Y", - "username": "localhost:8080", + "id": "01JPCMD83Y4WR901094YES3QC5", + "username": "media_mogul", "domain": null, - "created_at": "2020-05-17T13:10:59.000Z", - "email": "", + "created_at": "2025-03-15T11:08:00.000Z", + "email": "media.mogul@example.org", "ip": null, "ips": [], - "locale": "", + "locale": "en", "invite_request": null, "role": { "id": "user", @@ -567,35 +630,51 @@ func (suite *AccountsGetTestSuite) TestAccountsMinID() { "permissions": "0", "highlighted": false }, - "confirmed": false, - "approved": false, + "confirmed": true, + "approved": true, "disabled": false, "silenced": false, "suspended": false, "account": { - "id": "01AY6P665V14JJR0AFVRT7311Y", - "username": "localhost:8080", - "acct": "localhost:8080", + "id": "01JPCMD83Y4WR901094YES3QC5", + "username": "media_mogul", + "acct": "media_mogul", "display_name": "", "locked": false, - "discoverable": true, + "discoverable": false, "bot": false, - "created_at": "2020-05-17T13:10:59.000Z", - "note": "", - "url": "http://localhost:8080/@localhost:8080", - "avatar": "", - "avatar_static": "", - "header": "http://localhost:8080/assets/default_header.webp", - "header_static": "http://localhost:8080/assets/default_header.webp", - "header_description": "Flat gray background (default header).", + "created_at": "2025-03-15T11:08:00.000Z", + "note": "<p>I'm a test account that posts a shitload of media and I have my account rendered in \"gallery\" mode</p>", + "url": "http://localhost:8080/@media_mogul", + "avatar": "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/avatar/original/01JPHQZ0ZHC2AXJK1JQNXRXQZN.jpeg", + "avatar_static": "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/avatar/small/01JPHQZ0ZHC2AXJK1JQNXRXQZN.jpeg", + "avatar_description": "DESCRIPTION_GOES_HERE", + "avatar_media_id": "01JPHQZ0ZHC2AXJK1JQNXRXQZN", + "header": "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/header/original/01JPHRB7F2RXPTEQFRYC85EPD9.png", + "header_static": "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/header/small/01JPHRB7F2RXPTEQFRYC85EPD9.webp", + "header_description": "DESCRIPTION_GOES_HERE", + "header_media_id": "01JPHRB7F2RXPTEQFRYC85EPD9", "followers_count": 0, "following_count": 0, - "statuses_count": 0, - "last_status_at": null, + "statuses_count": 2, + "last_status_at": "2025-03-15", "emojis": [], - "fields": [], + "fields": [ + { + "name": "I'm going to post a lot of", + "value": "media!", + "verified_at": null + }, + { + "name": "and there's nothing", + "value": "you can do about it", + "verified_at": null + } + ], + "enable_rss": true, "group": false - } + }, + "created_by_application_id": "01HT5P2YHDMPAAD500NDAY8JW1" } ]`, dst.String()) } diff --git a/internal/api/client/instance/instancepatch_test.go b/internal/api/client/instance/instancepatch_test.go index a63ca9e11..b0ce795f0 100644 --- a/internal/api/client/instance/instancepatch_test.go +++ b/internal/api/client/instance/instancepatch_test.go @@ -158,8 +158,8 @@ func (suite *InstancePatchTestSuite) TestInstancePatch1() { }, "stats": { "domain_count": 2, - "status_count": 21, - "user_count": 4 + "status_count": 23, + "user_count": 5 }, "thumbnail": "http://localhost:8080/assets/logo.webp", "contact_account": { @@ -301,8 +301,8 @@ func (suite *InstancePatchTestSuite) TestInstancePatch2() { }, "stats": { "domain_count": 2, - "status_count": 21, - "user_count": 4 + "status_count": 23, + "user_count": 5 }, "thumbnail": "http://localhost:8080/assets/logo.webp", "contact_account": { @@ -444,8 +444,8 @@ func (suite *InstancePatchTestSuite) TestInstancePatch3() { }, "stats": { "domain_count": 2, - "status_count": 21, - "user_count": 4 + "status_count": 23, + "user_count": 5 }, "thumbnail": "http://localhost:8080/assets/logo.webp", "contact_account": { @@ -638,8 +638,8 @@ func (suite *InstancePatchTestSuite) TestInstancePatch6() { }, "stats": { "domain_count": 2, - "status_count": 21, - "user_count": 4 + "status_count": 23, + "user_count": 5 }, "thumbnail": "http://localhost:8080/assets/logo.webp", "contact_account": { @@ -803,8 +803,8 @@ func (suite *InstancePatchTestSuite) TestInstancePatch8() { }, "stats": { "domain_count": 2, - "status_count": 21, - "user_count": 4 + "status_count": 23, + "user_count": 5 }, "thumbnail": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/attachment/original/`+instanceAccount.AvatarMediaAttachment.ID+`.gif",`+` "thumbnail_type": "image/gif", @@ -987,8 +987,8 @@ func (suite *InstancePatchTestSuite) TestInstancePatch9() { }, "stats": { "domain_count": 2, - "status_count": 21, - "user_count": 4 + "status_count": 23, + "user_count": 5 }, "thumbnail": "http://localhost:8080/assets/logo.webp", "contact_account": { diff --git a/internal/api/client/search/searchget_test.go b/internal/api/client/search/searchget_test.go index 318010387..53f0a993c 100644 --- a/internal/api/client/search/searchget_test.go +++ b/internal/api/client/search/searchget_test.go @@ -915,7 +915,7 @@ func (suite *SearchGetTestSuite) TestSearchAAny() { suite.FailNow(err.Error()) } - suite.Len(searchResult.Accounts, 5) + suite.Len(searchResult.Accounts, 6) suite.Len(searchResult.Statuses, 9) suite.Len(searchResult.Hashtags, 0) } @@ -1130,7 +1130,7 @@ func (suite *SearchGetTestSuite) TestSearchAAccounts() { suite.FailNow(err.Error()) } - suite.Len(searchResult.Accounts, 5) + suite.Len(searchResult.Accounts, 6) suite.Len(searchResult.Statuses, 0) suite.Len(searchResult.Hashtags, 0) } diff --git a/internal/api/model/account.go b/internal/api/model/account.go index d14ef9047..34d8f5958 100644 --- a/internal/api/model/account.go +++ b/internal/api/model/account.go @@ -149,6 +149,9 @@ type WebAccount struct { // Only set if this account had a header set // (and not just the default "blank" image.) HeaderAttachment *WebAttachment `json:"-"` + + // Layout for this account (microblog, gallery). + WebLayout string `json:"-"` } // MutedAccount extends Account with a field used only by the muted user list. @@ -240,6 +243,10 @@ type UpdateCredentialsRequest struct { // 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"` + // Layout to use for the web view of the account. + // "microblog": default, classic microblog layout. + // "gallery": gallery layout with media only. + WebLayout *string `form:"web_layout" json:"web_layout"` } // UpdateSource is to be used specifically in an UpdateCredentialsRequest. diff --git a/internal/api/model/attachment.go b/internal/api/model/attachment.go index 1d910343c..63bfc52a6 100644 --- a/internal/api/model/attachment.go +++ b/internal/api/model/attachment.go @@ -136,6 +136,10 @@ type WebAttachment struct { // MIME type of // the thumbnail. PreviewMIMEType string + + // Link to the URL of the parent + // status of this attachment. + ParentStatusLink string } // MediaMeta models media metadata. diff --git a/internal/api/model/source.go b/internal/api/model/source.go index cc3eb78ee..ff0b25424 100644 --- a/internal/api/model/source.go +++ b/internal/api/model/source.go @@ -31,6 +31,10 @@ type Source struct { // "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"` + // Layout to use for the web view of the account. + // "microblog": default, classic microblog layout. + // "gallery": gallery layout with media only. + WebLayout string `json:"web_layout"` // Whether new statuses should be marked sensitive by default. Sensitive bool `json:"sensitive"` // The default posting language for new statuses. diff --git a/internal/db/account.go b/internal/db/account.go index aa0dfd985..cfb81308f 100644 --- a/internal/db/account.go +++ b/internal/db/account.go @@ -121,7 +121,7 @@ type Account interface { // 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, account *gtsmodel.Account, limit int, maxID string) ([]*gtsmodel.Status, error) + GetAccountWebStatuses(ctx context.Context, account *gtsmodel.Account, mediaOnly bool, limit int, maxID string) ([]*gtsmodel.Status, error) // GetInstanceAccount returns the instance account for the given domain. // If domain is empty, this instance account will be returned. diff --git a/internal/db/bundb/account.go b/internal/db/bundb/account.go index f905101e4..aacfcd247 100644 --- a/internal/db/bundb/account.go +++ b/internal/db/bundb/account.go @@ -878,6 +878,29 @@ func (a *accountDB) GetAccountFaves(ctx context.Context, accountID string) ([]*g return *faves, nil } +func qMediaOnly(q *bun.SelectQuery) *bun.SelectQuery { + // Attachments are stored as a json object; this + // implementation differs between SQLite and Postgres, + // so we have to be thorough to cover all eventualities + return q.WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery { + switch d := q.Dialect().Name(); d { + case dialect.PG: + return q. + Where("? IS NOT NULL", bun.Ident("status.attachments")). + Where("? != '{}'", bun.Ident("status.attachments")) + + case dialect.SQLite: + return q. + Where("? IS NOT NULL", bun.Ident("status.attachments")). + Where("? != 'null'", bun.Ident("status.attachments")). + Where("? != '[]'", bun.Ident("status.attachments")) + + default: + panic("dialect " + d.String() + " was neither pg nor sqlite") + } + }) +} + func (a *accountDB) GetAccountStatuses(ctx context.Context, accountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, mediaOnly bool, publicOnly bool) ([]*gtsmodel.Status, error) { // Ensure reasonable if limit < 0 { @@ -918,28 +941,9 @@ func (a *accountDB) GetAccountStatuses(ctx context.Context, accountID string, li q = q.Where("? IS NULL", bun.Ident("status.boost_of_id")) } + // Respect media-only preference. if mediaOnly { - // Attachments are stored as a json object; this - // implementation differs between SQLite and Postgres, - // so we have to be thorough to cover all eventualities - q = q.WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery { - switch a.db.Dialect().Name() { - case dialect.PG: - return q. - Where("? IS NOT NULL", bun.Ident("status.attachments")). - Where("? != '{}'", bun.Ident("status.attachments")) - case dialect.SQLite: - return q. - Where("? IS NOT NULL", bun.Ident("status.attachments")). - Where("? != ''", bun.Ident("status.attachments")). - Where("? != 'null'", bun.Ident("status.attachments")). - Where("? != '{}'", bun.Ident("status.attachments")). - Where("? != '[]'", bun.Ident("status.attachments")) - default: - log.Panic(ctx, "db dialect was neither pg nor sqlite") - return q - } - }) + q = qMediaOnly(q) } if publicOnly { @@ -1018,6 +1022,7 @@ func (a *accountDB) GetAccountPinnedStatuses(ctx context.Context, accountID stri func (a *accountDB) GetAccountWebStatuses( ctx context.Context, account *gtsmodel.Account, + mediaOnly bool, limit int, maxID string, ) ([]*gtsmodel.Status, error) { @@ -1046,10 +1051,7 @@ func (a *accountDB) GetAccountWebStatuses( TableExpr("? AS ?", bun.Ident("statuses"), bun.Ident("status")). // 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")) + Where("? = ?", bun.Ident("status.account_id"), account.ID) // Select statuses for this account according // to their web visibility preference. @@ -1074,10 +1076,19 @@ func (a *accountDB) GetAccountWebStatuses( ) } - // Don't show local-only statuses on the web view. - q = q.Where("? = ?", bun.Ident("status.federated"), true) + // Don't show replies, boosts, or + // local-only statuses on the web view. + q = q. + Where("? IS NULL", bun.Ident("status.in_reply_to_uri")). + Where("? IS NULL", bun.Ident("status.boost_of_id")). + Where("? = ?", bun.Ident("status.federated"), true) + + // Respect media-only preference. + if mediaOnly { + q = qMediaOnly(q) + } - // return only statuses LOWER (ie., older) than maxID + // Return only statuses LOWER (ie., older) than maxID if maxID == "" { maxID = id.Highest } diff --git a/internal/db/bundb/account_test.go b/internal/db/bundb/account_test.go index 879250408..e3d36855e 100644 --- a/internal/db/bundb/account_test.go +++ b/internal/db/bundb/account_test.go @@ -49,6 +49,12 @@ func (suite *AccountTestSuite) TestGetAccountStatuses() { suite.Len(statuses, 9) } +func (suite *AccountTestSuite) TestGetAccountWebStatusesMediaOnly() { + statuses, err := suite.db.GetAccountWebStatuses(context.Background(), suite.testAccounts["local_account_3"], true, 20, "") + suite.NoError(err) + suite.Len(statuses, 2) +} + func (suite *AccountTestSuite) TestGetAccountStatusesPageDown() { // get the first page statuses, err := suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 3, false, false, "", "", false, false) @@ -490,7 +496,7 @@ func (suite *AccountTestSuite) TestGetAccountsAll() { suite.FailNow(err.Error()) } - suite.Len(accounts, 9) + suite.Len(accounts, 10) } func (suite *AccountTestSuite) TestGetAccountsMaxID() { @@ -564,7 +570,7 @@ func (suite *AccountTestSuite) TestGetAccountsMinID() { suite.FailNow(err.Error()) } - suite.Len(accounts, 3) + suite.Len(accounts, 4) } func (suite *AccountTestSuite) TestGetAccountsModsOnly() { diff --git a/internal/db/bundb/basic_test.go b/internal/db/bundb/basic_test.go index e20aab765..1f2d1ac48 100644 --- a/internal/db/bundb/basic_test.go +++ b/internal/db/bundb/basic_test.go @@ -114,7 +114,7 @@ func (suite *BasicTestSuite) TestGetAllStatuses() { s := []*gtsmodel.Status{} err := suite.db.GetAll(context.Background(), &s) suite.NoError(err) - suite.Len(s, 28) + suite.Len(s, 30) } func (suite *BasicTestSuite) TestGetAllNotNull() { diff --git a/internal/db/bundb/instance_test.go b/internal/db/bundb/instance_test.go index 1364bacc2..c0d63003d 100644 --- a/internal/db/bundb/instance_test.go +++ b/internal/db/bundb/instance_test.go @@ -35,7 +35,7 @@ type InstanceTestSuite struct { func (suite *InstanceTestSuite) TestCountInstanceUsers() { count, err := suite.db.CountInstanceUsers(context.Background(), config.GetHost()) suite.NoError(err) - suite.Equal(4, count) + suite.Equal(5, count) } func (suite *InstanceTestSuite) TestCountInstanceUsersRemote() { @@ -47,7 +47,7 @@ func (suite *InstanceTestSuite) TestCountInstanceUsersRemote() { func (suite *InstanceTestSuite) TestCountInstanceStatuses() { count, err := suite.db.CountInstanceStatuses(context.Background(), config.GetHost()) suite.NoError(err) - suite.Equal(21, count) + suite.Equal(23, count) } func (suite *InstanceTestSuite) TestCountInstanceStatusesRemote() { diff --git a/internal/db/bundb/migrations/20250314120945_add_gallery_web_layout.go b/internal/db/bundb/migrations/20250314120945_add_gallery_web_layout.go new file mode 100644 index 000000000..64b133cd5 --- /dev/null +++ b/internal/db/bundb/migrations/20250314120945_add_gallery_web_layout.go @@ -0,0 +1,85 @@ +// 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 <http://www.gnu.org/licenses/>. + +package migrations + +import ( + "context" + + "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/uptrace/bun" +) + +func init() { + up := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + + // Add new column to settings. + if _, err := tx. + NewAddColumn(). + Table("account_settings"). + ColumnExpr( + "? SMALLINT NOT NULL DEFAULT ?", + bun.Ident("web_layout"), 1, + ). + Exec(ctx); err != nil { + return err + } + + // Drop existing statuses web index as it's out of date. + log.Info(ctx, "updating statuses_profile_web_view_idx, this may take a while, please wait!") + if _, err := tx. + NewDropIndex(). + Index("statuses_profile_web_view_idx"). + IfExists(). + Exec(ctx); err != nil { + return err + } + + // Note: "attachments" field is not included in + // the index below as SQLite is fussy about using it, + // and it prevents this index from being used + // properly in non media-only queries. + if _, err := tx. + NewCreateIndex(). + Table("statuses"). + Index("statuses_profile_web_view_idx"). + Column( + "account_id", + "visibility", + "in_reply_to_uri", + "boost_of_id", + "federated", + ). + ColumnExpr("? DESC", bun.Ident("id")). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + return nil + }) + } + + down := func(ctx context.Context, db *bun.DB) error { + return nil + } + + if err := Migrations.Register(up, down); err != nil { + panic(err) + } +} diff --git a/internal/gtsmodel/accountsettings.go b/internal/gtsmodel/accountsettings.go index 4624aa0b1..30fb7e5df 100644 --- a/internal/gtsmodel/accountsettings.go +++ b/internal/gtsmodel/accountsettings.go @@ -18,6 +18,7 @@ package gtsmodel import ( + "strings" "time" ) @@ -35,9 +36,51 @@ type AccountSettings struct { EnableRSS *bool `bun:",nullzero,notnull,default:false"` // enable RSS feed subscription for this account's public posts at [URL]/feed HideCollections *bool `bun:",nullzero,notnull,default:false"` // Hide this account's followers/following collections. WebVisibility Visibility `bun:",nullzero,notnull,default:3"` // Visibility level of statuses that visitors can view via the web profile. + WebLayout WebLayout `bun:",nullzero,notnull,default:1"` // Layout to use when showing this profile via the web. InteractionPolicyDirect *InteractionPolicy `bun:""` // Interaction policy to use for new direct visibility statuses by this account. If null, assume default policy. InteractionPolicyMutualsOnly *InteractionPolicy `bun:""` // Interaction policy to use for new mutuals only visibility statuses. If null, assume default policy. InteractionPolicyFollowersOnly *InteractionPolicy `bun:""` // Interaction policy to use for new followers only visibility statuses. If null, assume default policy. InteractionPolicyUnlocked *InteractionPolicy `bun:""` // Interaction policy to use for new unlocked visibility statuses. If null, assume default policy. InteractionPolicyPublic *InteractionPolicy `bun:""` // Interaction policy to use for new public visibility statuses. If null, assume default policy. } + +// WebLayout represents an account owner's +// choice for how they want their profile to be +// laid out via the web view, by default. +type WebLayout enumType + +const ( + WebLayoutUnknown WebLayout = 0 + + // "Classic" / default GtS microblog view. + WebLayoutMicroblog WebLayout = 1 + + // 'gram-style gallery view with media only. + WebLayoutGallery WebLayout = 2 +) + +// String returns a stringified, frontend +// API compatible form of WebLayout. +func (wrm WebLayout) String() string { + switch wrm { + case WebLayoutMicroblog: + return "microblog" + case WebLayoutGallery: + return "gallery" + default: + panic("invalid web layout") + } +} + +// ParseWebLayout returns a web +// layout from the given value. +func ParseWebLayout(in string) WebLayout { + switch strings.ToLower(in) { + case "microblog": + return WebLayoutMicroblog + case "gallery": + return WebLayoutGallery + default: + return WebLayoutUnknown + } +} diff --git a/internal/processing/account/rss.go b/internal/processing/account/rss.go index 22ba0fe42..b0debcc91 100644 --- a/internal/processing/account/rss.go +++ b/internal/processing/account/rss.go @@ -115,8 +115,20 @@ func (p *Processor) GetRSSFeedForUsername(ctx context.Context, username string) // Reuse the lastPostAt value for feed.Updated. feed.Updated = lastPostAt - // Retrieve latest statuses as they'd be shown on the web view of the account profile. - statuses, err := p.state.DB.GetAccountWebStatuses(ctx, account, rssFeedLength, "") + // Retrieve latest statuses as they'd be shown + // on the web view of the account profile. + // + // Take into account whether the user wants + // their web view laid out in gallery mode. + mediaOnly := account.Settings != nil && + account.Settings.WebLayout == gtsmodel.WebLayoutGallery + statuses, err := p.state.DB.GetAccountWebStatuses( + ctx, + account, + mediaOnly, + rssFeedLength, + "", // Latest posts from the top. + ) if err != nil && !errors.Is(err, db.ErrNoEntries) { err = fmt.Errorf("db error getting account web statuses: %w", err) return "", gtserror.NewErrorInternalError(err) diff --git a/internal/processing/account/statuses.go b/internal/processing/account/statuses.go index 8029a460b..701fe44ae 100644 --- a/internal/processing/account/statuses.go +++ b/internal/processing/account/statuses.go @@ -143,6 +143,7 @@ func (p *Processor) StatusesGet( func (p *Processor) WebStatusesGet( ctx context.Context, targetAccountID string, + mediaOnly bool, maxID string, ) (*apimodel.PageableResponse, gtserror.WithCode) { account, err := p.state.DB.GetAccountByID(ctx, targetAccountID) @@ -159,7 +160,13 @@ func (p *Processor) WebStatusesGet( return nil, gtserror.NewErrorNotFound(err) } - statuses, err := p.state.DB.GetAccountWebStatuses(ctx, account, 10, maxID) + statuses, err := p.state.DB.GetAccountWebStatuses( + ctx, + account, + mediaOnly, + 20, + maxID, + ) if err != nil && !errors.Is(err, db.ErrNoEntries) { return nil, gtserror.NewErrorInternalError(err) } @@ -198,6 +205,7 @@ func (p *Processor) WebStatusesGet( func (p *Processor) WebStatusesGetPinned( ctx context.Context, targetAccountID string, + mediaOnly bool, ) ([]*apimodel.WebStatus, gtserror.WithCode) { statuses, err := p.state.DB.GetAccountPinnedStatuses(ctx, targetAccountID) if err != nil && !errors.Is(err, db.ErrNoEntries) { @@ -206,6 +214,11 @@ func (p *Processor) WebStatusesGetPinned( webStatuses := make([]*apimodel.WebStatus, 0, len(statuses)) for _, status := range statuses { + if mediaOnly && len(status.Attachments) == 0 { + // No media, skip. + continue + } + // Ensure visible via the web. visible, err := p.visFilter.StatusVisible(ctx, nil, status) if err != nil { diff --git a/internal/processing/account/update.go b/internal/processing/account/update.go index 3a59dbdf3..a833d72c1 100644 --- a/internal/processing/account/update.go +++ b/internal/processing/account/update.go @@ -294,6 +294,18 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form settingsColumns = append(settingsColumns, "web_visibility") } + if form.WebLayout != nil { + webLayout := gtsmodel.ParseWebLayout(*form.WebLayout) + if webLayout == gtsmodel.WebLayoutUnknown { + const text = "web_layout must be one of microblog or gallery" + err := errors.New(text) + return nil, gtserror.NewErrorBadRequest(err, text) + } + + account.Settings.WebLayout = webLayout + settingsColumns = append(settingsColumns, "web_layout") + } + // We've parsed + set everything, do // necessary database updates now. diff --git a/internal/router/template.go b/internal/router/template.go index 70d87add1..51c0c4960 100644 --- a/internal/router/template.go +++ b/internal/router/template.go @@ -76,6 +76,10 @@ func LoadTemplates(engine *gin.Engine) error { // Set additional "include" functions to render // provided template name using the base template. + + // Include renders the given template with the given data. + // Unlike `template`, `include` can be chained with `indent` + // to produce nicely-indented HTML. funcMap["include"] = func(name string, data any) (template.HTML, error) { var buf strings.Builder err := tmpl.ExecuteTemplate(&buf, name, data) @@ -85,6 +89,25 @@ func LoadTemplates(engine *gin.Engine) error { return noescape(buf.String()), err } + // includeIndex is like `include` but an index can be specified at + // `.Index` and data will be nested at `.Item`. Useful when ranging. + funcMap["includeIndex"] = func(name string, data any, index int) (template.HTML, error) { + var buf strings.Builder + withIndex := struct { + Item any + Index int + }{ + Item: data, + Index: index, + } + err := tmpl.ExecuteTemplate(&buf, name, withIndex) + + // Template was already escaped by + // ExecuteTemplate so we can trust it. + return noescape(buf.String()), err + } + + // includeAttr is like `include` but for element attributes. funcMap["includeAttr"] = func(name string, data any) (template.HTMLAttr, error) { var buf strings.Builder err := tmpl.ExecuteTemplate(&buf, name, data) diff --git a/internal/timeline/prune_test.go b/internal/timeline/prune_test.go index 4b909540c..6ff67d505 100644 --- a/internal/timeline/prune_test.go +++ b/internal/timeline/prune_test.go @@ -40,7 +40,7 @@ func (suite *PruneTestSuite) TestPrune() { pruned, err := suite.state.Timelines.Home.Prune(ctx, testAccountID, desiredPreparedItemsLength, desiredIndexedItemsLength) suite.NoError(err) - suite.Equal(23, pruned) + suite.Equal(25, pruned) suite.Equal(5, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID)) } @@ -56,7 +56,7 @@ func (suite *PruneTestSuite) TestPruneTwice() { pruned, err := suite.state.Timelines.Home.Prune(ctx, testAccountID, desiredPreparedItemsLength, desiredIndexedItemsLength) suite.NoError(err) - suite.Equal(23, pruned) + suite.Equal(25, pruned) suite.Equal(5, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID)) // Prune same again, nothing should be pruned this time. @@ -78,7 +78,7 @@ func (suite *PruneTestSuite) TestPruneTo0() { pruned, err := suite.state.Timelines.Home.Prune(ctx, testAccountID, desiredPreparedItemsLength, desiredIndexedItemsLength) suite.NoError(err) - suite.Equal(28, pruned) + suite.Equal(30, pruned) suite.Equal(0, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID)) } @@ -95,7 +95,7 @@ func (suite *PruneTestSuite) TestPruneToInfinityAndBeyond() { pruned, err := suite.state.Timelines.Home.Prune(ctx, testAccountID, desiredPreparedItemsLength, desiredIndexedItemsLength) suite.NoError(err) suite.Equal(0, pruned) - suite.Equal(28, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID)) + suite.Equal(30, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID)) } func TestPruneTestSuite(t *testing.T) { diff --git a/internal/typeutils/internaltoas_test.go b/internal/typeutils/internaltoas_test.go index 7a2428f42..32f835da1 100644 --- a/internal/typeutils/internaltoas_test.go +++ b/internal/typeutils/internaltoas_test.go @@ -85,7 +85,7 @@ func (suite *InternalToASTestSuite) TestAccountToAS() { "publicKey": { "id": "http://localhost:8080/users/the_mighty_zork/main-key", "owner": "http://localhost:8080/users/the_mighty_zork", - "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwXTcOAvM1Jiw5Ffpk0qn\nr0cwbNvFe/5zQ+Tp7tumK/ZnT37o7X0FUEXrxNi+dkhmeJ0gsaiN+JQGNUewvpSk\nPIAXKvi908aSfCGjs7bGlJCJCuDuL5d6m7hZnP9rt9fJc70GElPpG0jc9fXwlz7T\nlsPb2ecatmG05Y4jPwdC+oN4MNCv9yQzEvCVMzl76EJaM602kIHC1CISn0rDFmYd\n9rSN7XPlNJw1F6PbpJ/BWQ+pXHKw3OEwNTETAUNYiVGnZU+B7a7bZC9f6/aPbJuV\nt8Qmg+UnDvW1Y8gmfHnxaWG2f5TDBvCHmcYtucIZPLQD4trAozC4ryqlmCWQNKbt\n0wIDAQAB\n-----END PUBLIC KEY-----\n" + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqtQQjwFLHPez+7uF9AX7\nuvLFHm3SyNIozhhVmGhxHIs0xdgRnZKmzmZkFdrFuXddBTAglU4C2u3dw10jJO1a\nWIFQF8bGkRHZG7Pd25/XmWWBRPmOJxNLeWBqpj0G+2zTMgnAV72hALSDFY2/QDsx\nUthenKw0Srpj1LUwvRbyVQQ8fGu4v0HACFnlOX2hCQwhfAnGrb0V70Y2IJu++MP7\n6i49md0vR0Mv3WbsEJUNp1fTIUzkgWB31icvfrNmaaAxw5FkAE+KfkkylhRxi5i5\nRR1XQUINWc2Kj2Kro+CJarKG+9zasMyN7+D230gpESi8rXv1SwRu865FR3gANdDS\nMwIDAQAB\n-----END PUBLIC KEY-----\n" }, "published": "2022-05-20T11:09:18Z", "summary": "\u003cp\u003ehey yo this is my profile!\u003c/p\u003e", @@ -151,7 +151,7 @@ func (suite *InternalToASTestSuite) TestAccountToASBot() { "publicKey": { "id": "http://localhost:8080/users/the_mighty_zork/main-key", "owner": "http://localhost:8080/users/the_mighty_zork", - "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwXTcOAvM1Jiw5Ffpk0qn\nr0cwbNvFe/5zQ+Tp7tumK/ZnT37o7X0FUEXrxNi+dkhmeJ0gsaiN+JQGNUewvpSk\nPIAXKvi908aSfCGjs7bGlJCJCuDuL5d6m7hZnP9rt9fJc70GElPpG0jc9fXwlz7T\nlsPb2ecatmG05Y4jPwdC+oN4MNCv9yQzEvCVMzl76EJaM602kIHC1CISn0rDFmYd\n9rSN7XPlNJw1F6PbpJ/BWQ+pXHKw3OEwNTETAUNYiVGnZU+B7a7bZC9f6/aPbJuV\nt8Qmg+UnDvW1Y8gmfHnxaWG2f5TDBvCHmcYtucIZPLQD4trAozC4ryqlmCWQNKbt\n0wIDAQAB\n-----END PUBLIC KEY-----\n" + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqtQQjwFLHPez+7uF9AX7\nuvLFHm3SyNIozhhVmGhxHIs0xdgRnZKmzmZkFdrFuXddBTAglU4C2u3dw10jJO1a\nWIFQF8bGkRHZG7Pd25/XmWWBRPmOJxNLeWBqpj0G+2zTMgnAV72hALSDFY2/QDsx\nUthenKw0Srpj1LUwvRbyVQQ8fGu4v0HACFnlOX2hCQwhfAnGrb0V70Y2IJu++MP7\n6i49md0vR0Mv3WbsEJUNp1fTIUzkgWB31icvfrNmaaAxw5FkAE+KfkkylhRxi5i5\nRR1XQUINWc2Kj2Kro+CJarKG+9zasMyN7+D230gpESi8rXv1SwRu865FR3gANdDS\nMwIDAQAB\n-----END PUBLIC KEY-----\n" }, "published": "2022-05-20T11:09:18Z", "summary": "\u003cp\u003ehey yo this is my profile!\u003c/p\u003e", @@ -216,7 +216,7 @@ func (suite *InternalToASTestSuite) TestAccountToASWithFields() { "publicKey": { "id": "http://localhost:8080/users/1happyturtle#main-key", "owner": "http://localhost:8080/users/1happyturtle", - "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtTc6Jpg6LrRPhVQG4KLz\n2+YqEUUtZPd4YR+TKXuCnwEG9ZNGhgP046xa9h3EWzrZXaOhXvkUQgJuRqPrAcfN\nvc8jBHV2xrUeD8pu/MWKEabAsA/tgCv3nUC47HQ3/c12aHfYoPz3ufWsGGnrkhci\nv8PaveJ3LohO5vjCn1yZ00v6osMJMViEZvZQaazyE9A8FwraIexXabDpoy7tkHRg\nA1fvSkg4FeSG1XMcIz2NN7xyUuFACD+XkuOk7UqzRd4cjPUPLxiDwIsTlcgGOd3E\nUFMWVlPxSGjY2hIKa3lEHytaYK9IMYdSuyCsJshd3/yYC9LqxZY2KdlKJ80VOVyh\nyQIDAQAB\n-----END PUBLIC KEY-----\n" + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwXTcOAvM1Jiw5Ffpk0qn\nr0cwbNvFe/5zQ+Tp7tumK/ZnT37o7X0FUEXrxNi+dkhmeJ0gsaiN+JQGNUewvpSk\nPIAXKvi908aSfCGjs7bGlJCJCuDuL5d6m7hZnP9rt9fJc70GElPpG0jc9fXwlz7T\nlsPb2ecatmG05Y4jPwdC+oN4MNCv9yQzEvCVMzl76EJaM602kIHC1CISn0rDFmYd\n9rSN7XPlNJw1F6PbpJ/BWQ+pXHKw3OEwNTETAUNYiVGnZU+B7a7bZC9f6/aPbJuV\nt8Qmg+UnDvW1Y8gmfHnxaWG2f5TDBvCHmcYtucIZPLQD4trAozC4ryqlmCWQNKbt\n0wIDAQAB\n-----END PUBLIC KEY-----\n" }, "published": "2022-06-04T13:12:00Z", "summary": "\u003cp\u003ei post about things that concern me\u003c/p\u003e", @@ -298,7 +298,7 @@ func (suite *InternalToASTestSuite) TestAccountToASAliasedAndMoved() { "publicKey": { "id": "http://localhost:8080/users/the_mighty_zork/main-key", "owner": "http://localhost:8080/users/the_mighty_zork", - "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwXTcOAvM1Jiw5Ffpk0qn\nr0cwbNvFe/5zQ+Tp7tumK/ZnT37o7X0FUEXrxNi+dkhmeJ0gsaiN+JQGNUewvpSk\nPIAXKvi908aSfCGjs7bGlJCJCuDuL5d6m7hZnP9rt9fJc70GElPpG0jc9fXwlz7T\nlsPb2ecatmG05Y4jPwdC+oN4MNCv9yQzEvCVMzl76EJaM602kIHC1CISn0rDFmYd\n9rSN7XPlNJw1F6PbpJ/BWQ+pXHKw3OEwNTETAUNYiVGnZU+B7a7bZC9f6/aPbJuV\nt8Qmg+UnDvW1Y8gmfHnxaWG2f5TDBvCHmcYtucIZPLQD4trAozC4ryqlmCWQNKbt\n0wIDAQAB\n-----END PUBLIC KEY-----\n" + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqtQQjwFLHPez+7uF9AX7\nuvLFHm3SyNIozhhVmGhxHIs0xdgRnZKmzmZkFdrFuXddBTAglU4C2u3dw10jJO1a\nWIFQF8bGkRHZG7Pd25/XmWWBRPmOJxNLeWBqpj0G+2zTMgnAV72hALSDFY2/QDsx\nUthenKw0Srpj1LUwvRbyVQQ8fGu4v0HACFnlOX2hCQwhfAnGrb0V70Y2IJu++MP7\n6i49md0vR0Mv3WbsEJUNp1fTIUzkgWB31icvfrNmaaAxw5FkAE+KfkkylhRxi5i5\nRR1XQUINWc2Kj2Kro+CJarKG+9zasMyN7+D230gpESi8rXv1SwRu865FR3gANdDS\nMwIDAQAB\n-----END PUBLIC KEY-----\n" }, "published": "2022-05-20T11:09:18Z", "summary": "\u003cp\u003ehey yo this is my profile!\u003c/p\u003e", @@ -360,7 +360,7 @@ func (suite *InternalToASTestSuite) TestAccountToASWithOneField() { "publicKey": { "id": "http://localhost:8080/users/1happyturtle#main-key", "owner": "http://localhost:8080/users/1happyturtle", - "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtTc6Jpg6LrRPhVQG4KLz\n2+YqEUUtZPd4YR+TKXuCnwEG9ZNGhgP046xa9h3EWzrZXaOhXvkUQgJuRqPrAcfN\nvc8jBHV2xrUeD8pu/MWKEabAsA/tgCv3nUC47HQ3/c12aHfYoPz3ufWsGGnrkhci\nv8PaveJ3LohO5vjCn1yZ00v6osMJMViEZvZQaazyE9A8FwraIexXabDpoy7tkHRg\nA1fvSkg4FeSG1XMcIz2NN7xyUuFACD+XkuOk7UqzRd4cjPUPLxiDwIsTlcgGOd3E\nUFMWVlPxSGjY2hIKa3lEHytaYK9IMYdSuyCsJshd3/yYC9LqxZY2KdlKJ80VOVyh\nyQIDAQAB\n-----END PUBLIC KEY-----\n" + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwXTcOAvM1Jiw5Ffpk0qn\nr0cwbNvFe/5zQ+Tp7tumK/ZnT37o7X0FUEXrxNi+dkhmeJ0gsaiN+JQGNUewvpSk\nPIAXKvi908aSfCGjs7bGlJCJCuDuL5d6m7hZnP9rt9fJc70GElPpG0jc9fXwlz7T\nlsPb2ecatmG05Y4jPwdC+oN4MNCv9yQzEvCVMzl76EJaM602kIHC1CISn0rDFmYd\n9rSN7XPlNJw1F6PbpJ/BWQ+pXHKw3OEwNTETAUNYiVGnZU+B7a7bZC9f6/aPbJuV\nt8Qmg+UnDvW1Y8gmfHnxaWG2f5TDBvCHmcYtucIZPLQD4trAozC4ryqlmCWQNKbt\n0wIDAQAB\n-----END PUBLIC KEY-----\n" }, "published": "2022-06-04T13:12:00Z", "summary": "\u003cp\u003ei post about things that concern me\u003c/p\u003e", @@ -422,7 +422,7 @@ func (suite *InternalToASTestSuite) TestAccountToASWithEmoji() { "publicKey": { "id": "http://localhost:8080/users/the_mighty_zork/main-key", "owner": "http://localhost:8080/users/the_mighty_zork", - "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwXTcOAvM1Jiw5Ffpk0qn\nr0cwbNvFe/5zQ+Tp7tumK/ZnT37o7X0FUEXrxNi+dkhmeJ0gsaiN+JQGNUewvpSk\nPIAXKvi908aSfCGjs7bGlJCJCuDuL5d6m7hZnP9rt9fJc70GElPpG0jc9fXwlz7T\nlsPb2ecatmG05Y4jPwdC+oN4MNCv9yQzEvCVMzl76EJaM602kIHC1CISn0rDFmYd\n9rSN7XPlNJw1F6PbpJ/BWQ+pXHKw3OEwNTETAUNYiVGnZU+B7a7bZC9f6/aPbJuV\nt8Qmg+UnDvW1Y8gmfHnxaWG2f5TDBvCHmcYtucIZPLQD4trAozC4ryqlmCWQNKbt\n0wIDAQAB\n-----END PUBLIC KEY-----\n" + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqtQQjwFLHPez+7uF9AX7\nuvLFHm3SyNIozhhVmGhxHIs0xdgRnZKmzmZkFdrFuXddBTAglU4C2u3dw10jJO1a\nWIFQF8bGkRHZG7Pd25/XmWWBRPmOJxNLeWBqpj0G+2zTMgnAV72hALSDFY2/QDsx\nUthenKw0Srpj1LUwvRbyVQQ8fGu4v0HACFnlOX2hCQwhfAnGrb0V70Y2IJu++MP7\n6i49md0vR0Mv3WbsEJUNp1fTIUzkgWB31icvfrNmaaAxw5FkAE+KfkkylhRxi5i5\nRR1XQUINWc2Kj2Kro+CJarKG+9zasMyN7+D230gpESi8rXv1SwRu865FR3gANdDS\nMwIDAQAB\n-----END PUBLIC KEY-----\n" }, "published": "2022-05-20T11:09:18Z", "summary": "\u003cp\u003ehey yo this is my profile!\u003c/p\u003e", @@ -497,7 +497,7 @@ func (suite *InternalToASTestSuite) TestAccountToASWithSharedInbox() { "publicKey": { "id": "http://localhost:8080/users/the_mighty_zork/main-key", "owner": "http://localhost:8080/users/the_mighty_zork", - "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwXTcOAvM1Jiw5Ffpk0qn\nr0cwbNvFe/5zQ+Tp7tumK/ZnT37o7X0FUEXrxNi+dkhmeJ0gsaiN+JQGNUewvpSk\nPIAXKvi908aSfCGjs7bGlJCJCuDuL5d6m7hZnP9rt9fJc70GElPpG0jc9fXwlz7T\nlsPb2ecatmG05Y4jPwdC+oN4MNCv9yQzEvCVMzl76EJaM602kIHC1CISn0rDFmYd\n9rSN7XPlNJw1F6PbpJ/BWQ+pXHKw3OEwNTETAUNYiVGnZU+B7a7bZC9f6/aPbJuV\nt8Qmg+UnDvW1Y8gmfHnxaWG2f5TDBvCHmcYtucIZPLQD4trAozC4ryqlmCWQNKbt\n0wIDAQAB\n-----END PUBLIC KEY-----\n" + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqtQQjwFLHPez+7uF9AX7\nuvLFHm3SyNIozhhVmGhxHIs0xdgRnZKmzmZkFdrFuXddBTAglU4C2u3dw10jJO1a\nWIFQF8bGkRHZG7Pd25/XmWWBRPmOJxNLeWBqpj0G+2zTMgnAV72hALSDFY2/QDsx\nUthenKw0Srpj1LUwvRbyVQQ8fGu4v0HACFnlOX2hCQwhfAnGrb0V70Y2IJu++MP7\n6i49md0vR0Mv3WbsEJUNp1fTIUzkgWB31icvfrNmaaAxw5FkAE+KfkkylhRxi5i5\nRR1XQUINWc2Kj2Kro+CJarKG+9zasMyN7+D230gpESi8rXv1SwRu865FR3gANdDS\nMwIDAQAB\n-----END PUBLIC KEY-----\n" }, "published": "2022-05-20T11:09:18Z", "summary": "\u003cp\u003ehey yo this is my profile!\u003c/p\u003e", diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index b0e137f75..b0f5d12fa 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -137,6 +137,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), + WebLayout: a.Settings.WebLayout.String(), Sensitive: *a.Settings.Sensitive, Language: a.Settings.Language, StatusContentType: statusContentType, @@ -222,6 +223,14 @@ func (c *Converter) AccountToWebAccount( } } + // Check for presence of settings before + // populating settings-specific thingies, + // as instance account doesn't store a + // settings struct. + if a.Settings != nil { + webAccount.WebLayout = a.Settings.WebLayout.String() + } + return webAccount, nil } @@ -1227,10 +1236,11 @@ func (c *Converter) StatusToWebStatus( for i, apiAttachment := range apiStatus.MediaAttachments { ogAttachment := ogAttachments[apiAttachment.ID] webStatus.MediaAttachments[i] = &apimodel.WebAttachment{ - Attachment: apiAttachment, - Sensitive: apiStatus.Sensitive, - MIMEType: ogAttachment.File.ContentType, - PreviewMIMEType: ogAttachment.Thumbnail.ContentType, + Attachment: apiAttachment, + Sensitive: apiStatus.Sensitive, + MIMEType: ogAttachment.File.ContentType, + PreviewMIMEType: ogAttachment.Thumbnail.ContentType, + ParentStatusLink: apiStatus.URL, } } diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index c94d4481a..d70c210f3 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -128,6 +128,7 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendAliasedAndMoved() "source": { "privacy": "public", "web_visibility": "unlisted", + "web_layout": "microblog", "sensitive": false, "language": "en", "status_content_type": "text/plain", @@ -324,6 +325,7 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendSensitive() { "source": { "privacy": "public", "web_visibility": "unlisted", + "web_layout": "microblog", "sensitive": false, "language": "en", "status_content_type": "text/plain", @@ -1815,7 +1817,8 @@ func (suite *InternalToFrontendTestSuite) TestStatusToWebStatus() { "blurhash": "LKE3VIw}0KD%a2o{M|t7NFWps:t7", "Sensitive": true, "MIMEType": "image/jpg", - "PreviewMIMEType": "image/webp" + "PreviewMIMEType": "image/webp", + "ParentStatusLink": "http://example.org/@Some_User/statuses/01HE7XJ1CG84TBKH5V9XKBVGF5" }, { "id": "01HE7ZFX9GKA5ZZVD4FACABSS9", @@ -1830,7 +1833,8 @@ func (suite *InternalToFrontendTestSuite) TestStatusToWebStatus() { "blurhash": "L26*j+~qE1RP?wxut7ofRlM{R*of", "Sensitive": true, "MIMEType": "", - "PreviewMIMEType": "" + "PreviewMIMEType": "", + "ParentStatusLink": "http://example.org/@Some_User/statuses/01HE7XJ1CG84TBKH5V9XKBVGF5" }, { "id": "01HE88YG74PVAB81PX2XA9F3FG", @@ -1845,7 +1849,8 @@ func (suite *InternalToFrontendTestSuite) TestStatusToWebStatus() { "blurhash": null, "Sensitive": true, "MIMEType": "", - "PreviewMIMEType": "" + "PreviewMIMEType": "", + "ParentStatusLink": "http://example.org/@Some_User/statuses/01HE7XJ1CG84TBKH5V9XKBVGF5" } ], "LanguageTag": "en", @@ -2364,8 +2369,8 @@ func (suite *InternalToFrontendTestSuite) TestInstanceV1ToFrontend() { }, "stats": { "domain_count": 2, - "status_count": 21, - "user_count": 4 + "status_count": 23, + "user_count": 5 }, "thumbnail": "http://localhost:8080/assets/logo.webp", "contact_account": { diff --git a/internal/typeutils/wrap_test.go b/internal/typeutils/wrap_test.go index 9076c6cbc..dde4f18b5 100644 --- a/internal/typeutils/wrap_test.go +++ b/internal/typeutils/wrap_test.go @@ -206,7 +206,7 @@ func (suite *WrapTestSuite) TestWrapAccountableInUpdate() { "publicKey": { "id": "http://localhost:8080/users/the_mighty_zork/main-key", "owner": "http://localhost:8080/users/the_mighty_zork", - "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwXTcOAvM1Jiw5Ffpk0qn\nr0cwbNvFe/5zQ+Tp7tumK/ZnT37o7X0FUEXrxNi+dkhmeJ0gsaiN+JQGNUewvpSk\nPIAXKvi908aSfCGjs7bGlJCJCuDuL5d6m7hZnP9rt9fJc70GElPpG0jc9fXwlz7T\nlsPb2ecatmG05Y4jPwdC+oN4MNCv9yQzEvCVMzl76EJaM602kIHC1CISn0rDFmYd\n9rSN7XPlNJw1F6PbpJ/BWQ+pXHKw3OEwNTETAUNYiVGnZU+B7a7bZC9f6/aPbJuV\nt8Qmg+UnDvW1Y8gmfHnxaWG2f5TDBvCHmcYtucIZPLQD4trAozC4ryqlmCWQNKbt\n0wIDAQAB\n-----END PUBLIC KEY-----\n" + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqtQQjwFLHPez+7uF9AX7\nuvLFHm3SyNIozhhVmGhxHIs0xdgRnZKmzmZkFdrFuXddBTAglU4C2u3dw10jJO1a\nWIFQF8bGkRHZG7Pd25/XmWWBRPmOJxNLeWBqpj0G+2zTMgnAV72hALSDFY2/QDsx\nUthenKw0Srpj1LUwvRbyVQQ8fGu4v0HACFnlOX2hCQwhfAnGrb0V70Y2IJu++MP7\n6i49md0vR0Mv3WbsEJUNp1fTIUzkgWB31icvfrNmaaAxw5FkAE+KfkkylhRxi5i5\nRR1XQUINWc2Kj2Kro+CJarKG+9zasMyN7+D230gpESi8rXv1SwRu865FR3gANdDS\nMwIDAQAB\n-----END PUBLIC KEY-----\n" }, "published": "2022-05-20T11:09:18Z", "summary": "\u003cp\u003ehey yo this is my profile!\u003c/p\u003e", diff --git a/internal/web/profile.go b/internal/web/profile.go index cf12ca33a..52d918b48 100644 --- a/internal/web/profile.go +++ b/internal/web/profile.go @@ -19,7 +19,6 @@ package web import ( "context" - "encoding/json" "fmt" "net/http" "strings" @@ -28,9 +27,24 @@ import ( apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/log" ) -func (m *Module) profileGETHandler(c *gin.Context) { +type profile struct { + instance *apimodel.InstanceV1 + account *apimodel.WebAccount + rssFeed string + robotsMeta string + pinnedStatuses []*apimodel.WebStatus + statusResp *apimodel.PageableResponse + paging bool +} + +// prepareProfile does content type checks, fetches the +// targeted account from the db, and converts it to its +// web representation, along with other data needed to +// render the web view of the account. +func (m *Module) prepareProfile(c *gin.Context) *profile { ctx := c.Request.Context() // We'll need the instance later, and we can also use it @@ -38,7 +52,7 @@ func (m *Module) profileGETHandler(c *gin.Context) { instance, errWithCode := m.processor.InstanceGetV1(ctx) if errWithCode != nil { apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1) - return + return nil } // Return instance we already got from the db, @@ -47,90 +61,142 @@ func (m *Module) profileGETHandler(c *gin.Context) { return instance, nil } - // Parse account targetUsername from the URL. - targetUsername, errWithCode := apiutil.ParseUsername(c.Param(apiutil.UsernameKey)) + // Parse + normalize account username from the URL. + requestedUsername, errWithCode := apiutil.ParseUsername(c.Param(apiutil.UsernameKey)) if errWithCode != nil { apiutil.WebErrorHandler(c, errWithCode, instanceGet) - return + return nil } + requestedUsername = strings.ToLower(requestedUsername) - // Normalize requested username: - // - // - Usernames on our instance are (currently) always lowercase. - // - // todo: Update this logic when different username patterns - // are allowed, and/or when status slugs are introduced. - targetUsername = strings.ToLower(targetUsername) - - // Check what type of content is being requested. If we're getting an AP - // request on this endpoint we should render the AP representation instead. - accept, err := apiutil.NegotiateAccept(c, apiutil.HTMLOrActivityPubHeaders...) + // Check what type of content is being requested. + // If we're getting an AP request on this endpoint + // we should render the AP representation instead. + contentType, err := apiutil.NegotiateAccept(c, apiutil.HTMLOrActivityPubHeaders...) if err != nil { apiutil.WebErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), instanceGet) - return + return nil } - if accept == string(apiutil.AppActivityJSON) || accept == string(apiutil.AppActivityLDJSON) { - // AP account representation has been requested. - m.returnAPAccount(c, targetUsername, accept, instanceGet) - return + if contentType == string(apiutil.AppActivityJSON) || + contentType == string(apiutil.AppActivityLDJSON) { + // AP account representation has + // been requested, return that. + m.returnAPAccount(c, requestedUsername, contentType) + return nil } - // text/html has been requested. Proceed with getting the web view of the account. - - // Fetch the target account so we can do some checks on it. - targetAccount, errWithCode := m.processor.Account().GetWeb(ctx, targetUsername) + // text/html has been requested. + // + // Proceed with getting the web + // representation of the account. + account, errWithCode := m.processor.Account().GetWeb(ctx, requestedUsername) if errWithCode != nil { apiutil.WebErrorHandler(c, errWithCode, instanceGet) - return + return nil } - // If target account is suspended, this page should not be visible. + // If target account is suspended, + // this page should not be visible. + // // TODO: change this to 410? - if targetAccount.Suspended { - err := fmt.Errorf("target account %s is suspended", targetUsername) + if account.Suspended { + err := fmt.Errorf("target account %s is suspended", requestedUsername) apiutil.WebErrorHandler(c, gtserror.NewErrorNotFound(err), instanceGet) - return + return nil } - // Only generate RSS link if account has RSS enabled. + // Only generate RSS link if + // account has RSS enabled. var rssFeed string - if targetAccount.EnableRSS { - rssFeed = "/@" + targetAccount.Username + "/feed.rss" + if account.EnableRSS { + rssFeed = "/@" + account.Username + "/feed.rss" } - // Only allow search engines / robots to - // index if account is discoverable. + // Only allow search robots + // if account is discoverable. var robotsMeta string - if targetAccount.Discoverable { + if account.Discoverable { robotsMeta = apiutil.RobotsDirectivesAllowSome } - // We need to change our response slightly if the - // profile visitor is paging through statuses. + // Check if paging. + maxStatusID := apiutil.ParseMaxID(c.Query(apiutil.MaxIDKey), "") + paging := maxStatusID != "" + + // If not paging, load pinned statuses. var ( - maxStatusID = apiutil.ParseMaxID(c.Query(apiutil.MaxIDKey), "") - paging = maxStatusID != "" + mediaOnly = account.WebLayout == "gallery" pinnedStatuses []*apimodel.WebStatus ) - if !paging { - // Client opened bare profile (from the top) - // so load + display pinned statuses. - pinnedStatuses, errWithCode = m.processor.Account().WebStatusesGetPinned(ctx, targetAccount.ID) + var errWithCode gtserror.WithCode + pinnedStatuses, errWithCode = m.processor.Account().WebStatusesGetPinned( + ctx, + account.ID, + mediaOnly, + ) if errWithCode != nil { apiutil.WebErrorHandler(c, errWithCode, instanceGet) - return + return nil } } // Get statuses from maxStatusID onwards (or from top if empty string). - statusResp, errWithCode := m.processor.Account().WebStatusesGet(ctx, targetAccount.ID, maxStatusID) + statusResp, errWithCode := m.processor.Account().WebStatusesGet( + ctx, + account.ID, + mediaOnly, + maxStatusID, + ) if errWithCode != nil { apiutil.WebErrorHandler(c, errWithCode, instanceGet) + return nil + } + + return &profile{ + instance: instance, + account: account, + rssFeed: rssFeed, + robotsMeta: robotsMeta, + pinnedStatuses: pinnedStatuses, + statusResp: statusResp, + paging: paging, + } +} + +// profileGETHandler selects the appropriate rendering +// mode for the target account profile, and serves that. +func (m *Module) profileGETHandler(c *gin.Context) { + p := m.prepareProfile(c) + if p == nil { + // Something went wrong, + // error already written. return } + // Choose desired web renderer for this acct. + switch wrm := p.account.WebLayout; wrm { + + // El classico. + case "", "microblog": + m.profileMicroblog(c, p) + + // 'gram style media gallery. + case "gallery": + m.profileGallery(c, p) + + default: + log.Panicf( + c.Request.Context(), + "unknown webrenderingmode %s", wrm, + ) + } +} + +// profileMicroblog serves the profile +// in classic GtS "microblog" view. +func (m *Module) profileMicroblog(c *gin.Context, p *profile) { // Prepare stylesheets for profile. stylesheets := make([]string, 0, 7) @@ -146,7 +212,7 @@ func (m *Module) profileGETHandler(c *gin.Context) { ) // User-selected theme if set. - if theme := targetAccount.Theme; theme != "" { + if theme := p.account.Theme; theme != "" { stylesheets = append( stylesheets, themesPathPrefix+"/"+theme, @@ -156,23 +222,89 @@ func (m *Module) profileGETHandler(c *gin.Context) { // Custom CSS for this user last in cascade. stylesheets = append( stylesheets, - "/@"+targetAccount.Username+"/custom.css", + "/@"+p.account.Username+"/custom.css", ) page := apiutil.WebPage{ Template: "profile.tmpl", - Instance: instance, - OGMeta: apiutil.OGBase(instance).WithAccount(targetAccount), + Instance: p.instance, + OGMeta: apiutil.OGBase(p.instance).WithAccount(p.account), + Stylesheets: stylesheets, + Javascript: []string{jsFrontend}, + Extra: map[string]any{ + "account": p.account, + "rssFeed": p.rssFeed, + "robotsMeta": p.robotsMeta, + "statuses": p.statusResp.Items, + "statuses_next": p.statusResp.NextLink, + "pinned_statuses": p.pinnedStatuses, + "show_back_to_top": p.paging, + }, + } + + apiutil.TemplateWebPage(c, page) +} + +// profileMicroblog serves the profile +// in media-only 'gram-style gallery view. +func (m *Module) profileGallery(c *gin.Context, p *profile) { + // Get just attachments from pinned, + // making a rough guess for slice size. + pinnedGalleryItems := make([]*apimodel.WebAttachment, 0, len(p.pinnedStatuses)*4) + for _, status := range p.pinnedStatuses { + pinnedGalleryItems = append(pinnedGalleryItems, status.MediaAttachments...) + } + + // Get just attachments from statuses, + // making a rough guess for slice size. + galleryItems := make([]*apimodel.WebAttachment, 0, len(p.statusResp.Items)*4) + for _, statusI := range p.statusResp.Items { + status := statusI.(*apimodel.WebStatus) + galleryItems = append(galleryItems, status.MediaAttachments...) + } + + // Prepare stylesheets for profile. + stylesheets := make([]string, 0, 4) + + // Profile gallery stylesheets. + stylesheets = append( + stylesheets, + []string{ + cssFA, + cssProfileGallery, + }...) + + // User-selected theme if set. + if theme := p.account.Theme; theme != "" { + stylesheets = append( + stylesheets, + themesPathPrefix+"/"+theme, + ) + } + + // Custom CSS for this + // user last in cascade. + stylesheets = append( + stylesheets, + "/@"+p.account.Username+"/custom.css", + ) + + page := apiutil.WebPage{ + Template: "profile-gallery.tmpl", + Instance: p.instance, + OGMeta: apiutil.OGBase(p.instance).WithAccount(p.account), Stylesheets: stylesheets, Javascript: []string{jsFrontend}, Extra: map[string]any{ - "account": targetAccount, - "rssFeed": rssFeed, - "robotsMeta": robotsMeta, - "statuses": statusResp.Items, - "statuses_next": statusResp.NextLink, - "pinned_statuses": pinnedStatuses, - "show_back_to_top": paging, + "account": p.account, + "rssFeed": p.rssFeed, + "robotsMeta": p.robotsMeta, + "pinnedGalleryItems": pinnedGalleryItems, + "galleryItems": galleryItems, + "statuses": p.statusResp.Items, + "statuses_next": p.statusResp.NextLink, + "pinned_statuses": p.pinnedStatuses, + "show_back_to_top": p.paging, }, } @@ -184,8 +316,7 @@ func (m *Module) profileGETHandler(c *gin.Context) { func (m *Module) returnAPAccount( c *gin.Context, targetUsername string, - accept string, - instanceGet func(ctx context.Context) (*apimodel.InstanceV1, gtserror.WithCode), + contentType string, ) { user, errWithCode := m.processor.Fedi().UserGet(c.Request.Context(), targetUsername, c.Request.URL) if errWithCode != nil { @@ -193,12 +324,5 @@ func (m *Module) returnAPAccount( return } - b, err := json.Marshal(user) - if err != nil { - err := gtserror.Newf("could not marshal json: %w", err) - apiutil.WebErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1) - return - } - - c.Data(http.StatusOK, accept, b) + apiutil.JSONType(c, http.StatusOK, contentType, user) } diff --git a/internal/web/web.go b/internal/web/web.go index e5d4db4c4..dbfc2a3b5 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -56,15 +56,16 @@ const ( eTagHeader = "ETag" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag lastModifiedHeader = "Last-Modified" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified - cssFA = assetsPathPrefix + "/Fork-Awesome/css/fork-awesome.min.css" - cssAbout = distPathPrefix + "/about.css" - cssIndex = distPathPrefix + "/index.css" - cssLoginInfo = distPathPrefix + "/login-info.css" - cssStatus = distPathPrefix + "/status.css" - cssThread = distPathPrefix + "/thread.css" - cssProfile = distPathPrefix + "/profile.css" - cssSettings = distPathPrefix + "/settings-style.css" - cssTag = distPathPrefix + "/tag.css" + cssFA = assetsPathPrefix + "/Fork-Awesome/css/fork-awesome.min.css" + cssAbout = distPathPrefix + "/about.css" + cssIndex = distPathPrefix + "/index.css" + cssLoginInfo = distPathPrefix + "/login-info.css" + cssStatus = distPathPrefix + "/status.css" + cssThread = distPathPrefix + "/thread.css" + cssProfile = distPathPrefix + "/profile.css" + cssProfileGallery = distPathPrefix + "/profile-gallery.css" + cssSettings = distPathPrefix + "/settings-style.css" + cssTag = distPathPrefix + "/tag.css" jsFrontend = distPathPrefix + "/frontend.js" // Progressive enhancement frontend JS. jsSettings = distPathPrefix + "/settings.js" // Settings panel React application. |
