diff --git a/cmd/process-media/main.go b/cmd/process-media/main.go index 9fc4983ec..27d1de201 100644 --- a/cmd/process-media/main.go +++ b/cmd/process-media/main.go @@ -19,14 +19,18 @@ package main import ( "context" + "fmt" "io" "os" "os/signal" + "strings" "syscall" + "time" "codeberg.org/gruf/go-storage/memory" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db/bundb" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/media/ffmpeg" @@ -39,7 +43,7 @@ func main() { ctx, cncl := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT) defer cncl() - log.SetLevel(log.INFO) + log.SetLevel(log.ERROR) if len(os.Args) != 4 { log.Panic(ctx, "Usage: go run ./cmd/process-media ") @@ -63,7 +67,8 @@ func main() { var err error - config.SetHost("example.com") + config.SetProtocol("http") + config.SetHost("localhost:8080") config.SetStorageBackend("disk") config.SetStorageLocalBasePath("/tmp/gotosocial") config.SetDbType("sqlite") @@ -109,6 +114,7 @@ func main() { log.Panic(ctx, err) } + outputCopyable(media) copyFile(ctx, &st, media.File.Path, os.Args[2]) copyFile(ctx, &st, media.Thumbnail.Path, os.Args[3]) } @@ -136,3 +142,104 @@ func copyFile(ctx context.Context, st *storage.Driver, key string, path string) log.Panic(ctx, err) } } + +func outputCopyable(media *gtsmodel.MediaAttachment) { + var ( + now = time.Now() + nowStr = now.Format(time.RFC3339) + mediaType string + fileMetaExtra string + ) + + switch media.Type { + case gtsmodel.FileTypeImage: + mediaType = "gtsmodel.FileTypeImage" + case gtsmodel.FileTypeVideo: + mediaType = "gtsmodel.FileTypeVideo" + case gtsmodel.FileTypeGifv: + mediaType = "gtsmodel.FileTypeGifv" + case gtsmodel.FileTypeAudio: + mediaType = "gtsmodel.FileTypeAudio" + case gtsmodel.FileTypeUnknown: + mediaType = "gtsmodel.FileTypeUnknown" + } + + if media.FileMeta.Original.Duration != nil { + fileMetaExtra += fmt.Sprintf("\n\t\t\tDuration: util.Ptr[float32](%f),", *media.FileMeta.Original.Duration) + } + if media.FileMeta.Original.Framerate != nil { + fileMetaExtra += fmt.Sprintf("\n\t\t\tFramerate: util.Ptr[float32](%f),", *media.FileMeta.Original.Framerate) + } + if media.FileMeta.Original.Bitrate != nil { + fileMetaExtra += fmt.Sprintf("\n\t\t\tBitrate: util.Ptr[uint64](%d),", *media.FileMeta.Original.Bitrate) + } + + fmt.Printf(`{ + ID: "%s", + StatusID: "STATUS_ID_GOES_HERE", + URL: "%s", + RemoteURL: "", + CreatedAt: TimeMustParse("%s"), + Type: %s, + FileMeta: gtsmodel.FileMeta{ + Original: gtsmodel.Original{ + Width: %d, + Height: %d, + Size: %d, + Aspect: %f,%s + }, + Small: gtsmodel.Small{ + Width: %d, + Height: %d, + Size: %d, + Aspect: %f, + }, + Focus: gtsmodel.Focus{ + X: 0, + Y: 0, + }, + }, + AccountID: "ACCOUNT_ID_GOES_HERE", + Description: "DESCRIPTION_GOES_HERE", + ScheduledStatusID: "", + Blurhash: "%s", + Processing: 2, + File: gtsmodel.File{ + Path: "%s", + ContentType: "%s", + FileSize: %d, + }, + Thumbnail: gtsmodel.Thumbnail{ + Path: "%s", + ContentType: "%s", + FileSize: %d, + URL: "%s", + RemoteURL: "", + }, + Avatar: util.Ptr(false), + Header: util.Ptr(false), + Cached: util.Ptr(true), +}`+"\n", + media.ID, + strings.ReplaceAll(media.URL, media.AccountID, "ACCOUNT_ID_GOES_HERE"), + nowStr, + mediaType, + media.FileMeta.Original.Width, + media.FileMeta.Original.Height, + media.FileMeta.Original.Size, + media.FileMeta.Original.Aspect, + fileMetaExtra, + media.FileMeta.Small.Width, + media.FileMeta.Small.Height, + media.FileMeta.Small.Size, + media.FileMeta.Small.Aspect, + media.Blurhash, + strings.ReplaceAll(media.File.Path, media.AccountID, "ACCOUNT_ID_GOES_HERE"), + media.File.ContentType, + media.File.FileSize, + strings.ReplaceAll(media.Thumbnail.Path, media.AccountID, "ACCOUNT_ID_GOES_HERE"), + media.Thumbnail.ContentType, + media.Thumbnail.FileSize, + strings.ReplaceAll(media.Thumbnail.URL, media.AccountID, "ACCOUNT_ID_GOES_HERE"), + ) +} diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index ce415784a..1a96b0f65 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -179,6 +179,13 @@ definitions: description: The default posting content type for new statuses. type: string x-go-name: StatusContentType + web_layout: + description: |- + Layout to use for the web view of the account. + "microblog": default, classic microblog layout. + "gallery": gallery layout with media only. + type: string + x-go-name: WebLayout web_visibility: description: |- Visibility level(s) of posts to show for this account via the web api. @@ -4902,6 +4909,13 @@ paths: in: formData name: web_visibility type: string + - description: |- + Layout to use for the web view of the account. + "microblog": default, classic microblog layout. + "gallery": gallery layout with media only. + in: formData + name: web_layout + 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/overrides/public/user-settings-layout-gallery.png b/docs/overrides/public/user-settings-layout-gallery.png new file mode 100644 index 000000000..75a57f8e5 Binary files /dev/null and b/docs/overrides/public/user-settings-layout-gallery.png differ diff --git a/docs/overrides/public/user-settings-layout-microblog.png b/docs/overrides/public/user-settings-layout-microblog.png new file mode 100644 index 000000000..a573e327b Binary files /dev/null and b/docs/overrides/public/user-settings-layout-microblog.png differ diff --git a/docs/user_guide/settings.md b/docs/user_guide/settings.md index 590b04b42..3d7bd811c 100644 --- a/docs/user_guide/settings.md +++ b/docs/user_guide/settings.md @@ -31,6 +31,30 @@ To choose a theme, just select it from the profile settings page, and click/tap !!! tip "Adding more themes" Instance admins can add more themes by dropping css files into the `web/assets/themes` folder. See the [themes](../admin/themes.md) part of the admin docs for more information. +### Select Layout + +GoToSocial lets you choose from two different layouts for the web view of your profile. + +The setting does not affect how the API behaves or how client applications look or work, it's purely a cosmetic change for the web view. + +In both cases, only top-level posts (or media from top-level posts) is shown, not replies or boosts, and the [Visibility Level of Posts to Show on Your Profile](#visibility-level-of-posts-to-show-on-your-profile) setting is respected. + +#### Microblog + +The GtS classic microblog layout. Your profile is split into two columns with your bio and recent/pinned posts. + +This is a good choice if you primarily post text, or a mixture of text and media. + +![Microblog layout](../public/user-settings-layout-microblog.png) + +#### Gallery + +'Gram-style layout. Posts are not shown directly on your profile. Instead, your recent/pinned media is shown in a gallery grid view. Posts (with their replies) can still be accessed via link. + +This is a good choice if you primarily post media. + +![Gallery layout](../public/user-settings-layout-gallery.png) + ### Basic Information #### Display Name 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 @@ -222,6 +222,69 @@ func (suite *AccountsGetTestSuite) TestAccountsGetFromTop() { "group": false } }, + { + "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": "

I'm a test account that posts a shitload of media and I have my account rendered in \"gallery\" mode

", + "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", @@ -547,18 +610,18 @@ func (suite *AccountsGetTestSuite) TestAccountsMinID() { } link := recorder.Header().Get("Link") - suite.Equal(`; rel="next", ; rel="prev"`, link) + suite.Equal(`; rel="next", ; 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": "

I'm a test account that posts a shitload of media and I have my account rendered in \"gallery\" mode

