[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

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