[feature] Allow user to choose "gallery" style layout for web view of profile (#3917)

* [feature] Allow user to choose "gallery" style web layout

* find a bug and squish it up and all day long you'll have good luck

* just a sec

* [performance] reindex public timeline + tinker with query a bit

* fiddling

* should be good now

* last bit of finagling, i'm done now i prommy

* panic normally
This commit is contained in:
tobi 2025-03-26 16:59:39 +01:00 committed by GitHub
commit b6e481d63e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
82 changed files with 2921 additions and 1171 deletions

View file

@ -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")
}

View file

@ -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() {

View file

@ -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": "<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",
@ -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())
}

View file

@ -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": {

View file

@ -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)
}

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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
}

View file

@ -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() {

View file

@ -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() {

View file

@ -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() {

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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)

View file

@ -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 {

View file

@ -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.

View file

@ -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)

View file

@ -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) {

View file

@ -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",

View file

@ -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,
}
}

View file

@ -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": {

View file

@ -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",

View file

@ -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)
}

View file

@ -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.