", + "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) - // return only statuses LOWER (ie., older) than maxID + // Respect media-only preference. + if mediaOnly { + q = qMediaOnly(q) + } + + // 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 . + +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": 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, + "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": 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. diff --git a/testrig/media/bunny-original.webm b/testrig/media/bunny-original.webm new file mode 100644 index 000000000..6716c0066 Binary files /dev/null and b/testrig/media/bunny-original.webm differ diff --git a/testrig/media/bunny-small.webp b/testrig/media/bunny-small.webp new file mode 100644 index 000000000..09ff1b8ed Binary files /dev/null and b/testrig/media/bunny-small.webp differ diff --git a/testrig/media/buscemi-original.jpeg b/testrig/media/buscemi-original.jpeg new file mode 100644 index 000000000..0a0a0db2a Binary files /dev/null and b/testrig/media/buscemi-original.jpeg differ diff --git a/testrig/media/buscemi-small.jpeg b/testrig/media/buscemi-small.jpeg new file mode 100644 index 000000000..60f933bf7 Binary files /dev/null and b/testrig/media/buscemi-small.jpeg differ diff --git a/testrig/media/butt-original.gif b/testrig/media/butt-original.gif new file mode 100644 index 000000000..078b75561 Binary files /dev/null and b/testrig/media/butt-original.gif differ diff --git a/testrig/media/butt-small.webp b/testrig/media/butt-small.webp new file mode 100644 index 000000000..9cc638d19 Binary files /dev/null and b/testrig/media/butt-small.webp differ diff --git a/testrig/media/computerbye-original.gif b/testrig/media/computerbye-original.gif new file mode 100644 index 000000000..b13410dc7 Binary files /dev/null and b/testrig/media/computerbye-original.gif differ diff --git a/testrig/media/computerbye-small.webp b/testrig/media/computerbye-small.webp new file mode 100644 index 000000000..55a46c38a Binary files /dev/null and b/testrig/media/computerbye-small.webp differ diff --git a/testrig/media/diarrhea-original.gif b/testrig/media/diarrhea-original.gif new file mode 100644 index 000000000..364743a9e Binary files /dev/null and b/testrig/media/diarrhea-original.gif differ diff --git a/testrig/media/diarrhea-small.webp b/testrig/media/diarrhea-small.webp new file mode 100644 index 000000000..7b910e3e6 Binary files /dev/null and b/testrig/media/diarrhea-small.webp differ diff --git a/testrig/media/dollar-original.jpeg b/testrig/media/dollar-original.jpeg new file mode 100644 index 000000000..48b4caf70 Binary files /dev/null and b/testrig/media/dollar-original.jpeg differ diff --git a/testrig/media/dollar-small.jpeg b/testrig/media/dollar-small.jpeg new file mode 100644 index 000000000..91c2a8d54 Binary files /dev/null and b/testrig/media/dollar-small.jpeg differ diff --git a/testrig/media/dollar2-original.png b/testrig/media/dollar2-original.png new file mode 100644 index 000000000..a5b99b191 Binary files /dev/null and b/testrig/media/dollar2-original.png differ diff --git a/testrig/media/dollar2-small.webp b/testrig/media/dollar2-small.webp new file mode 100644 index 000000000..c6e50d6b1 Binary files /dev/null and b/testrig/media/dollar2-small.webp differ diff --git a/testrig/media/ffmpreg-original.jpeg b/testrig/media/ffmpreg-original.jpeg new file mode 100644 index 000000000..c0d08a9a5 Binary files /dev/null and b/testrig/media/ffmpreg-original.jpeg differ diff --git a/testrig/media/ffmpreg-small.jpeg b/testrig/media/ffmpreg-small.jpeg new file mode 100644 index 000000000..9d5724bdf Binary files /dev/null and b/testrig/media/ffmpreg-small.jpeg differ diff --git a/testrig/media/marge-original.png b/testrig/media/marge-original.png new file mode 100644 index 000000000..c3a1c6c3d Binary files /dev/null and b/testrig/media/marge-original.png differ diff --git a/testrig/media/marge-small.webp b/testrig/media/marge-small.webp new file mode 100644 index 000000000..4dc49c60c Binary files /dev/null and b/testrig/media/marge-small.webp differ diff --git a/testrig/media/notabug-original.jpeg b/testrig/media/notabug-original.jpeg new file mode 100644 index 000000000..1086fff63 Binary files /dev/null and b/testrig/media/notabug-original.jpeg differ diff --git a/testrig/media/notabug-small.jpeg b/testrig/media/notabug-small.jpeg new file mode 100644 index 000000000..a3ee0db48 Binary files /dev/null and b/testrig/media/notabug-small.jpeg differ diff --git a/testrig/media/sickos-original.jpeg b/testrig/media/sickos-original.jpeg new file mode 100644 index 000000000..1a0fb37ec Binary files /dev/null and b/testrig/media/sickos-original.jpeg differ diff --git a/testrig/media/sickos-small.jpeg b/testrig/media/sickos-small.jpeg new file mode 100644 index 000000000..69c8a9f94 Binary files /dev/null and b/testrig/media/sickos-small.jpeg differ diff --git a/testrig/media/sloth-gear-original.webp b/testrig/media/sloth-gear-original.webp new file mode 100644 index 000000000..d3b2b0426 Binary files /dev/null and b/testrig/media/sloth-gear-original.webp differ diff --git a/testrig/media/sloth-gear-small.jpeg b/testrig/media/sloth-gear-small.jpeg new file mode 100644 index 000000000..28d48cc7a Binary files /dev/null and b/testrig/media/sloth-gear-small.jpeg differ diff --git a/testrig/media/you-posted-original.webp b/testrig/media/you-posted-original.webp new file mode 100644 index 000000000..ed26e34a2 Binary files /dev/null and b/testrig/media/you-posted-original.webp differ diff --git a/testrig/media/you-posted-small.webp b/testrig/media/you-posted-small.webp new file mode 100644 index 000000000..e6f669543 Binary files /dev/null and b/testrig/media/you-posted-small.webp differ diff --git a/testrig/testmodels.go b/testrig/testmodels.go index d8c91b611..08ca3b943 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -95,6 +95,15 @@ func NewTestTokens() map[string]*gtsmodel.Token { AccessCreateAt: TimeMustParse("2022-06-10T15:22:08Z"), AccessExpiresAt: TimeMustParse("2050-01-01T15:22:08Z"), }, + "local_account_3": { + ID: "01JPCMGR09M8VGARPSBABXNZFQ", + ClientID: "01F8MGV8AC3NGSJW0FE8W1BV70", + UserID: "01JPCMFRTQ0B6R8SXPM7RS80Q4", + RedirectURI: "http://localhost:8080", + Scope: "read write push", + Access: "01JPCMK0YQ24FFVZ98PYZGJCC901JPCMK32ZKZMM737HGSWMW", + AccessCreateAt: TimeMustParse("2025-03-15T11:08:00Z"), + }, "admin_account": { ID: "01FS4TP8ANA5VE92EAPA9E0M7Q", ClientID: "01F8MGWSJCND9BWBD4WGJXBM93", @@ -249,6 +258,29 @@ func NewTestUsers() map[string]*gtsmodel.User { ResetPasswordToken: "", ResetPasswordSentAt: time.Time{}, }, + "local_account_3": { + ID: "01JPCMFRTQ0B6R8SXPM7RS80Q4", + Email: "media.mogul@example.org", + AccountID: "01JPCMD83Y4WR901094YES3QC5", + EncryptedPassword: "$2y$10$ggWz5QWwnx6kzb9g0tnIJurFtE0dhr5Zfeaqs9iFuUIXzafQlJVZS", // 'password' + CreatedAt: TimeMustParse("2025-03-15T11:08:00Z"), + SignUpIP: nil, + UpdatedAt: TimeMustParse("2025-03-15T11:08:00Z"), + InviteID: "", + Locale: "en", + CreatedByApplicationID: "01HT5P2YHDMPAAD500NDAY8JW1", + LastEmailedAt: TimeMustParse("2025-03-15T11:08:00Z"), + ConfirmationToken: "", + ConfirmedAt: TimeMustParse("2025-03-15T11:08:00Z"), + ConfirmationSentAt: TimeMustParse("2025-03-15T11:08:00Z"), + UnconfirmedEmail: "", + Moderator: util.Ptr(false), + Admin: util.Ptr(false), + Disabled: util.Ptr(false), + Approved: util.Ptr(true), + ResetPasswordToken: "", + ResetPasswordSentAt: time.Time{}, + }, } return users @@ -446,6 +478,59 @@ func NewTestAccounts() map[string]*gtsmodel.Account { SuspensionOrigin: "", Settings: settings["local_account_2"], }, + "local_account_3": { + ID: "01JPCMD83Y4WR901094YES3QC5", + Username: "media_mogul", + AvatarMediaAttachmentID: "01JPHQZ0ZHC2AXJK1JQNXRXQZN", + HeaderMediaAttachmentID: "01JPHRB7F2RXPTEQFRYC85EPD9", + DisplayName: "", + Fields: []*gtsmodel.Field{ + { + Name: "I'm going to post a lot of", + Value: "media!", + }, + { + Name: "and there's nothing", + Value: "you can do about it", + }, + }, + FieldsRaw: []*gtsmodel.Field{ + { + Name: "I'm going to post a lot of", + Value: "media!", + }, + { + Name: "and there's nothing", + Value: "you can do about it", + }, + }, + Note: "

I'm a test account that posts a shitload of media and I have my account rendered in \"gallery\" mode

