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