", + NoteRaw: "I'm a test account that posts a shitload of media and I have my account rendered in \"gallery\" mode", + Memorial: util.Ptr(false), + MovedToURI: "", + CreatedAt: TimeMustParse("2025-03-15T11:08:00Z"), + UpdatedAt: TimeMustParse("2025-03-15T11:08:00Z"), + Bot: util.Ptr(false), + Locked: util.Ptr(false), + Discoverable: util.Ptr(false), + URI: "http://localhost:8080/users/media_mogul", + URL: "http://localhost:8080/@media_mogul", + FetchedAt: time.Time{}, + InboxURI: "http://localhost:8080/users/media_mogul/inbox", + OutboxURI: "http://localhost:8080/users/media_mogul/outbox", + FollowersURI: "http://localhost:8080/users/media_mogul/followers", + FollowingURI: "http://localhost:8080/users/media_mogul/following", + FeaturedCollectionURI: "http://localhost:8080/users/media_mogul/collections/featured", + ActorType: ap.ActorPerson, + PrivateKey: &rsa.PrivateKey{}, + PublicKey: &rsa.PublicKey{}, + PublicKeyURI: "http://localhost:8080/users/media_mogul#main-key", + SensitizedAt: time.Time{}, + SilencedAt: time.Time{}, + SuspendedAt: time.Time{}, + SuspensionOrigin: "", + Settings: settings["local_account_3"], + }, "remote_account_1": { ID: "01F8MH5ZK5VRH73AKHQM6Y9VNX", Username: "foss_satan", @@ -596,6 +681,7 @@ func NewTestAccounts() map[string]*gtsmodel.Account { "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", } if diff := len(accountsSorted) - len(preserializedKeys); diff > 0 { @@ -640,6 +726,7 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings { EnableRSS: util.Ptr(false), HideCollections: util.Ptr(false), WebVisibility: gtsmodel.VisibilityPublic, + WebLayout: gtsmodel.WebLayoutMicroblog, }, "admin_account": { AccountID: "01F8MH17FWEB39HZJ76B6VXSKF", @@ -651,6 +738,7 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings { EnableRSS: util.Ptr(true), HideCollections: util.Ptr(false), WebVisibility: gtsmodel.VisibilityPublic, + WebLayout: gtsmodel.WebLayoutMicroblog, }, "local_account_1": { AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", @@ -662,6 +750,7 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings { EnableRSS: util.Ptr(true), HideCollections: util.Ptr(false), WebVisibility: gtsmodel.VisibilityUnlocked, + WebLayout: gtsmodel.WebLayoutMicroblog, }, "local_account_2": { AccountID: "01F8MH5NBDF2MV7CTC4Q5128HF", @@ -673,6 +762,19 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings { EnableRSS: util.Ptr(false), HideCollections: util.Ptr(true), WebVisibility: gtsmodel.VisibilityPublic, + WebLayout: gtsmodel.WebLayoutMicroblog, + }, + "local_account_3": { + AccountID: "01JPCMD83Y4WR901094YES3QC5", + CreatedAt: TimeMustParse("2025-03-15T11:08:00Z"), + UpdatedAt: TimeMustParse("2025-03-15T11:08:00Z"), + Privacy: gtsmodel.VisibilityPublic, + Sensitive: util.Ptr(true), + Language: "en", + EnableRSS: util.Ptr(true), + HideCollections: util.Ptr(false), + WebVisibility: gtsmodel.VisibilityUnlocked, + WebLayout: gtsmodel.WebLayoutGallery, }, } } @@ -1035,6 +1137,623 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { Header: util.Ptr(false), Cached: util.Ptr(false), }, + "local_account_3_avatar": { + ID: "01JPHQZ0ZHC2AXJK1JQNXRXQZN", + StatusID: "", // this attachment isn't connected to a status + URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/avatar/original/01JPHQZ0ZHC2AXJK1JQNXRXQZN.jpeg", + RemoteURL: "", + CreatedAt: TimeMustParse("2025-03-17T10:46:37+01:00"), + Type: gtsmodel.FileTypeImage, + FileMeta: gtsmodel.FileMeta{ + Original: gtsmodel.Original{ + Width: 1280, + Height: 720, + Size: 921600, + Aspect: 1.777778, + }, + Small: gtsmodel.Small{ + Width: 512, + Height: 288, + Size: 147456, + Aspect: 1.777778, + }, + Focus: gtsmodel.Focus{ + X: 0, + Y: 0, + }, + }, + AccountID: "01JPCMD83Y4WR901094YES3QC5", + Description: "DESCRIPTION_GOES_HERE", + ScheduledStatusID: "", + Blurhash: "LRF~2LIU0esp-qRjR*aeJ$s;iwW.", + Processing: 2, + File: gtsmodel.File{ + Path: "01JPCMD83Y4WR901094YES3QC5/avatar/original/01JPHQZ0ZHC2AXJK1JQNXRXQZN.jpeg", + ContentType: "image/jpeg", + FileSize: 291230, + }, + Thumbnail: gtsmodel.Thumbnail{ + Path: "01JPCMD83Y4WR901094YES3QC5/avatar/small/01JPHQZ0ZHC2AXJK1JQNXRXQZN.jpeg", + ContentType: "image/jpeg", + FileSize: 24486, + URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/avatar/small/01JPHQZ0ZHC2AXJK1JQNXRXQZN.jpeg", + RemoteURL: "", + }, + Avatar: util.Ptr(true), + Header: util.Ptr(false), + Cached: util.Ptr(true), + }, + "local_account_3_header": { + ID: "01JPHRB7F2RXPTEQFRYC85EPD9", + StatusID: "", // this attachment isn't connected to a status + URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/header/original/01JPHRB7F2RXPTEQFRYC85EPD9.png", + RemoteURL: "", + CreatedAt: TimeMustParse("2025-03-17T10:53:17+01:00"), + Type: gtsmodel.FileTypeImage, + FileMeta: gtsmodel.FileMeta{ + Original: gtsmodel.Original{ + Width: 725, + Height: 307, + Size: 222575, + Aspect: 2.361563, + }, + Small: gtsmodel.Small{ + Width: 512, + Height: 216, + Size: 110592, + Aspect: 2.361563, + }, + Focus: gtsmodel.Focus{ + X: 0, + Y: 0, + }, + }, + AccountID: "01JPCMD83Y4WR901094YES3QC5", + Description: "DESCRIPTION_GOES_HERE", + ScheduledStatusID: "", + Blurhash: "L9I5h:%M%M?a~os:D*bFMybFM{jI", + Processing: 2, + File: gtsmodel.File{ + Path: "01JPCMD83Y4WR901094YES3QC5/header/original/01JPHRB7F2RXPTEQFRYC85EPD9.png", + ContentType: "image/png", + FileSize: 405238, + }, + Thumbnail: gtsmodel.Thumbnail{ + Path: "01JPCMD83Y4WR901094YES3QC5/header/small/01JPHRB7F2RXPTEQFRYC85EPD9.webp", + ContentType: "image/webp", + FileSize: 26478, + URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/header/small/01JPHRB7F2RXPTEQFRYC85EPD9.webp", + RemoteURL: "", + }, + Avatar: util.Ptr(false), + Header: util.Ptr(true), + Cached: util.Ptr(true), + }, + // sickos + "local_account_3_status_1_attachment_1": { + ID: "01JPCPRMPPGWKBCAE7X81XA0PK", + StatusID: "01JPCNB4417JG3XHHP0WS60RM3", + URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPCPRMPPGWKBCAE7X81XA0PK.jpeg", + RemoteURL: "", + CreatedAt: TimeMustParse("2025-03-15T11:49:28+01:00"), + Type: gtsmodel.FileTypeImage, + FileMeta: gtsmodel.FileMeta{ + Original: gtsmodel.Original{ + Width: 1920, + Height: 1200, + Size: 2304000, + Aspect: 1.600000, + }, + Small: gtsmodel.Small{ + Width: 512, + Height: 320, + Size: 163840, + Aspect: 1.600000, + }, + Focus: gtsmodel.Focus{ + X: 0, + Y: 0, + }, + }, + AccountID: "01JPCMD83Y4WR901094YES3QC5", + Description: "DESCRIPTION_GOES_HERE", + ScheduledStatusID: "", + Blurhash: "L~EqXWX5t6og%jW=owa~N1WFjYWC", + Processing: 2, + File: gtsmodel.File{ + Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPCPRMPPGWKBCAE7X81XA0PK.jpeg", + ContentType: "image/jpeg", + FileSize: 513277, + }, + Thumbnail: gtsmodel.Thumbnail{ + Path: "01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPCPRMPPGWKBCAE7X81XA0PK.jpeg", + ContentType: "image/jpeg", + FileSize: 23550, + URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPCPRMPPGWKBCAE7X81XA0PK.jpeg", + RemoteURL: "", + }, + Avatar: util.Ptr(false), + Header: util.Ptr(false), + Cached: util.Ptr(true), + }, + // marge + "local_account_3_status_1_attachment_2": { + ID: "01JPCPTSFNQDAGTHP49DXSD0BM", + StatusID: "01JPCNB4417JG3XHHP0WS60RM3", + URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPCPTSFNQDAGTHP49DXSD0BM.png", + RemoteURL: "", + CreatedAt: TimeMustParse("2025-03-15T11:50:38+01:00"), + Type: gtsmodel.FileTypeImage, + FileMeta: gtsmodel.FileMeta{ + Original: gtsmodel.Original{ + Width: 976, + Height: 741, + Size: 723216, + Aspect: 1.317139, + }, + Small: gtsmodel.Small{ + Width: 512, + Height: 388, + Size: 198656, + Aspect: 1.317139, + }, + Focus: gtsmodel.Focus{ + X: 0, + Y: 0, + }, + }, + AccountID: "01JPCMD83Y4WR901094YES3QC5", + Description: "DESCRIPTION_GOES_HERE", + ScheduledStatusID: "", + Blurhash: "LGH1i6RpD;-,0DoZaIogA2N3xZI]", + Processing: 2, + File: gtsmodel.File{ + Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPCPTSFNQDAGTHP49DXSD0BM.png", + ContentType: "image/png", + FileSize: 380878, + }, + Thumbnail: gtsmodel.Thumbnail{ + Path: "01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPCPTSFNQDAGTHP49DXSD0BM.webp", + ContentType: "image/webp", + FileSize: 51882, + URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPCPTSFNQDAGTHP49DXSD0BM.webp", + RemoteURL: "", + }, + Avatar: util.Ptr(false), + Header: util.Ptr(false), + Cached: util.Ptr(true), + }, + // sloth-gear + "local_account_3_status_1_attachment_3": { + ID: "01JPCPYJ6N2E2R7GAJ1XECXNV5", + StatusID: "01JPCNB4417JG3XHHP0WS60RM3", + URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPCPYJ6N2E2R7GAJ1XECXNV5.webp", + RemoteURL: "", + CreatedAt: TimeMustParse("2025-03-15T11:52:42+01:00"), + Type: gtsmodel.FileTypeImage, + FileMeta: gtsmodel.FileMeta{ + Original: gtsmodel.Original{ + Width: 2830, + Height: 1472, + Size: 4165760, + Aspect: 1.922554, + }, + Small: gtsmodel.Small{ + Width: 512, + Height: 266, + Size: 136192, + Aspect: 1.922554, + }, + Focus: gtsmodel.Focus{ + X: 0, + Y: 0, + }, + }, + AccountID: "01JPCMD83Y4WR901094YES3QC5", + Description: "DESCRIPTION_GOES_HERE", + ScheduledStatusID: "", + Blurhash: "LOE.|bxZx]j[~pt7WWWW%Lj@%Mj[", + Processing: 2, + File: gtsmodel.File{ + Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPCPYJ6N2E2R7GAJ1XECXNV5.webp", + ContentType: "image/webp", + FileSize: 366592, + }, + Thumbnail: gtsmodel.Thumbnail{ + Path: "01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPCPYJ6N2E2R7GAJ1XECXNV5.jpeg", + ContentType: "image/jpeg", + FileSize: 15461, + URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPCPYJ6N2E2R7GAJ1XECXNV5.jpeg", + RemoteURL: "", + }, + Avatar: util.Ptr(false), + Header: util.Ptr(false), + Cached: util.Ptr(true), + }, + // you-posted + "local_account_3_status_1_attachment_4": { + ID: "01JPCQ4WXEA52VVR9V1HN7E0RS", + StatusID: "01JPCNB4417JG3XHHP0WS60RM3", + URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPCQ4WXEA52VVR9V1HN7E0RS.png", + RemoteURL: "", + CreatedAt: TimeMustParse("2025-03-15T11:56:09+01:00"), + Type: gtsmodel.FileTypeImage, + FileMeta: gtsmodel.FileMeta{ + Original: gtsmodel.Original{ + Width: 1920, + Height: 1080, + Size: 2073600, + Aspect: 1.777778, + }, + Small: gtsmodel.Small{ + Width: 512, + Height: 288, + Size: 147456, + Aspect: 1.777778, + }, + Focus: gtsmodel.Focus{ + X: 0, + Y: 0, + }, + }, + AccountID: "01JPCMD83Y4WR901094YES3QC5", + Description: "DESCRIPTION_GOES_HERE", + ScheduledStatusID: "", + Blurhash: "L00+zhoLNubHj[fQa|fQ9tWVw{jZ", + Processing: 2, + File: gtsmodel.File{ + Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPCQ4WXEA52VVR9V1HN7E0RS.png", + ContentType: "image/png", + FileSize: 80917, + }, + Thumbnail: gtsmodel.Thumbnail{ + Path: "01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPCQ4WXEA52VVR9V1HN7E0RS.webp", + ContentType: "image/webp", + FileSize: 5344, + URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPCQ4WXEA52VVR9V1HN7E0RS.webp", + RemoteURL: "", + }, + Avatar: util.Ptr(false), + Header: util.Ptr(false), + Cached: util.Ptr(true), + }, + // buscemi + "local_account_3_status_1_attachment_5": { + ID: "01JPCQ9VBZBMSTVN56QN3R5188", + StatusID: "01JPCNB4417JG3XHHP0WS60RM3", + URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPCQ9VBZBMSTVN56QN3R5188.jpeg", + RemoteURL: "", + CreatedAt: TimeMustParse("2025-03-15T11:58:51+01:00"), + Type: gtsmodel.FileTypeImage, + FileMeta: gtsmodel.FileMeta{ + Original: gtsmodel.Original{ + Width: 1077, + Height: 525, + Size: 565425, + Aspect: 2.051429, + }, + Small: gtsmodel.Small{ + Width: 512, + Height: 249, + Size: 127488, + Aspect: 2.051429, + }, + Focus: gtsmodel.Focus{ + X: 0, + Y: 0, + }, + }, + AccountID: "01JPCMD83Y4WR901094YES3QC5", + Description: "DESCRIPTION_GOES_HERE", + ScheduledStatusID: "", + Blurhash: "L5A9A=}?J*5m56Rk={$%O?Nb$M$i", + Processing: 2, + File: gtsmodel.File{ + Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPCQ9VBZBMSTVN56QN3R5188.jpeg", + ContentType: "image/jpeg", + FileSize: 42899, + }, + Thumbnail: gtsmodel.Thumbnail{ + Path: "01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPCQ9VBZBMSTVN56QN3R5188.jpeg", + ContentType: "image/jpeg", + FileSize: 17341, + URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPCQ9VBZBMSTVN56QN3R5188.jpeg", + RemoteURL: "", + }, + Avatar: util.Ptr(false), + Header: util.Ptr(false), + Cached: util.Ptr(true), + }, + // butt + "local_account_3_status_1_attachment_6": { + ID: "01JPG1RZPRH3Y00VSA3RQ2SJWP", + StatusID: "01JPCNB4417JG3XHHP0WS60RM3", + URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPG1RZPRH3Y00VSA3RQ2SJWP.gif", + RemoteURL: "", + CreatedAt: TimeMustParse("2025-03-16T18:59:36+01:00"), + Type: gtsmodel.FileTypeImage, + FileMeta: gtsmodel.FileMeta{ + Original: gtsmodel.Original{ + Width: 31, + Height: 25, + Size: 775, + Aspect: 1.240000, + }, + Small: gtsmodel.Small{ + Width: 31, + Height: 25, + Size: 775, + Aspect: 1.240000, + }, + Focus: gtsmodel.Focus{ + X: 0, + Y: 0, + }, + }, + AccountID: "01JPCMD83Y4WR901094YES3QC5", + Description: "DESCRIPTION_GOES_HERE", + ScheduledStatusID: "", + Blurhash: "LWLN.4~q00ofxuxu-;%M9F-;-;xu", + Processing: 2, + File: gtsmodel.File{ + Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPG1RZPRH3Y00VSA3RQ2SJWP.gif", + ContentType: "image/gif", + FileSize: 636, + }, + Thumbnail: gtsmodel.Thumbnail{ + Path: "01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPG1RZPRH3Y00VSA3RQ2SJWP.webp", + ContentType: "image/webp", + FileSize: 406, + URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPG1RZPRH3Y00VSA3RQ2SJWP.webp", + RemoteURL: "", + }, + Avatar: util.Ptr(false), + Header: util.Ptr(false), + Cached: util.Ptr(true), + }, + // bunny + "local_account_3_status_2_attachment_1": { + ID: "01JPHFKQ86GT9W76SWPHE9P8JB", + StatusID: "01JPCNJAPHJKJC4EXWA6N9BXDD", + URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPHFKQ86GT9W76SWPHE9P8JB.webm", + RemoteURL: "", + CreatedAt: TimeMustParse("2025-03-17T08:20:38+01:00"), + Type: gtsmodel.FileTypeVideo, + FileMeta: gtsmodel.FileMeta{ + Original: gtsmodel.Original{ + Width: 640, + Height: 360, + Size: 230400, + Aspect: 1.777778, + Duration: util.Ptr[float32](32.480000), + Bitrate: util.Ptr[uint64](533294), + }, + Small: gtsmodel.Small{ + Width: 512, + Height: 288, + Size: 147456, + Aspect: 1.777778, + }, + Focus: gtsmodel.Focus{ + X: 0, + Y: 0, + }, + }, + AccountID: "01JPCMD83Y4WR901094YES3QC5", + Description: "DESCRIPTION_GOES_HERE", + ScheduledStatusID: "", + Blurhash: "LEQcn{?bfQ?b~qoffQoffQfQfQfQ", + Processing: 2, + File: gtsmodel.File{ + Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPHFKQ86GT9W76SWPHE9P8JB.webm", + ContentType: "video/webm", + FileSize: 2165608, + }, + Thumbnail: gtsmodel.Thumbnail{ + Path: "01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPHFKQ86GT9W76SWPHE9P8JB.webp", + ContentType: "image/webp", + FileSize: 324, + URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPHFKQ86GT9W76SWPHE9P8JB.webp", + RemoteURL: "", + }, + Avatar: util.Ptr(false), + Header: util.Ptr(false), + Cached: util.Ptr(true), + }, + // computerbye + "local_account_3_status_2_attachment_2": { + ID: "01JPHFSCVGGH02FX9VJMXGXN45", + StatusID: "01JPCNJAPHJKJC4EXWA6N9BXDD", + URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPHFSCVGGH02FX9VJMXGXN45.gif", + RemoteURL: "", + CreatedAt: TimeMustParse("2025-03-17T08:23:44+01:00"), + Type: gtsmodel.FileTypeImage, + FileMeta: gtsmodel.FileMeta{ + Original: gtsmodel.Original{ + Width: 442, + Height: 332, + Size: 146744, + Aspect: 1.331325, + Duration: util.Ptr[float32](3.750000), + Framerate: util.Ptr[float32](20.000000), + Bitrate: util.Ptr[uint64](4078150), + }, + Small: gtsmodel.Small{ + Width: 442, + Height: 332, + Size: 146744, + Aspect: 1.331325, + }, + Focus: gtsmodel.Focus{ + X: 0, + Y: 0, + }, + }, + AccountID: "01JPCMD83Y4WR901094YES3QC5", + Description: "DESCRIPTION_GOES_HERE", + ScheduledStatusID: "", + Blurhash: "LLHUzr-;o#_2~q-:IV%Mxu%MM{M{", + Processing: 2, + File: gtsmodel.File{ + Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPHFSCVGGH02FX9VJMXGXN45.gif", + ContentType: "image/gif", + FileSize: 1911633, + }, + Thumbnail: gtsmodel.Thumbnail{ + Path: "01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPHFSCVGGH02FX9VJMXGXN45.webp", + ContentType: "image/webp", + FileSize: 10056, + URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPHFSCVGGH02FX9VJMXGXN45.webp", + RemoteURL: "", + }, + Avatar: util.Ptr(false), + Header: util.Ptr(false), + Cached: util.Ptr(true), + }, + // diarrhea + "local_account_3_status_2_attachment_3": { + ID: "01JPHFW5HKFWQNQ954P5KNXWSR", + StatusID: "01JPCNJAPHJKJC4EXWA6N9BXDD", + URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPHFW5HKFWQNQ954P5KNXWSR.gif", + RemoteURL: "", + CreatedAt: TimeMustParse("2025-03-17T08:25:15+01:00"), + Type: gtsmodel.FileTypeImage, + FileMeta: gtsmodel.FileMeta{ + Original: gtsmodel.Original{ + Width: 320, + Height: 214, + Size: 68480, + Aspect: 1.495327, + Duration: util.Ptr[float32](3.100000), + Framerate: util.Ptr[float32](10.000000), + Bitrate: util.Ptr[uint64](2011086), + }, + Small: gtsmodel.Small{ + Width: 320, + Height: 214, + Size: 68480, + Aspect: 1.495327, + }, + Focus: gtsmodel.Focus{ + X: 0, + Y: 0, + }, + }, + AccountID: "01JPCMD83Y4WR901094YES3QC5", + Description: "DESCRIPTION_GOES_HERE", + ScheduledStatusID: "", + Blurhash: "L78qTmNG00xZkWxsIURQ01s;?aR*", + Processing: 2, + File: gtsmodel.File{ + Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPHFW5HKFWQNQ954P5KNXWSR.gif", + ContentType: "image/gif", + FileSize: 779296, + }, + Thumbnail: gtsmodel.Thumbnail{ + Path: "01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPHFW5HKFWQNQ954P5KNXWSR.webp", + ContentType: "image/webp", + FileSize: 10238, + URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPHFW5HKFWQNQ954P5KNXWSR.webp", + RemoteURL: "", + }, + Avatar: util.Ptr(false), + Header: util.Ptr(false), + Cached: util.Ptr(true), + }, + // ffmpreg + "local_account_3_status_2_attachment_4": { + ID: "01JPHFZP2VNS1M2RQ646BXBZQG", + StatusID: "01JPCNJAPHJKJC4EXWA6N9BXDD", + URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPHFZP2VNS1M2RQ646BXBZQG.jpeg", + RemoteURL: "", + CreatedAt: TimeMustParse("2025-03-17T08:27:10+01:00"), + Type: gtsmodel.FileTypeImage, + FileMeta: gtsmodel.FileMeta{ + Original: gtsmodel.Original{ + Width: 1280, + Height: 720, + Size: 921600, + Aspect: 1.777778, + }, + Small: gtsmodel.Small{ + Width: 512, + Height: 288, + Size: 147456, + Aspect: 1.777778, + }, + Focus: gtsmodel.Focus{ + X: 0, + Y: 0, + }, + }, + AccountID: "01JPCMD83Y4WR901094YES3QC5", + Description: "DESCRIPTION_GOES_HERE", + ScheduledStatusID: "", + Blurhash: "LOCX.y}rIpE3,?w{S4W;9vENX8t6", + Processing: 2, + File: gtsmodel.File{ + Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPHFZP2VNS1M2RQ646BXBZQG.jpeg", + ContentType: "image/jpeg", + FileSize: 137328, + }, + Thumbnail: gtsmodel.Thumbnail{ + Path: "01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPHFZP2VNS1M2RQ646BXBZQG.jpeg", + ContentType: "image/jpeg", + FileSize: 19775, + URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPHFZP2VNS1M2RQ646BXBZQG.jpeg", + RemoteURL: "", + }, + Avatar: util.Ptr(false), + Header: util.Ptr(false), + Cached: util.Ptr(true), + }, + // notabug + "local_account_3_status_2_attachment_5": { + ID: "01JPHG32F7M6F084WKEGAYJ40X", + StatusID: "01JPCNJAPHJKJC4EXWA6N9BXDD", + URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPHG32F7M6F084WKEGAYJ40X.jpeg", + RemoteURL: "", + CreatedAt: TimeMustParse("2025-03-17T08:29:01+01:00"), + Type: gtsmodel.FileTypeImage, + FileMeta: gtsmodel.FileMeta{ + Original: gtsmodel.Original{ + Width: 500, + Height: 739, + Size: 369500, + Aspect: 0.676590, + }, + Small: gtsmodel.Small{ + Width: 346, + Height: 512, + Size: 177152, + Aspect: 0.676590, + }, + Focus: gtsmodel.Focus{ + X: 0, + Y: 0, + }, + }, + AccountID: "01JPCMD83Y4WR901094YES3QC5", + Description: "DESCRIPTION_GOES_HERE", + ScheduledStatusID: "", + Blurhash: "LTGbrRxAE1og0OR:xve-OFs6kCWY", + Processing: 2, + File: gtsmodel.File{ + Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPHG32F7M6F084WKEGAYJ40X.jpeg", + ContentType: "image/jpeg", + FileSize: 106636, + }, + Thumbnail: gtsmodel.Thumbnail{ + Path: "01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPHG32F7M6F084WKEGAYJ40X.jpeg", + ContentType: "image/jpeg", + FileSize: 27483, + URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPHG32F7M6F084WKEGAYJ40X.jpeg", + RemoteURL: "", + }, + Avatar: util.Ptr(false), + Header: util.Ptr(false), + Cached: util.Ptr(true), + }, "remote_account_1_status_1_attachment_1": { ID: "01FVW7RXPQ8YJHTEXYPE7Q8ZY0", StatusID: "01FVW7JHQFSFK166WWKR8CBA6M", @@ -1372,6 +2091,58 @@ func newTestStoredAttachments() map[string]filenames { Original: "ghosts-original.mp3", Small: "ghosts-small.webp", }, + "local_account_3_status_1_attachment_1": { + Original: "sickos-original.jpeg", + Small: "sickos-small.jpeg", + }, + "local_account_3_status_1_attachment_2": { + Original: "marge-original.png", + Small: "marge-small.webp", + }, + "local_account_3_status_1_attachment_3": { + Original: "sloth-gear-original.webp", + Small: "sloth-gear-small.jpeg", + }, + "local_account_3_status_1_attachment_4": { + Original: "you-posted-original.webp", + Small: "you-posted-small.webp", + }, + "local_account_3_status_1_attachment_5": { + Original: "buscemi-original.jpeg", + Small: "buscemi-small.jpeg", + }, + "local_account_3_avatar": { + Original: "dollar-original.jpeg", + Small: "dollar-small.jpeg", + }, + "local_account_3_header": { + Original: "dollar2-original.png", + Small: "dollar2-small.webp", + }, + "local_account_3_status_1_attachment_6": { + Original: "butt-original.gif", + Small: "butt-small.webp", + }, + "local_account_3_status_2_attachment_1": { + Original: "bunny-original.webm", + Small: "bunny-small.webp", + }, + "local_account_3_status_2_attachment_2": { + Original: "computerbye-original.gif", + Small: "computerbye-small.webp", + }, + "local_account_3_status_2_attachment_3": { + Original: "diarrhea-original.gif", + Small: "diarrhea-small.webp", + }, + "local_account_3_status_2_attachment_4": { + Original: "ffmpreg-original.jpeg", + Small: "ffmpreg-small.jpeg", + }, + "local_account_3_status_2_attachment_5": { + Original: "notabug-original.jpeg", + Small: "notabug-small.jpeg", + }, "remote_account_1_status_1_attachment_1": { Original: "thoughtsofdog-original.jpg", Small: "thoughtsofdog-small.jpeg", @@ -1941,6 +2712,54 @@ func NewTestStatuses() map[string]*gtsmodel.Status { Federated: util.Ptr(true), ActivityStreamsType: ap.ObjectNote, }, + "local_account_3_status_1": { + ID: "01JPCNB4417JG3XHHP0WS60RM3", + URI: "http://localhost:8080/users/media_mogul/statuses/01JPCNB4417JG3XHHP0WS60RM3", + URL: "http://localhost:8080/@media_mogul/statuses/01JPCNB4417JG3XHHP0WS60RM3", + AttachmentIDs: []string{ + "01JPCPRMPPGWKBCAE7X81XA0PK", + "01JPCPTSFNQDAGTHP49DXSD0BM", + "01JPCPYJ6N2E2R7GAJ1XECXNV5", + "01JPCQ4WXEA52VVR9V1HN7E0RS", + "01JPCQ9VBZBMSTVN56QN3R5188", + "01JPG1RZPRH3Y00VSA3RQ2SJWP", + }, + ContentType: gtsmodel.StatusContentTypePlain, + CreatedAt: TimeMustParse("2025-03-15T11:26:17Z"), + Local: util.Ptr(true), + AccountURI: "http://localhost:8080/users/media_mogul", + AccountID: "01JPCMD83Y4WR901094YES3QC5", + Visibility: gtsmodel.VisibilityUnlocked, + Sensitive: util.Ptr(false), + Language: "en", + CreatedWithApplicationID: "01F8MGY43H3N2C8EWPR2FPYEXG", + Federated: util.Ptr(true), + ActivityStreamsType: ap.ObjectNote, + PinnedAt: TimeMustParse("2025-03-15T11:27:00Z"), + }, + "local_account_3_status_2": { + ID: "01JPCNJAPHJKJC4EXWA6N9BXDD", + URI: "http://localhost:8080/users/media_mogul/statuses/01JPCNJAPHJKJC4EXWA6N9BXDD", + URL: "http://localhost:8080/@media_mogul/statuses/01JPCNJAPHJKJC4EXWA6N9BXDD", + AttachmentIDs: []string{ + "01JPHFKQ86GT9W76SWPHE9P8JB", + "01JPHFSCVGGH02FX9VJMXGXN45", + "01JPHFW5HKFWQNQ954P5KNXWSR", + "01JPHFZP2VNS1M2RQ646BXBZQG", + "01JPHG32F7M6F084WKEGAYJ40X", + }, + ContentType: gtsmodel.StatusContentTypePlain, + CreatedAt: TimeMustParse("2025-03-15T11:28:42Z"), + Local: util.Ptr(true), + AccountURI: "http://localhost:8080/users/media_mogul", + AccountID: "01JPCMD83Y4WR901094YES3QC5", + Visibility: gtsmodel.VisibilityUnlocked, + Sensitive: util.Ptr(false), + Language: "en", + CreatedWithApplicationID: "01F8MGY43H3N2C8EWPR2FPYEXG", + Federated: util.Ptr(true), + ActivityStreamsType: ap.ObjectNote, + }, "remote_account_1_status_1": { ID: "01FVW7JHQFSFK166WWKR8CBA6M", URI: "http://fossbros-anonymous.io/users/foss_satan/statuses/01FVW7JHQFSFK166WWKR8CBA6M", diff --git a/web/assets/themes/brutalist-dark.css b/web/assets/themes/brutalist-dark.css index 4cf8cd655..9be12ba92 100644 --- a/web/assets/themes/brutalist-dark.css +++ b/web/assets/themes/brutalist-dark.css @@ -135,12 +135,12 @@ html, body { } /* Make show more/less buttons more legible */ -.status button, .status .button { +.button { background-color: var(--almost-white); color: var(--almost-black); border: var(--dashed-border); } -.status button:hover, .status .button:hover { +.button:hover { background-color: var(--almost-black); color: var(--almost-white); border: var(--dashed-border); diff --git a/web/assets/themes/brutalist.css b/web/assets/themes/brutalist.css index e29509c21..e183ee50c 100644 --- a/web/assets/themes/brutalist.css +++ b/web/assets/themes/brutalist.css @@ -130,12 +130,12 @@ html, body { } /* Make show more/less buttons more legible */ -.status button, .status .button { +.button { background-color: var(--almost-black); color: var(--almost-white); border: var(--dashed-border); } -.status button:hover, .status .button:hover { +.button:hover { background-color: var(--almost-white); color: var(--almost-black); border: var(--dashed-border); diff --git a/web/source/css/_media-wrapper.css b/web/source/css/_media-wrapper.css new file mode 100644 index 000000000..1c2ae1503 --- /dev/null +++ b/web/source/css/_media-wrapper.css @@ -0,0 +1,207 @@ +/* + 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 . +*/ + +@import "photoswipe/dist/photoswipe.css"; +@import "photoswipe-dynamic-caption-plugin/photoswipe-dynamic-caption-plugin.css"; +@import "plyr/dist/plyr.css"; + +.media-wrapper { + height: 100%; + width: 100%; + box-sizing: border-box; + border: 0.15rem solid $gray1; + border-radius: $br; + position: relative; + overflow: hidden; + z-index: 2; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + details { + position: absolute; + height: 100%; + width: 100%; + + &[open] summary { + height: auto; + width: auto; + margin: 1rem; + padding: 0; + + .show, video, img { + display: none; + } + + .eye.button .hide { + display: inline-block; + grid-column: 1 / span 3; + grid-row: 1 / span 2; + } + } + + summary { + position: absolute; + height: 100%; + 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: + "eye sensitive ." + ". sensitive ."; + + &::-webkit-details-marker { + display: none; /* Safari */ + } + + .eye.button { + grid-area: eye; + align-self: start; + justify-self: start; + margin: 0; + padding: 0.4rem; + + .fa-fw { + line-height: $fa-fw; + } + + .hide { + display: none; + } + } + + .show.sensitive { + grid-area: sensitive; + align-self: center; + + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + + .button { + cursor: pointer; + align-self: center; + } + } + + video, img { + z-index: -1; + position: absolute; + height: calc(100% + 1.2rem); + width: calc(100% + 1.2rem); + top: -0.6rem; + left: -0.6rem; + filter: blur(1.2rem); + } + } + + video.plyr-video, .plyr { + position: absolute; + height: 100%; + width: 100%; + object-fit: contain; + background: $gray1; + } + + .unknown-attachment { + .placeholder { + width: 100%; + height: 100%; + padding: 0.8rem; + border: 0.2rem dashed $white2; + + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + + color: $white2; + + .placeholder-external-link { + align-self: end; + font-size: 2.5rem; + } + + .placeholder-icon { + width: 100%; + font-size: 3.5rem; + text-align: center; + margin-top: auto; + } + + .placeholder-link-to { + width: 100%; + text-align: center; + margin-bottom: auto; + } + } + } + } +} + +.pswp__button--open-post-link { + display: flex; + align-items: center; + justify-content: center; + + span > i { + background: $status-bg; + color: $fg; + border-radius: 25%; + } +} + +.plyr--video { + flex-direction: column-reverse; + + .plyr__video-wrapper { + position: relative; + } + + .plyr__controls { + align-self: stretch; + position: initial; + padding: 0.1rem; + padding-top: 0.2rem; + } + + .plyr__control { + box-shadow: none; + } + + .plyr__control--overlaid { + top: calc(50% - 18px); + } +} + +.pswp__content { + padding: 2rem; + + .plyr { + max-height: 100%; + } +} diff --git a/web/source/css/prism.css b/web/source/css/_prism.css similarity index 100% rename from web/source/css/prism.css rename to web/source/css/_prism.css diff --git a/web/source/css/_profile-header.css b/web/source/css/_profile-header.css new file mode 100644 index 000000000..b4ebadf8d --- /dev/null +++ b/web/source/css/_profile-header.css @@ -0,0 +1,343 @@ +/* + 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 . +*/ + +.profile .profile-header { + background: $profile-bg; + border-radius: $br; + overflow: hidden; + margin-bottom: 1rem; + + .moved-to { + padding: 1rem; + text-align: center; + } + + .header-image-wrapper { + position: relative; + padding-top: 33.33%; /* aspect-ratio 1/3 */ + + img { + position: absolute; + top: 0; + left: 0; + right: 0; + width: 100%; + height: 100%; + object-fit: cover; + } + } + + /* + Basic info container has the user's avatar, display- and username, and role + It's partially overlapped over the header image, by a negative margin-top. + */ + $avatar-size: 8.5rem; + $name-size: 3rem; + $username-size: 2rem; + $overlap: calc($avatar-size - $name-size - $username-size); + + .basic-info { + position: relative; + display: grid; + box-sizing: border-box; + grid-template-columns: $avatar-size auto 1fr; + grid-template-rows: $overlap $name-size auto; + grid-template-areas: + "avatar . ." + "avatar namerole namerole" + "avatar namerole namerole"; + + margin: 1rem; + margin-top: calc(-1 * $overlap); + gap: 0 1rem; + + .avatar-image-wrapper { + grid-area: avatar; + + border: 0.2rem solid $avatar-border; + border-radius: $br; + + /* + Wrapper always same + size + proportions no + matter image inside. + */ + height: $avatar-size; + width: $avatar-size; + + .avatar { + /* + Fit 100% of the wrapper. + */ + height: 100%; + width: 100%; + + /* + Normalize non-square images. + */ + object-fit: cover; + + /* + Prevent image extending + beyond rounded borders. + */ + border-radius: $br-inner; + } + } + + .namerole { + grid-area: namerole; + + display: grid; + gap: 0 1rem; + box-sizing: border-box; + grid-template-columns: 1fr auto; + grid-template-rows: $name-size auto; + grid-template-areas: + "displayname displayname" + "username role"; + + .displayname { + grid-area: displayname; + line-height: $name-size; + font-size: 1.5rem; + font-weight: bold; + } + + .bot-username-wrapper { + display: flex; + gap: 0.5rem; + grid-area: username; + align-items: center; + + .bot-legend-wrapper { + display: flex; + gap: 0.25rem; + align-items: center; + + background: $bg; + color: $fg; + + border-radius: $br; + padding: 0.1rem 0.4rem 0.2rem 0.4rem; + + font-variant: small-caps; + font-weight: bold; + + cursor: default; + + .bot-icon { + /* + FA icon is weirdly + aligned so tweak it + */ + margin-top: 0.25rem; + } + } + + .username { + min-width: 0; + line-height: $username-size; + + font-size: 1rem; + font-weight: bold; + color: $fg-accent; + user-select: all; + } + } + + .role { + background: $bg; + color: $fg; + border: 0.13rem solid $bg; + + grid-area: role; + align-self: center; + justify-self: start; + border-radius: $br; + padding: 0.3rem; + + line-height: 1.1rem; + font-size: 0.9rem; + font-variant: small-caps; + font-weight: bold; + + &.admin { + color: $role-admin; + border-color: $role-admin; + } + + &.moderator { + color: $role-mod; + border-color: $role-mod; + } + } + } + } +} + +.profile .about-user { + flex: 35 14rem; + border-radius: $br; + overflow: hidden; + + .col-header { + margin-bottom: -0.25rem; + } + + dt { + font-weight: bold; + } + + .fields { + background: $profile-bg; + display: flex; + flex-direction: column; + padding: 0 0.5rem; + padding-top: 0.25rem; + + .field { + padding: 0.25rem; + display: flex; + flex-direction: column; + border-bottom: 0.1rem solid $gray2; + + > dt, > dd { + word-break: break-word; + } + + &:first-child { + border-top: 0.1rem solid $gray2; + } + } + } + + .bio { + background: $profile-bg; + padding: 1rem 0.75rem; + padding-bottom: 1.25rem; + } + + .accountstats { + background: $bg-accent; + padding: 0.75rem; + + display: flex; + flex-direction: column; + gap: 0.25rem; + + .stats-item { + display: flex; + dt { + width: 7rem; + } + } + } +} + +/* + RSS icon isn't really part of the profile header exactly, + but also it sort of is, and we want it styled the same for + both microblog and gallery view anyway, so include it here. +*/ +.rss-icon { + display: block; + margin: -0.25rem 0; + + .fa { + font-size: 2rem; + object-fit: contain; + vertical-align: middle; + color: $orange2; + /* + Can't size a single-color background, so we use + a linear-gradient that's effectively white. + */ + background: linear-gradient(to right, $white1 100%, transparent 0) no-repeat center center; + background-size: 1.2rem 1.4rem; + /* light mode */ + @media (prefers-color-scheme: light) { + background: linear-gradient(to right, $white 100%, transparent 0) no-repeat center center; + background-size: 1.2rem 1.4rem; + } + } +} + +/* + Tablet-ish-kinda size. +*/ +@media screen and (max-width: 750px) { + .profile .profile-header { + .basic-info { + grid-template-columns: auto 1fr; + grid-template-rows: $avatar-size $name-size auto; + grid-template-areas: + "avatar avatar" + "namerole namerole" + "namerole namerole"; + + /* + Make display name a bit smaller + so there's more chance of being + able to read everything. + */ + .namerole { + .displayname { + font-size: 1.2rem; + line-height: 2rem; + margin-top: 0.5rem; + } + } + } + } +} + +/* + Phone-ish-kinda size. +*/ +@media screen and (max-width: 500px) { + .profile + .profile-header + .basic-info + .namerole { + /* + Line up in smallest possible + horizontal space to avoid overflow. + */ + display: flex; + flex-direction: column; + gap: 0.5rem; + + /* + Don't hug the right anymore + (good life advice in general). + */ + .role { + align-self: flex-start; + } + + /* + Allow this to wrap in case + of a really skinny screen. + */ + .bot-username-wrapper { + flex-wrap: wrap; + } + } +} diff --git a/web/source/css/_status-media.css b/web/source/css/_status-media.css new file mode 100644 index 000000000..e8386c87a --- /dev/null +++ b/web/source/css/_status-media.css @@ -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 . +*/ + +@import "./_media-wrapper.css"; + +.media { + grid-column: span 3; + display: grid; + grid-template-columns: 50% 50%; + grid-auto-rows: 10rem; + overflow: hidden; + + &.single .media-wrapper { + grid-column: span 2; + } + + &.odd .media-wrapper:first-child, + &.double .media-wrapper { + grid-row: span 2; + } + + @media screen and (max-width: 42rem) { + .media-wrapper { + grid-column: span 2; + grid-row: span 2; + } + } +} diff --git a/web/source/css/base.css b/web/source/css/base.css index 5f2f75802..765453ac2 100644 --- a/web/source/css/base.css +++ b/web/source/css/base.css @@ -22,7 +22,7 @@ ****************************************/ @import "modern-normalize/modern-normalize.css"; -@import "./prism.css"; +@import "./_prism.css"; /* noto-sans-regular - latin */ @font-face { diff --git a/web/source/css/profile-gallery.css b/web/source/css/profile-gallery.css new file mode 100644 index 000000000..cb70eff22 --- /dev/null +++ b/web/source/css/profile-gallery.css @@ -0,0 +1,108 @@ +/* + 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 . +*/ + +@import "./_profile-header.css"; +@import "./_media-wrapper.css"; + +.page { + /* + Profile gallery can be wider than default. + */ + grid-template-columns: 1fr min(95%, 65rem) 1fr; +} + +.profile { + .about-user { + margin-bottom: 1rem; + + .accountstats { + flex-direction: row; + justify-content: space-between; + + .stats-item { + gap: 0.5rem; + width: 25%; + justify-content: space-around; + + dt { + width: fit-content; + margin-left: auto; + } + + dd { + margin-right: auto; + } + } + + @media screen and (max-width: 750px) { + flex-direction: column; + .stats-item { + width: fit-content; + dt { + width: 7rem; + } + } + } + } + } + + .media-galleries-wrapper { + display: flex; + flex-direction: column; + gap: 1rem; + min-width: 0%; + + .media-gallery { + margin-top: 0.15rem; + margin-bottom: 0.15rem; + + display: grid; + gap: 0.15rem; + + /* Desktop-ish width, show 3 cols of media */ + grid-template-columns: repeat(3, 1fr); + + @media screen and (max-width: 55rem) { + /* Tablet-ish width, switch to 2 cols */ + grid-template-columns: repeat(2, 1fr); + } + + @media screen and (max-width: 36rem) { + /* Mobile-ish width, switch to 1 col */ + grid-template-columns: repeat(1, 1fr); + } + + .media-wrapper { + aspect-ratio: 4/3; + border: 0; + border-radius: 0; + background: $bg; + } + } + + .backnextlinks { + display: flex; + justify-content: space-between; + + .next { + margin-left: auto; + } + } + } +} diff --git a/web/source/css/profile.css b/web/source/css/profile.css index 1296b8927..5b9f2188e 100644 --- a/web/source/css/profile.css +++ b/web/source/css/profile.css @@ -17,13 +17,14 @@ along with this program. If not, see . */ +@import "./_profile-header.css"; + .page { /* Profile page can be a little wider than default page, since we're using a side-by-side column view. */ - grid-template-columns: 1fr minmax(auto, 60rem) 1fr; - grid-template-columns: 1fr min(92%, 65rem) 1fr; + grid-template-columns: 1fr min(95%, 65rem) 1fr; } .profile .column-split { @@ -32,244 +33,6 @@ gap: 1rem; } -.profile .profile-header { - background: $profile-bg; - border-radius: $br; - overflow: hidden; - margin-bottom: 1rem; - - .moved-to { - padding: 1rem; - text-align: center; - } - - .header-image-wrapper { - position: relative; - padding-top: 33.33%; /* aspect-ratio 1/3 */ - - img { - position: absolute; - top: 0; - left: 0; - right: 0; - width: 100%; - height: 100%; - object-fit: cover; - } - } - - /* - Basic info container has the user's avatar, display- and username, and role - It's partially overlapped over the header image, by a negative margin-top. - */ - $avatar-size: 8.5rem; - $name-size: 3rem; - $username-size: 2rem; - $overlap: calc($avatar-size - $name-size - $username-size); - - .basic-info { - position: relative; - display: grid; - box-sizing: border-box; - grid-template-columns: $avatar-size auto 1fr; - grid-template-rows: $overlap $name-size auto; - grid-template-areas: - "avatar . ." - "avatar namerole namerole" - "avatar namerole namerole"; - - margin: 1rem; - margin-top: calc(-1 * $overlap); - gap: 0 1rem; - - .avatar-image-wrapper { - grid-area: avatar; - - border: 0.2rem solid $avatar-border; - border-radius: $br; - - /* - Wrapper always same - size + proportions no - matter image inside. - */ - height: $avatar-size; - width: $avatar-size; - - .avatar { - /* - Fit 100% of the wrapper. - */ - height: 100%; - width: 100%; - - /* - Normalize non-square images. - */ - object-fit: cover; - - /* - Prevent image extending - beyond rounded borders. - */ - border-radius: $br-inner; - } - } - - .namerole { - grid-area: namerole; - - display: grid; - gap: 0 1rem; - box-sizing: border-box; - grid-template-columns: 1fr auto; - grid-template-rows: $name-size auto; - grid-template-areas: - "displayname displayname" - "username role"; - - .displayname { - grid-area: displayname; - line-height: $name-size; - font-size: 1.5rem; - font-weight: bold; - } - - .bot-username-wrapper { - display: flex; - gap: 0.5rem; - grid-area: username; - align-items: center; - - .bot-legend-wrapper { - display: flex; - gap: 0.25rem; - align-items: center; - - background: $bg; - color: $fg; - - border-radius: $br; - padding: 0.1rem 0.4rem 0.2rem 0.4rem; - - font-variant: small-caps; - font-weight: bold; - - cursor: default; - - .bot-icon { - /* - FA icon is weirdly - aligned so tweak it - */ - margin-top: 0.25rem; - } - } - - .username { - min-width: 0; - line-height: $username-size; - - font-size: 1rem; - font-weight: bold; - color: $fg-accent; - user-select: all; - } - } - - .role { - background: $bg; - color: $fg; - border: 0.13rem solid $bg; - - grid-area: role; - align-self: center; - justify-self: start; - border-radius: $br; - padding: 0.3rem; - - line-height: 1.1rem; - font-size: 0.9rem; - font-variant: small-caps; - font-weight: bold; - - &.admin { - color: $role-admin; - border-color: $role-admin; - } - - &.moderator { - color: $role-mod; - border-color: $role-mod; - } - } - } - } -} - -/* - Tablet-ish-kinda size. -*/ -@media screen and (max-width: 750px) { - .profile .profile-header { - .basic-info { - grid-template-columns: auto 1fr; - grid-template-rows: $avatar-size $name-size auto; - grid-template-areas: - "avatar avatar" - "namerole namerole" - "namerole namerole"; - - /* - Make display name a bit smaller - so there's more chance of being - able to read everything. - */ - .namerole { - .displayname { - font-size: 1.2rem; - line-height: 2rem; - margin-top: 0.5rem; - } - } - } - } -} - -/* - Phone-ish-kinda size. -*/ -@media screen and (max-width: 500px) { - .profile - .profile-header - .basic-info - .namerole { - /* - Line up in smallest possible - horizontal space to avoid overflow. - */ - display: flex; - flex-direction: column; - gap: 0.5rem; - - /* - Don't hug the right anymore - (good life advice in general). - */ - .role { - align-self: flex-start; - } - - /* - Allow this to wrap in case - of a really skinny screen. - */ - .bot-username-wrapper { - flex-wrap: wrap; - } - } -} - .profile .statuses-wrapper { flex: 65 25rem; display: flex; @@ -283,29 +46,6 @@ flex-direction: column; gap: 0.4rem; - .rss-icon { - display: block; - margin: -0.25rem 0; - - .fa { - font-size: 2rem; - object-fit: contain; - vertical-align: middle; - color: $orange2; - /* - Can't size a single-color background, so we use - a linear-gradient that's effectively white. - */ - background: linear-gradient(to right, $white1 100%, transparent 0) no-repeat center center; - background-size: 1.2rem 1.4rem; - /* light mode */ - @media (prefers-color-scheme: light) { - background: linear-gradient(to right, $white 100%, transparent 0) no-repeat center center; - background-size: 1.2rem 1.4rem; - } - } - } - .backnextlinks { display: flex; justify-content: space-between; @@ -315,55 +55,3 @@ } } } - -.profile .about-user { - flex: 35 14rem; - border-radius: $br; - overflow: hidden; - - .col-header { - margin-bottom: -0.25rem; - } - - dt { - font-weight: bold; - } - - .fields { - background: $profile-bg; - display: flex; - flex-direction: column; - padding: 0 0.5rem; - padding-top: 0.25rem; - - .field { - padding: 0.25rem; - display: flex; - flex-direction: column; - border-bottom: 0.1rem solid $gray2; - - > dt, > dd { - word-break: break-word; - } - - &:first-child { - border-top: 0.1rem solid $gray2; - } - } - } - - .bio { - background: $profile-bg; - padding: 1rem 0.75rem; - padding-bottom: 1.25rem; - } - - .accountstats { - background: $bg-accent; - padding: 0.75rem; - - display: grid; - grid-template-columns: auto 1fr; - gap: 0.25rem 1rem; - } -} \ No newline at end of file diff --git a/web/source/css/status.css b/web/source/css/status.css index 4b2d7e2a7..ec6cac3e5 100644 --- a/web/source/css/status.css +++ b/web/source/css/status.css @@ -17,14 +17,7 @@ along with this program. If not, see . */ -@import "photoswipe/dist/photoswipe.css"; -@import "photoswipe-dynamic-caption-plugin/photoswipe-dynamic-caption-plugin.css"; -@import "plyr/dist/plyr.css"; - -main { - background: transparent; - grid-auto-rows: auto; -} +@import "./_status-media.css"; .status { background: $status-bg; @@ -257,172 +250,6 @@ main { } } - .media { - grid-column: span 3; - display: grid; - grid-template-columns: 50% 50%; - grid-auto-rows: 10rem; - overflow: hidden; - - .media-wrapper { - height: 100%; - width: 100%; - box-sizing: border-box; - border: 0.15rem solid $gray1; - border-radius: $br; - position: relative; - overflow: hidden; - z-index: 2; - - details { - position: absolute; - height: 100%; - width: 100%; - - &[open] summary { - height: auto; - width: auto; - margin: 1rem; - padding: 0; - - .show, video, img { - display: none; - } - - .eye.button .hide { - display: inline-block; - grid-column: 1 / span 3; - grid-row: 1 / span 2; - } - } - - summary { - position: absolute; - height: 100%; - 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: - "eye sensitive ." - ". sensitive ."; - - &::-webkit-details-marker { - display: none; /* Safari */ - } - - .eye.button { - grid-area: eye; - align-self: start; - justify-self: start; - margin: 0; - padding: 0.4rem; - - .fa-fw { - line-height: $fa-fw; - } - - .hide { - display: none; - } - } - - .show.sensitive { - grid-area: sensitive; - align-self: center; - - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - - .button { - cursor: pointer; - align-self: center; - } - } - - video, img { - z-index: -1; - position: absolute; - height: calc(100% + 1.2rem); - width: calc(100% + 1.2rem); - top: -0.6rem; - left: -0.6rem; - filter: blur(1.2rem); - } - } - - video.plyr-video, .plyr { - position: absolute; - height: 100%; - width: 100%; - object-fit: contain; - background: $gray1; - } - - .unknown-attachment { - .placeholder { - width: 100%; - height: 100%; - padding: 0.8rem; - border: 0.2rem dashed $white2; - - display: flex; - flex-direction: column; - align-items: center; - gap: 0.25rem; - - color: $white2; - - .placeholder-external-link { - align-self: end; - font-size: 2.5rem; - } - - .placeholder-icon { - width: 100%; - font-size: 3.5rem; - text-align: center; - margin-top: auto; - } - - .placeholder-link-to { - width: 100%; - text-align: center; - margin-bottom: auto; - } - } - } - } - } - - &.single .media-wrapper { - grid-column: span 2; - } - - &.odd .media-wrapper:first-child, - &.double .media-wrapper { - grid-row: span 2; - } - - @media screen and (max-width: 42rem) { - .media-wrapper { - grid-column: span 2; - grid-row: span 2; - } - } - - img { - width: 100%; - height: 100%; - object-fit: cover; - } - } - .status-info { background: $status-info-bg; color: $fg-reduced; @@ -448,10 +275,6 @@ main { gap: 0.4rem; } - .stats-item.published-at { - text-decoration: underline; - } - .stats-item:not(.published-at):not(.edited-at) { z-index: 1; user-select: none; @@ -497,34 +320,3 @@ main { } } } - -.plyr--video { - flex-direction: column-reverse; - - .plyr__video-wrapper { - position: relative; - } - - .plyr__controls { - align-self: stretch; - position: initial; - padding: 0.1rem; - padding-top: 0.2rem; - } - - .plyr__control { - box-shadow: none; - } - - .plyr__control--overlaid { - top: calc(50% - 18px); - } -} - -.pswp__content { - padding: 2rem; - - .plyr { - max-height: 100%; - } -} \ No newline at end of file diff --git a/web/source/css/tag.css b/web/source/css/tag.css index 2f1c9db98..99dbd6f61 100644 --- a/web/source/css/tag.css +++ b/web/source/css/tag.css @@ -18,8 +18,8 @@ */ .thread { - #tag-name { - /* Ensure ridiculous length tags get wrapped */ - word-wrap: anywhere; - } + #tag-name { + /* Ensure ridiculous length tags get wrapped */ + word-wrap: anywhere; + } } diff --git a/web/source/frontend/index.js b/web/source/frontend/index.js index 310d149ed..d45420255 100644 --- a/web/source/frontend/index.js +++ b/web/source/frontend/index.js @@ -40,6 +40,9 @@ const lightbox = new PhotoswipeLightbox({ gallery: '.photoswipe-gallery', children: '.photoswipe-slide', pswpModule: Photoswipe, + // Bit darker than default 0.8. + bgOpacity: 0.9, + loop: false, }); new PhotoswipeCaptionPlugin(lightbox, { @@ -71,7 +74,9 @@ lightbox.addFilter('itemData', (item) => { } }, width: parseInt(el.dataset.pswpWidth), - height: parseInt(el.dataset.pswpHeight) + height: parseInt(el.dataset.pswpHeight), + parentStatus: el.dataset.pswpParentStatus, + attachmentId: el.dataset.pswpAttachmentId, }; } return item; @@ -98,6 +103,26 @@ lightbox.on("close", function () { } }); +lightbox.on('uiRegister', function() { + lightbox.pswp.ui.registerElement({ + name: 'open-post-link', + ariaLabel: 'Open post', + order: 8, + isButton: true, + tagName: "a", + html: 'Open post', + onInit: (el, pswp) => { + el.setAttribute('target', '_blank'); + el.setAttribute('rel', 'noopener'); + pswp.on('change', () => { + el.href = pswp.currSlide.data.parentStatus + ? pswp.currSlide.data.parentStatus + : pswp.currSlide.data.element.dataset.pswpParentStatus; + }); + } + }); +}); + lightbox.init(); function dynamicSpoiler(className, updateFunc) { @@ -156,22 +181,40 @@ Array.from(document.getElementsByClassName("plyr-video")).forEach((video) => { let player = new Plyr(video, { title: video.title, - settings: ["loop"], + settings: [], + controls: ['play-large', 'play', 'progress', 'current-time', 'volume', 'mute', 'fullscreen'], disableContextMenu: false, hideControls: false, - tooltips: { contrors: true, seek: true }, + tooltips: { controls: true, seek: true }, iconUrl: "/assets/plyr.svg", + invertTime: false, listeners: { fullscreen: () => { - if (player.playing) { - setTimeout(() => { - player.play(); - }, 1); + // Check if the photoswipe lightbox is + // open with this as the current slide. + const alreadyInLightbox = ( + lightbox.pswp !== undefined && + video.dataset.pswpAttachmentId === lightbox.pswp.currSlide.data.attachmentId + ); + + if (alreadyInLightbox) { + // If this video is already open as the + // current photoswipe slide, the fullscreen + // button toggles proper fullscreen. + player.fullscreen.toggle(); + } else { + // Otherwise the fullscreen button opens + // the video as current photoswipe slide. + // + // (Don't pause the video while it's + // being transitioned to a slide.) + if (player.playing) { + setTimeout(() => player.play(), 1); + } + lightbox.loadAndOpen(parseInt(video.dataset.pswpIndex), { + gallery: video.closest(".photoswipe-gallery") + }); } - lightbox.loadAndOpen(parseInt(video.dataset.pswpIndex), { - gallery: video.closest(".photoswipe-gallery") - }); - return false; } } diff --git a/web/source/package.json b/web/source/package.json index ea90137c8..7b11d7eb2 100644 --- a/web/source/package.json +++ b/web/source/package.json @@ -24,7 +24,7 @@ "object-to-formdata": "^4.4.2", "papaparse": "^5.3.2", "parse-link-header": "^2.0.0", - "photoswipe": "^5.3.3", + "photoswipe": "^5.4.4", "photoswipe-dynamic-caption-plugin": "^1.2.7", "plyr": "^3.7.8", "psl": "^1.9.0", diff --git a/web/source/settings/lib/types/account.ts b/web/source/settings/lib/types/account.ts index 76055ba53..f5238a7d3 100644 --- a/web/source/settings/lib/types/account.ts +++ b/web/source/settings/lib/types/account.ts @@ -79,6 +79,8 @@ export interface AccountSource { privacy: string; sensitive: boolean; status_content_type: string; + web_visibility: string; + web_layout: string; } export interface SearchAccountParams { diff --git a/web/source/settings/views/user/profile.tsx b/web/source/settings/views/user/profile.tsx index 80be3c878..9ea948337 100644 --- a/web/source/settings/views/user/profile.tsx +++ b/web/source/settings/views/user/profile.tsx @@ -61,20 +61,6 @@ interface UserProfileFormProps { } function UserProfileForm({ data: profile }: UserProfileFormProps) { - /* - User profile update form keys - - bool bot - - bool locked - - string display_name - - string note - - file avatar - - file header - - bool enable_rss - - bool hide_collections - - string custom_css (if enabled) - - string theme - */ - const { data: instance } = useInstanceV1Query(); const instanceConfig = React.useMemo(() => { return { @@ -120,7 +106,8 @@ function UserProfileForm({ data: profile }: UserProfileFormProps) { 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 }), + webVisibility: useTextInput("web_visibility", { source: profile, valueSelector: (p: Account) => p.source?.web_visibility }), + webLayout: useTextInput("web_layout", { source: profile, valueSelector: (p: Account) => p.source?.web_layout }), fields: useFieldArrayInput("fields_attributes", { defaultValue: profile?.source?.fields, length: instanceConfig.maxPinnedFields @@ -185,18 +172,24 @@ function UserProfileForm({ data: profile }: UserProfileFormProps) { /> -
-
- Theme -
- After choosing theme and saving, open your profile and refresh to see changes. -
- {themeOptions}} + /> + +