[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
parent f46e490c30
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

@ -19,14 +19,18 @@ package main
import (
"context"
"fmt"
"io"
"os"
"os/signal"
"strings"
"syscall"
"time"
"codeberg.org/gruf/go-storage/memory"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db/bundb"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/media/ffmpeg"
@ -39,7 +43,7 @@ func main() {
ctx, cncl := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT)
defer cncl()
log.SetLevel(log.INFO)
log.SetLevel(log.ERROR)
if len(os.Args) != 4 {
log.Panic(ctx, "Usage: go run ./cmd/process-media <input-file> <output-processed> <output-thumbnail>")
@ -63,7 +67,8 @@ func main() {
var err error
config.SetHost("example.com")
config.SetProtocol("http")
config.SetHost("localhost:8080")
config.SetStorageBackend("disk")
config.SetStorageLocalBasePath("/tmp/gotosocial")
config.SetDbType("sqlite")
@ -109,6 +114,7 @@ func main() {
log.Panic(ctx, err)
}
outputCopyable(media)
copyFile(ctx, &st, media.File.Path, os.Args[2])
copyFile(ctx, &st, media.Thumbnail.Path, os.Args[3])
}
@ -136,3 +142,104 @@ func copyFile(ctx context.Context, st *storage.Driver, key string, path string)
log.Panic(ctx, err)
}
}
func outputCopyable(media *gtsmodel.MediaAttachment) {
var (
now = time.Now()
nowStr = now.Format(time.RFC3339)
mediaType string
fileMetaExtra string
)
switch media.Type {
case gtsmodel.FileTypeImage:
mediaType = "gtsmodel.FileTypeImage"
case gtsmodel.FileTypeVideo:
mediaType = "gtsmodel.FileTypeVideo"
case gtsmodel.FileTypeGifv:
mediaType = "gtsmodel.FileTypeGifv"
case gtsmodel.FileTypeAudio:
mediaType = "gtsmodel.FileTypeAudio"
case gtsmodel.FileTypeUnknown:
mediaType = "gtsmodel.FileTypeUnknown"
}
if media.FileMeta.Original.Duration != nil {
fileMetaExtra += fmt.Sprintf("\n\t\t\tDuration: util.Ptr[float32](%f),", *media.FileMeta.Original.Duration)
}
if media.FileMeta.Original.Framerate != nil {
fileMetaExtra += fmt.Sprintf("\n\t\t\tFramerate: util.Ptr[float32](%f),", *media.FileMeta.Original.Framerate)
}
if media.FileMeta.Original.Bitrate != nil {
fileMetaExtra += fmt.Sprintf("\n\t\t\tBitrate: util.Ptr[uint64](%d),", *media.FileMeta.Original.Bitrate)
}
fmt.Printf(`{
ID: "%s",
StatusID: "STATUS_ID_GOES_HERE",
URL: "%s",
RemoteURL: "",
CreatedAt: TimeMustParse("%s"),
Type: %s,
FileMeta: gtsmodel.FileMeta{
Original: gtsmodel.Original{
Width: %d,
Height: %d,
Size: %d,
Aspect: %f,%s
},
Small: gtsmodel.Small{
Width: %d,
Height: %d,
Size: %d,
Aspect: %f,
},
Focus: gtsmodel.Focus{
X: 0,
Y: 0,
},
},
AccountID: "ACCOUNT_ID_GOES_HERE",
Description: "DESCRIPTION_GOES_HERE",
ScheduledStatusID: "",
Blurhash: "%s",
Processing: 2,
File: gtsmodel.File{
Path: "%s",
ContentType: "%s",
FileSize: %d,
},
Thumbnail: gtsmodel.Thumbnail{
Path: "%s",
ContentType: "%s",
FileSize: %d,
URL: "%s",
RemoteURL: "",
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
}`+"\n",
media.ID,
strings.ReplaceAll(media.URL, media.AccountID, "ACCOUNT_ID_GOES_HERE"),
nowStr,
mediaType,
media.FileMeta.Original.Width,
media.FileMeta.Original.Height,
media.FileMeta.Original.Size,
media.FileMeta.Original.Aspect,
fileMetaExtra,
media.FileMeta.Small.Width,
media.FileMeta.Small.Height,
media.FileMeta.Small.Size,
media.FileMeta.Small.Aspect,
media.Blurhash,
strings.ReplaceAll(media.File.Path, media.AccountID, "ACCOUNT_ID_GOES_HERE"),
media.File.ContentType,
media.File.FileSize,
strings.ReplaceAll(media.Thumbnail.Path, media.AccountID, "ACCOUNT_ID_GOES_HERE"),
media.Thumbnail.ContentType,
media.Thumbnail.FileSize,
strings.ReplaceAll(media.Thumbnail.URL, media.AccountID, "ACCOUNT_ID_GOES_HERE"),
)
}

View file

@ -179,6 +179,13 @@ definitions:
description: The default posting content type for new statuses.
type: string
x-go-name: StatusContentType
web_layout:
description: |-
Layout to use for the web view of the account.
"microblog": default, classic microblog layout.
"gallery": gallery layout with media only.
type: string
x-go-name: WebLayout
web_visibility:
description: |-
Visibility level(s) of posts to show for this account via the web api.
@ -4902,6 +4909,13 @@ paths:
in: formData
name: web_visibility
type: string
- description: |-
Layout to use for the web view of the account.
"microblog": default, classic microblog layout.
"gallery": gallery layout with media only.
in: formData
name: web_layout
type: string
- description: Name of 1st profile field to be added to this account's profile. (The index may be any string; add more indexes to send more fields.)
in: formData
name: fields_attributes[0][name]

Binary file not shown.

After

Width:  |  Height:  |  Size: 947 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 793 KiB

View file

@ -31,6 +31,30 @@ To choose a theme, just select it from the profile settings page, and click/tap
!!! tip "Adding more themes"
Instance admins can add more themes by dropping css files into the `web/assets/themes` folder. See the [themes](../admin/themes.md) part of the admin docs for more information.
### Select Layout
GoToSocial lets you choose from two different layouts for the web view of your profile.
The setting does not affect how the API behaves or how client applications look or work, it's purely a cosmetic change for the web view.
In both cases, only top-level posts (or media from top-level posts) is shown, not replies or boosts, and the [Visibility Level of Posts to Show on Your Profile](#visibility-level-of-posts-to-show-on-your-profile) setting is respected.
#### Microblog
The GtS classic microblog layout. Your profile is split into two columns with your bio and recent/pinned posts.
This is a good choice if you primarily post text, or a mixture of text and media.
![Microblog layout](../public/user-settings-layout-microblog.png)
#### Gallery
'Gram-style layout. Posts are not shown directly on your profile. Instead, your recent/pinned media is shown in a gallery grid view. Posts (with their replies) can still be accessed via link.
This is a good choice if you primarily post media.
![Gallery layout](../public/user-settings-layout-gallery.png)
### Basic Information
#### Display Name

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.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 761 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View file

@ -95,6 +95,15 @@ func NewTestTokens() map[string]*gtsmodel.Token {
AccessCreateAt: TimeMustParse("2022-06-10T15:22:08Z"),
AccessExpiresAt: TimeMustParse("2050-01-01T15:22:08Z"),
},
"local_account_3": {
ID: "01JPCMGR09M8VGARPSBABXNZFQ",
ClientID: "01F8MGV8AC3NGSJW0FE8W1BV70",
UserID: "01JPCMFRTQ0B6R8SXPM7RS80Q4",
RedirectURI: "http://localhost:8080",
Scope: "read write push",
Access: "01JPCMK0YQ24FFVZ98PYZGJCC901JPCMK32ZKZMM737HGSWMW",
AccessCreateAt: TimeMustParse("2025-03-15T11:08:00Z"),
},
"admin_account": {
ID: "01FS4TP8ANA5VE92EAPA9E0M7Q",
ClientID: "01F8MGWSJCND9BWBD4WGJXBM93",
@ -249,6 +258,29 @@ func NewTestUsers() map[string]*gtsmodel.User {
ResetPasswordToken: "",
ResetPasswordSentAt: time.Time{},
},
"local_account_3": {
ID: "01JPCMFRTQ0B6R8SXPM7RS80Q4",
Email: "media.mogul@example.org",
AccountID: "01JPCMD83Y4WR901094YES3QC5",
EncryptedPassword: "$2y$10$ggWz5QWwnx6kzb9g0tnIJurFtE0dhr5Zfeaqs9iFuUIXzafQlJVZS", // 'password'
CreatedAt: TimeMustParse("2025-03-15T11:08:00Z"),
SignUpIP: nil,
UpdatedAt: TimeMustParse("2025-03-15T11:08:00Z"),
InviteID: "",
Locale: "en",
CreatedByApplicationID: "01HT5P2YHDMPAAD500NDAY8JW1",
LastEmailedAt: TimeMustParse("2025-03-15T11:08:00Z"),
ConfirmationToken: "",
ConfirmedAt: TimeMustParse("2025-03-15T11:08:00Z"),
ConfirmationSentAt: TimeMustParse("2025-03-15T11:08:00Z"),
UnconfirmedEmail: "",
Moderator: util.Ptr(false),
Admin: util.Ptr(false),
Disabled: util.Ptr(false),
Approved: util.Ptr(true),
ResetPasswordToken: "",
ResetPasswordSentAt: time.Time{},
},
}
return users
@ -446,6 +478,59 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
SuspensionOrigin: "",
Settings: settings["local_account_2"],
},
"local_account_3": {
ID: "01JPCMD83Y4WR901094YES3QC5",
Username: "media_mogul",
AvatarMediaAttachmentID: "01JPHQZ0ZHC2AXJK1JQNXRXQZN",
HeaderMediaAttachmentID: "01JPHRB7F2RXPTEQFRYC85EPD9",
DisplayName: "",
Fields: []*gtsmodel.Field{
{
Name: "I'm going to post a lot of",
Value: "media!",
},
{
Name: "and there's nothing",
Value: "you can do about it",
},
},
FieldsRaw: []*gtsmodel.Field{
{
Name: "I'm going to post a lot of",
Value: "media!",
},
{
Name: "and there's nothing",
Value: "you can do about it",
},
},
Note: "<p>I'm a test account that posts a shitload of media and I have my account rendered in \"gallery\" mode</p>",
NoteRaw: "I'm a test account that posts a shitload of media and I have my account rendered in \"gallery\" mode",
Memorial: util.Ptr(false),
MovedToURI: "",
CreatedAt: TimeMustParse("2025-03-15T11:08:00Z"),
UpdatedAt: TimeMustParse("2025-03-15T11:08:00Z"),
Bot: util.Ptr(false),
Locked: util.Ptr(false),
Discoverable: util.Ptr(false),
URI: "http://localhost:8080/users/media_mogul",
URL: "http://localhost:8080/@media_mogul",
FetchedAt: time.Time{},
InboxURI: "http://localhost:8080/users/media_mogul/inbox",
OutboxURI: "http://localhost:8080/users/media_mogul/outbox",
FollowersURI: "http://localhost:8080/users/media_mogul/followers",
FollowingURI: "http://localhost:8080/users/media_mogul/following",
FeaturedCollectionURI: "http://localhost:8080/users/media_mogul/collections/featured",
ActorType: ap.ActorPerson,
PrivateKey: &rsa.PrivateKey{},
PublicKey: &rsa.PublicKey{},
PublicKeyURI: "http://localhost:8080/users/media_mogul#main-key",
SensitizedAt: time.Time{},
SilencedAt: time.Time{},
SuspendedAt: time.Time{},
SuspensionOrigin: "",
Settings: settings["local_account_3"],
},
"remote_account_1": {
ID: "01F8MH5ZK5VRH73AKHQM6Y9VNX",
Username: "foss_satan",
@ -596,6 +681,7 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
"MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCq1BCPAUsc97P7u4X0Bfu68sUebdLI0ijOGFWYaHEcizTF2BGdkqbOZmQV2sW5d10FMCCVTgLa7d3DXSMk7VpYgVAXxsaREdkbs93bn9eZZYFE+Y4nE0t5YGqmPQb7bNMyCcBXvaEAtIMVjb9AOzFS2F6crDRKumPUtTC9FvJVBDx8a7i/QcAIWeU5faEJDCF8CcatvRXvRjYgm774w/vqLj2Z3S9HQy/dZuwQlQ2nV9MhTOSBYHfWJy9+s2ZpoDHDkWQAT4p+STKWFHGLmLlFHVdBQg1ZzYqPYquj4Ilqsob73NqwzI3v4PbfSCkRKLyte/VLBG7zrkVHeAA10NIzAgMBAAECggEAJQLTH5ihJIKKTTUAvbD6LDPi/0e+DmJyEsz05pNiRlPmuCKrFl+qojdO4elHQ3qX/cLCnHaNac91Z5lrPtnp5BkIOE6JwO6EAluC6s2D0alLS51h7hdhF8gK8z9vntOiIko4kQn1swhpCidu00S/1/om7Xzly3b8oB4tlBo/oKlyrhoZr9r3VDPwJVY1Z9r1feyjNtUVblDRRLBXBGyeCqUhPgESM+huNIVl8QM7zXMs0ie2QrjWSevF6Hzcdxqf05/UwVj0tfMrWf9kTz6aUR1ZUYuzuVxEn96xmrsnvAXI9BTYpRKdZzTfL5gItxdvfF6uPrK0W9QNS9ZIk7EUgQKBgQDOzP82IsZhywEr0D4bOm6GIspk05LGEi6AVVp1YaP9ZxGGTXwIXpXPbWhoZh8o3smnVgW89kD4xIA+2AXJRS/ZSA+XCqlIzGSfekd8UfLM6o6zDiC0YGgce4xMhcHXabKrGquEp64a4hrs3JcrQCM0EqhFlpOWrX3On4JJI/QlwQKBgQDTeDQizbn/wygAn1kccSBeOx45Pc8Bkpcq8KxVYsYpwpKcz4m7hqPIcz8kOofWGFqjV2AHEIoDm5OB5DwejutKJQIJhGln/boS5fOJDhvOwSaV8Lo7ehcqGqD1tbvZfDQJWjEf6acj2owIBNU5ni0GlHo/zqyu+ibaABPH36f88wKBgA8e/io/MLJF3bgOafwjsaEtOg9VSQ4iljPcCdk7YnpM5wMi90bFY77fCRtZHD4ozCXoLFM8zlNiSt5NfV7SKEWC92Db7rTb/R+MGV4Fv/Mr03NUPR/zTKmIfyG5RgsyN1Y7hP8WI6zji4R2PLd04R4Vnyg3cmM6HFDXaPdgIaIBAoGAKOYPl0eYmImi+/PVpTWP4Amo/8MffRtf1zMy8VSoJL1345IT/ku883CunpAfY13UcdDdRqCBQM9fCPkeU36qrO1ZZoPQawdcbHlCz5gF8sfScZ9cNVKYllEOHldmnFp0Kfbil1x2Me37tTVSE9GuvZ4LwrlzFmhVCUaIjNiJwdcCgYBnR7lp+rnJpXPkvllArmrKEvhcyCbcDIEGaV8aPUsXfXoVMUaiVEybdUrL3IuLtNgiab3qNZ/knYSsuAW+0tnoaOhRCUFzK47x+uLFFKCMw4FOOOJJzVu8E/5Lu0d6FpU7MuVXMa0UUGIqfOYNGywuo3XOIfWHh3iSHUg1X6/+1A==",
"MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDSIsx0TsUCeSHXDYPzViqRwB/wZhBkj5f0Mrc+Q0yogUmiTcubYQcf/xj9LOvtArJ+8/rori0j8aFX17jZqtFyDDINyhICT+i5bk1ZKPt/uH/H5oFpjtsL+bCoOF8F4AUeELExH0dO3uwl8v9fPZZ3AZEGj6UB6Ru13LON7fKHt+JT6s9jNtUIUpHUDg2GZYv9gLFGDDm9H91Yervl8yF6VWbK+7pcVyhlz5wqHR/qNUiyUXhiie+veiJc9ipCU7RriNEuehvF12d3rRIOK/wRsFAG4LxufJS8Shu8VJrOBlKzsufqjDZtnZb8SrTY0EjLJpslMf67zRDD1kEDpq4jAgMBAAECggEBAMeKxe2YMxpjHpBRRECZTTk0YN/ue5iShrAcTMeyLqRAiUS3bSXyIErw+bDIrIxXKFrHoja71x+vvw9kSSNhQxxymkFf5nQNn6geJxMIiLJC6AxSRgeP4U/g3jEPvqQck592KFzGH/e0Vji/JGMzX6NIeIfrdbx3uJmcp2CaWNkoOs7UYV5VbNDaIWYcgptQS9hJpCQ+cuMov7scXE88uKtwAl+0VVopNr/XA7vV+npsESBCt3dfnp6poA13ldfqReLdPTmDWH7Z8QrTIagrfPi5mKpxksTYyC0/quKyk4yTj8Ge5GWmsXCHtyf19NX7reeJa8MjEWonYDCdnqReDoECgYEA8R5OHNIGC6yw6ZyTuyEt2epXwUj0h2Z9d+JAT9ndRGK9xdMqJt4acjxfcEck2wjv9BuNLr5YvLc4CYiOgyqJHNt5c5Ys5rJEOgBZ2IFoaoXZNom2LEtr583T4RFXp/Id8ix85D6EZj8Hp6OvZygQFwEYQexY383hZZh5enkorUECgYEA3xr3u/SbttM86ib1RP1uuON9ZURfzpmrr2ubSWiRDqwift0T2HesdhWi6xDGjzGyeT5e7irf1BsBKUq2dp/wFX6+15A6eV12C7PvC4N8u3NJwGBdvCmufh5wZ19rerelaB7+vG9c+Nbw9h1BbDi8MlGs06oVSawvwUzp2oVKLmMCgYEAq1RFXOU/tnv3GYhQ0N86nWWPBaC5YJzK+qyh1huQxk8DWdY6VXPshs+vYTCsV5d6KZKKN3S5yR7Hir6lxT4sP30UR7WmIib5o90r+lO5xjdlqQMhl0fgXM48h+iyyHuaG8LQ274whhazccM1l683/6Cfg/hVDnJUfsRhTU1aQgECgYBrZPTZcf6+u+I3qHcqNYBl2YPUCly/+7LsJzVB2ebxlCSqwsq5yamn0fRxiMq7xSVvPXm+1b6WwEUH1mIMqiKMhk1hQJkVMMsRCRVJioqxROa8hua4G6xWI1riN8lp8hraCwl+NXEgi37ESgLjEFBvPGegH+BNbWgzeU2clcrGlwKBgHBxlFLf6AjDxjR8Z5dnZVPyvLOUjejs5nsLdOfONJ8F/MU0PoKFWdBavhbnwXwium6NvcearnhbWL758sKooZviQL6m/sKDGWMq3O8SCnX+TKTEOw+kLLFn4L3sT02WaHYg+C5iVEDdGlsXSehhI2e7hBoTulE/zbUkbA3+wlmv",
"MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC6LR5HNVS8rwA6P8U9TGOwEQ1Z8bVTCfWXJ+SjzPNYaTh/YWHA9bg+0TIKbXB9yxPVETKbEBYaP953OcIXJjGFtHNi4snhOP2/F61XoGkLltSDE2tOaGQJ0gQ5uhkGjmK2jfptBcESAZ2W4UzQkV6mGej194leGLjtxdk0A9b/Rk0MPMDrurnHH818pU2XsWfEabUGFAQlU4SuZmLHPqnxMDkOXjnOQdyXweSeMtQVYgiUOy8xkY+ecAbm7f+HGuZM5uSaAg/6z7xOpvVJeACI2PVme6pGV46o5yJUO56tt/ioCmrvgun7LqDDU0VxPuiX5WuwGeNUFrHi0boz3XivAgMBAAECggEAdWgYjQ1rx6WQvisTBooS36iRQ+Ry1dAVCWLGBCouV9XbJDFURSxwKWUhaoQDicC0XAyBXloxphIbCBLrfE/AsTHQBk9AwoB/PLAAx57IP9+5WoO3ivW4CJ1hvsnGGGVYiQlWIMSdMe7E465nE6xpBNSYHe0huq5aiM/ZHr1BKy+l5T2z2k0437+3d8RhSfwlW8T7WYWK2rQZ3hPq9Cl+gDvyvcMNt2Wo9AGonwB+XtrF13tF3nqnPx8jomj4pbmFXMzKR5RsgWNX2Fec064e53OQzkYhqQ6mByUPA//UxfOO1BtNwhFQUjNEZCYMKWcD3EoR17dcosX/GlHt+MZGuQKBgQDWBdDKqV3zZSjeUJwnkd3ykdNdVggqJiNfLww3owUG1E/VUHZuvYzsJbyWp0g+rLESqa+sPp8cKP93q1ve4Dw9Dqp4ejR8hqYUEzq2Adrcgb30WDj5IZRnku34CGsq/wUP9IOyA7chZYONzllY07m/W9ZZcSwG6ziXFeyPj4XzbQKBgQDesR4jMSEys2b5PA4MO+rQYgbKj+lVzHn4uYX0ghhuoYwZYEZ0yJKyDztbgD2x7/DP8bYAZTuksqRk4Ss/bS6iRDZlGQQaXVNeEJMiIMbLCDxx69I312nYHgZ0/ETyk/5eOdJkObshkTrFA0UO13c9t4jRQfNdjTepQj56mTcvCwKBgQCQXaXkPnCoULFLnNZofqVXDXSkvfaN7+HmP8ce9HDclXQwcLEiq+uWEzJt8PLzi+t5qkpchnUvOpxwbX9wDJO1n+HvmIc1BGKcogf1Y7TtDvtCCgyMSFFhuCObLpqTiygwBgCboJP0DBS8H9f26gKeiOVCues304z9pQVIJUj21QKBgBsUDGcZFUFWAUJzI/4m1wGpucutviC5sWcmH/zASPpC2IdJZqfSr8vJAF269UWKuIyAhrH7nUoEkurVWm3m99GxW6/lX9NY38dDWrC+rY2Indj4ZOJ3Zh5qYDyfZD7e8gJBI60eO/vz7eKA6EfKuWwewhs32sDYaBlDvdcohEZLAoGBAIoWjKNJg02dKQUU4df1BjhvEw5pSEh4hGDBR12cD52ibqGPLF36TBwVnNL284BXipjBWejzvVnCUAzflym4UgMUidhJxpVrVJSx0Tdclr0+70Lz6emtNA4e+A9ttJLwuiZrmct7G9FWJ6GgBa/1z7a+/qRLM4SMxgbMufQcIl+r",
"MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDXvT0UjYZ7vIXSnlAtCH/FurOW4V7YKp3KsXkI3p3kqpwUkwojD6a16npHw+oN6FOS0ZPli5++KpCmXPw4WDkFXC9ldi82ZxYBQL0Gu3xeRfuizvRjN3pNfw80/ph/QV9ZCc4iYr2EuMHmC352ga36tvrt89UvZeS0+UweRNlKiEJG320Mu5zUpSKiWER2d2GDfWDIKmaoF7dlG745kkL+gYBM9g6Umq67oMLVZou0FMhXsFDbeFuir/VstT8eHwlUuKdK9w8dtJJlDoYg5EXMKCckrBXADwUWBEIfVxPHwOWRrYe2Xv5Nf326se993vuSEufzBDU4hN/4nuM6pOdFAgMBAAECggEAZp04JEJ8qPYuoNN0Rzc3rxDywt1Hg4Ihs3temn1olI8h1hdqRur23Kg+qUviU+MhfT/6HMCgpo8QZlDsFtC/rnD+ikAAjNvTd50XS9B5g02+Nt5BF8AXiCzbStWeK0ko1Oz5Axn8EtjeQVFOQYfE/O9zwyKrT/QjKIE7V1pgEDaHtm4TmmTgC7238zkzvaCXSUckyi6ShsFoU2NcJvomMNeD5XgWZqbwO6rHig6BQhIizi0NsLXvIvIPXsawYV1AQFIap76c2biCgdPODMTtA/rgkGlpdu/PhST+gsx0CbA5iaIHY4nmKavrpbLzF2TG6GjomH4n4+1C/5HVqarbAQKBgQDiQUt0/RirbGr+9B4LOOLKEmoJoOrdNXoydKssTqUvOtMNTmDnJNoVQ0zYH5waydgZSN7Ce3pGztFwZ6gHyxQ80utjF4ttb5CmZCpoWyMqOyEbiV70lWjxcdfGnTtm0b2XJPTFFCXI+JemWoy+c7B+1AViYlHX/IMB/jWH+Y/q8QKBgQD0GgdjHYcyk5MZha5bWTRdzrX/IyWtmsqY1vvKwwb8e2W/AFLljL91elb6eKPhfLhbWoGRSLzgGJ1LGSv4e15bIPk6ZXkxl+PDlCvlAMLmV5LiH3ky5xlC7/zBFhKvLVztb66JGbielilVV4zTqS04VsYhZOKVuCNRNYjh4Km5lQKBgQCRdPLi6lgy1QfQkvbBtjevO7lqKUb1Ig1GZNUrLgBqZcILmukXkQyXgOXlSCUe38cLMlrr42BQJ2RkhG91WyzOkbb8xMVBfOkc3+aXoofv/YWiY2VljqyiFNNo/+qRhqQBiKPIE9Ta6F7uduZnBo9gakRv5M/DMLa00E5v9ZR9sQKBgD3KsQAII4dMEDqvunlpVXZBs5SIgys1OgACu+6R/BzB5/m3zURKotTMSWRSUbns5oZJnO74KMfZs0elcZoPMM2ExVJhCZLiTkfeJFZuIOhKVuZi7T1TfvOQ6LzAJ66snw+D6/zMxA1xGbl+1ilmdAoE/VbKwQkBef8+vA3h31UZAoGAUzlh0nGH59pZ7pRH5XHCXCSqnwFn9l9Dnfoin2tsjSLQVqANAqUySaNfZ6CxHlP/J5Cg6PMebZGr0I3KIXl3iXfth1Jnf8kPtBc5/OLOtN2njleILVlrqHwnWA757OsE+BKpqI9wOKn/B9iY3SgBSlosSIbOQKd/V2vZVUGf37U=",
}
if diff := len(accountsSorted) - len(preserializedKeys); diff > 0 {
@ -640,6 +726,7 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings {
EnableRSS: util.Ptr(false),
HideCollections: util.Ptr(false),
WebVisibility: gtsmodel.VisibilityPublic,
WebLayout: gtsmodel.WebLayoutMicroblog,
},
"admin_account": {
AccountID: "01F8MH17FWEB39HZJ76B6VXSKF",
@ -651,6 +738,7 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings {
EnableRSS: util.Ptr(true),
HideCollections: util.Ptr(false),
WebVisibility: gtsmodel.VisibilityPublic,
WebLayout: gtsmodel.WebLayoutMicroblog,
},
"local_account_1": {
AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF",
@ -662,6 +750,7 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings {
EnableRSS: util.Ptr(true),
HideCollections: util.Ptr(false),
WebVisibility: gtsmodel.VisibilityUnlocked,
WebLayout: gtsmodel.WebLayoutMicroblog,
},
"local_account_2": {
AccountID: "01F8MH5NBDF2MV7CTC4Q5128HF",
@ -673,6 +762,19 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings {
EnableRSS: util.Ptr(false),
HideCollections: util.Ptr(true),
WebVisibility: gtsmodel.VisibilityPublic,
WebLayout: gtsmodel.WebLayoutMicroblog,
},
"local_account_3": {
AccountID: "01JPCMD83Y4WR901094YES3QC5",
CreatedAt: TimeMustParse("2025-03-15T11:08:00Z"),
UpdatedAt: TimeMustParse("2025-03-15T11:08:00Z"),
Privacy: gtsmodel.VisibilityPublic,
Sensitive: util.Ptr(true),
Language: "en",
EnableRSS: util.Ptr(true),
HideCollections: util.Ptr(false),
WebVisibility: gtsmodel.VisibilityUnlocked,
WebLayout: gtsmodel.WebLayoutGallery,
},
}
}
@ -1035,6 +1137,623 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
Header: util.Ptr(false),
Cached: util.Ptr(false),
},
"local_account_3_avatar": {
ID: "01JPHQZ0ZHC2AXJK1JQNXRXQZN",
StatusID: "", // this attachment isn't connected to a status
URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/avatar/original/01JPHQZ0ZHC2AXJK1JQNXRXQZN.jpeg",
RemoteURL: "",
CreatedAt: TimeMustParse("2025-03-17T10:46:37+01:00"),
Type: gtsmodel.FileTypeImage,
FileMeta: gtsmodel.FileMeta{
Original: gtsmodel.Original{
Width: 1280,
Height: 720,
Size: 921600,
Aspect: 1.777778,
},
Small: gtsmodel.Small{
Width: 512,
Height: 288,
Size: 147456,
Aspect: 1.777778,
},
Focus: gtsmodel.Focus{
X: 0,
Y: 0,
},
},
AccountID: "01JPCMD83Y4WR901094YES3QC5",
Description: "DESCRIPTION_GOES_HERE",
ScheduledStatusID: "",
Blurhash: "LRF~2LIU0esp-qRjR*aeJ$s;iwW.",
Processing: 2,
File: gtsmodel.File{
Path: "01JPCMD83Y4WR901094YES3QC5/avatar/original/01JPHQZ0ZHC2AXJK1JQNXRXQZN.jpeg",
ContentType: "image/jpeg",
FileSize: 291230,
},
Thumbnail: gtsmodel.Thumbnail{
Path: "01JPCMD83Y4WR901094YES3QC5/avatar/small/01JPHQZ0ZHC2AXJK1JQNXRXQZN.jpeg",
ContentType: "image/jpeg",
FileSize: 24486,
URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/avatar/small/01JPHQZ0ZHC2AXJK1JQNXRXQZN.jpeg",
RemoteURL: "",
},
Avatar: util.Ptr(true),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
"local_account_3_header": {
ID: "01JPHRB7F2RXPTEQFRYC85EPD9",
StatusID: "", // this attachment isn't connected to a status
URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/header/original/01JPHRB7F2RXPTEQFRYC85EPD9.png",
RemoteURL: "",
CreatedAt: TimeMustParse("2025-03-17T10:53:17+01:00"),
Type: gtsmodel.FileTypeImage,
FileMeta: gtsmodel.FileMeta{
Original: gtsmodel.Original{
Width: 725,
Height: 307,
Size: 222575,
Aspect: 2.361563,
},
Small: gtsmodel.Small{
Width: 512,
Height: 216,
Size: 110592,
Aspect: 2.361563,
},
Focus: gtsmodel.Focus{
X: 0,
Y: 0,
},
},
AccountID: "01JPCMD83Y4WR901094YES3QC5",
Description: "DESCRIPTION_GOES_HERE",
ScheduledStatusID: "",
Blurhash: "L9I5h:%M%M?a~os:D*bFMybFM{jI",
Processing: 2,
File: gtsmodel.File{
Path: "01JPCMD83Y4WR901094YES3QC5/header/original/01JPHRB7F2RXPTEQFRYC85EPD9.png",
ContentType: "image/png",
FileSize: 405238,
},
Thumbnail: gtsmodel.Thumbnail{
Path: "01JPCMD83Y4WR901094YES3QC5/header/small/01JPHRB7F2RXPTEQFRYC85EPD9.webp",
ContentType: "image/webp",
FileSize: 26478,
URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/header/small/01JPHRB7F2RXPTEQFRYC85EPD9.webp",
RemoteURL: "",
},
Avatar: util.Ptr(false),
Header: util.Ptr(true),
Cached: util.Ptr(true),
},
// sickos
"local_account_3_status_1_attachment_1": {
ID: "01JPCPRMPPGWKBCAE7X81XA0PK",
StatusID: "01JPCNB4417JG3XHHP0WS60RM3",
URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPCPRMPPGWKBCAE7X81XA0PK.jpeg",
RemoteURL: "",
CreatedAt: TimeMustParse("2025-03-15T11:49:28+01:00"),
Type: gtsmodel.FileTypeImage,
FileMeta: gtsmodel.FileMeta{
Original: gtsmodel.Original{
Width: 1920,
Height: 1200,
Size: 2304000,
Aspect: 1.600000,
},
Small: gtsmodel.Small{
Width: 512,
Height: 320,
Size: 163840,
Aspect: 1.600000,
},
Focus: gtsmodel.Focus{
X: 0,
Y: 0,
},
},
AccountID: "01JPCMD83Y4WR901094YES3QC5",
Description: "DESCRIPTION_GOES_HERE",
ScheduledStatusID: "",
Blurhash: "L~EqXWX5t6og%jW=owa~N1WFjYWC",
Processing: 2,
File: gtsmodel.File{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPCPRMPPGWKBCAE7X81XA0PK.jpeg",
ContentType: "image/jpeg",
FileSize: 513277,
},
Thumbnail: gtsmodel.Thumbnail{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPCPRMPPGWKBCAE7X81XA0PK.jpeg",
ContentType: "image/jpeg",
FileSize: 23550,
URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPCPRMPPGWKBCAE7X81XA0PK.jpeg",
RemoteURL: "",
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
// marge
"local_account_3_status_1_attachment_2": {
ID: "01JPCPTSFNQDAGTHP49DXSD0BM",
StatusID: "01JPCNB4417JG3XHHP0WS60RM3",
URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPCPTSFNQDAGTHP49DXSD0BM.png",
RemoteURL: "",
CreatedAt: TimeMustParse("2025-03-15T11:50:38+01:00"),
Type: gtsmodel.FileTypeImage,
FileMeta: gtsmodel.FileMeta{
Original: gtsmodel.Original{
Width: 976,
Height: 741,
Size: 723216,
Aspect: 1.317139,
},
Small: gtsmodel.Small{
Width: 512,
Height: 388,
Size: 198656,
Aspect: 1.317139,
},
Focus: gtsmodel.Focus{
X: 0,
Y: 0,
},
},
AccountID: "01JPCMD83Y4WR901094YES3QC5",
Description: "DESCRIPTION_GOES_HERE",
ScheduledStatusID: "",
Blurhash: "LGH1i6RpD;-,0DoZaIogA2N3xZI]",
Processing: 2,
File: gtsmodel.File{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPCPTSFNQDAGTHP49DXSD0BM.png",
ContentType: "image/png",
FileSize: 380878,
},
Thumbnail: gtsmodel.Thumbnail{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPCPTSFNQDAGTHP49DXSD0BM.webp",
ContentType: "image/webp",
FileSize: 51882,
URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPCPTSFNQDAGTHP49DXSD0BM.webp",
RemoteURL: "",
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
// sloth-gear
"local_account_3_status_1_attachment_3": {
ID: "01JPCPYJ6N2E2R7GAJ1XECXNV5",
StatusID: "01JPCNB4417JG3XHHP0WS60RM3",
URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPCPYJ6N2E2R7GAJ1XECXNV5.webp",
RemoteURL: "",
CreatedAt: TimeMustParse("2025-03-15T11:52:42+01:00"),
Type: gtsmodel.FileTypeImage,
FileMeta: gtsmodel.FileMeta{
Original: gtsmodel.Original{
Width: 2830,
Height: 1472,
Size: 4165760,
Aspect: 1.922554,
},
Small: gtsmodel.Small{
Width: 512,
Height: 266,
Size: 136192,
Aspect: 1.922554,
},
Focus: gtsmodel.Focus{
X: 0,
Y: 0,
},
},
AccountID: "01JPCMD83Y4WR901094YES3QC5",
Description: "DESCRIPTION_GOES_HERE",
ScheduledStatusID: "",
Blurhash: "LOE.|bxZx]j[~pt7WWWW%Lj@%Mj[",
Processing: 2,
File: gtsmodel.File{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPCPYJ6N2E2R7GAJ1XECXNV5.webp",
ContentType: "image/webp",
FileSize: 366592,
},
Thumbnail: gtsmodel.Thumbnail{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPCPYJ6N2E2R7GAJ1XECXNV5.jpeg",
ContentType: "image/jpeg",
FileSize: 15461,
URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPCPYJ6N2E2R7GAJ1XECXNV5.jpeg",
RemoteURL: "",
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
// you-posted
"local_account_3_status_1_attachment_4": {
ID: "01JPCQ4WXEA52VVR9V1HN7E0RS",
StatusID: "01JPCNB4417JG3XHHP0WS60RM3",
URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPCQ4WXEA52VVR9V1HN7E0RS.png",
RemoteURL: "",
CreatedAt: TimeMustParse("2025-03-15T11:56:09+01:00"),
Type: gtsmodel.FileTypeImage,
FileMeta: gtsmodel.FileMeta{
Original: gtsmodel.Original{
Width: 1920,
Height: 1080,
Size: 2073600,
Aspect: 1.777778,
},
Small: gtsmodel.Small{
Width: 512,
Height: 288,
Size: 147456,
Aspect: 1.777778,
},
Focus: gtsmodel.Focus{
X: 0,
Y: 0,
},
},
AccountID: "01JPCMD83Y4WR901094YES3QC5",
Description: "DESCRIPTION_GOES_HERE",
ScheduledStatusID: "",
Blurhash: "L00+zhoLNubHj[fQa|fQ9tWVw{jZ",
Processing: 2,
File: gtsmodel.File{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPCQ4WXEA52VVR9V1HN7E0RS.png",
ContentType: "image/png",
FileSize: 80917,
},
Thumbnail: gtsmodel.Thumbnail{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPCQ4WXEA52VVR9V1HN7E0RS.webp",
ContentType: "image/webp",
FileSize: 5344,
URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPCQ4WXEA52VVR9V1HN7E0RS.webp",
RemoteURL: "",
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
// buscemi
"local_account_3_status_1_attachment_5": {
ID: "01JPCQ9VBZBMSTVN56QN3R5188",
StatusID: "01JPCNB4417JG3XHHP0WS60RM3",
URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPCQ9VBZBMSTVN56QN3R5188.jpeg",
RemoteURL: "",
CreatedAt: TimeMustParse("2025-03-15T11:58:51+01:00"),
Type: gtsmodel.FileTypeImage,
FileMeta: gtsmodel.FileMeta{
Original: gtsmodel.Original{
Width: 1077,
Height: 525,
Size: 565425,
Aspect: 2.051429,
},
Small: gtsmodel.Small{
Width: 512,
Height: 249,
Size: 127488,
Aspect: 2.051429,
},
Focus: gtsmodel.Focus{
X: 0,
Y: 0,
},
},
AccountID: "01JPCMD83Y4WR901094YES3QC5",
Description: "DESCRIPTION_GOES_HERE",
ScheduledStatusID: "",
Blurhash: "L5A9A=}?J*5m56Rk={$%O?Nb$M$i",
Processing: 2,
File: gtsmodel.File{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPCQ9VBZBMSTVN56QN3R5188.jpeg",
ContentType: "image/jpeg",
FileSize: 42899,
},
Thumbnail: gtsmodel.Thumbnail{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPCQ9VBZBMSTVN56QN3R5188.jpeg",
ContentType: "image/jpeg",
FileSize: 17341,
URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPCQ9VBZBMSTVN56QN3R5188.jpeg",
RemoteURL: "",
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
// butt
"local_account_3_status_1_attachment_6": {
ID: "01JPG1RZPRH3Y00VSA3RQ2SJWP",
StatusID: "01JPCNB4417JG3XHHP0WS60RM3",
URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPG1RZPRH3Y00VSA3RQ2SJWP.gif",
RemoteURL: "",
CreatedAt: TimeMustParse("2025-03-16T18:59:36+01:00"),
Type: gtsmodel.FileTypeImage,
FileMeta: gtsmodel.FileMeta{
Original: gtsmodel.Original{
Width: 31,
Height: 25,
Size: 775,
Aspect: 1.240000,
},
Small: gtsmodel.Small{
Width: 31,
Height: 25,
Size: 775,
Aspect: 1.240000,
},
Focus: gtsmodel.Focus{
X: 0,
Y: 0,
},
},
AccountID: "01JPCMD83Y4WR901094YES3QC5",
Description: "DESCRIPTION_GOES_HERE",
ScheduledStatusID: "",
Blurhash: "LWLN.4~q00ofxuxu-;%M9F-;-;xu",
Processing: 2,
File: gtsmodel.File{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPG1RZPRH3Y00VSA3RQ2SJWP.gif",
ContentType: "image/gif",
FileSize: 636,
},
Thumbnail: gtsmodel.Thumbnail{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPG1RZPRH3Y00VSA3RQ2SJWP.webp",
ContentType: "image/webp",
FileSize: 406,
URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPG1RZPRH3Y00VSA3RQ2SJWP.webp",
RemoteURL: "",
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
// bunny
"local_account_3_status_2_attachment_1": {
ID: "01JPHFKQ86GT9W76SWPHE9P8JB",
StatusID: "01JPCNJAPHJKJC4EXWA6N9BXDD",
URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPHFKQ86GT9W76SWPHE9P8JB.webm",
RemoteURL: "",
CreatedAt: TimeMustParse("2025-03-17T08:20:38+01:00"),
Type: gtsmodel.FileTypeVideo,
FileMeta: gtsmodel.FileMeta{
Original: gtsmodel.Original{
Width: 640,
Height: 360,
Size: 230400,
Aspect: 1.777778,
Duration: util.Ptr[float32](32.480000),
Bitrate: util.Ptr[uint64](533294),
},
Small: gtsmodel.Small{
Width: 512,
Height: 288,
Size: 147456,
Aspect: 1.777778,
},
Focus: gtsmodel.Focus{
X: 0,
Y: 0,
},
},
AccountID: "01JPCMD83Y4WR901094YES3QC5",
Description: "DESCRIPTION_GOES_HERE",
ScheduledStatusID: "",
Blurhash: "LEQcn{?bfQ?b~qoffQoffQfQfQfQ",
Processing: 2,
File: gtsmodel.File{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPHFKQ86GT9W76SWPHE9P8JB.webm",
ContentType: "video/webm",
FileSize: 2165608,
},
Thumbnail: gtsmodel.Thumbnail{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPHFKQ86GT9W76SWPHE9P8JB.webp",
ContentType: "image/webp",
FileSize: 324,
URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPHFKQ86GT9W76SWPHE9P8JB.webp",
RemoteURL: "",
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
// computerbye
"local_account_3_status_2_attachment_2": {
ID: "01JPHFSCVGGH02FX9VJMXGXN45",
StatusID: "01JPCNJAPHJKJC4EXWA6N9BXDD",
URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPHFSCVGGH02FX9VJMXGXN45.gif",
RemoteURL: "",
CreatedAt: TimeMustParse("2025-03-17T08:23:44+01:00"),
Type: gtsmodel.FileTypeImage,
FileMeta: gtsmodel.FileMeta{
Original: gtsmodel.Original{
Width: 442,
Height: 332,
Size: 146744,
Aspect: 1.331325,
Duration: util.Ptr[float32](3.750000),
Framerate: util.Ptr[float32](20.000000),
Bitrate: util.Ptr[uint64](4078150),
},
Small: gtsmodel.Small{
Width: 442,
Height: 332,
Size: 146744,
Aspect: 1.331325,
},
Focus: gtsmodel.Focus{
X: 0,
Y: 0,
},
},
AccountID: "01JPCMD83Y4WR901094YES3QC5",
Description: "DESCRIPTION_GOES_HERE",
ScheduledStatusID: "",
Blurhash: "LLHUzr-;o#_2~q-:IV%Mxu%MM{M{",
Processing: 2,
File: gtsmodel.File{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPHFSCVGGH02FX9VJMXGXN45.gif",
ContentType: "image/gif",
FileSize: 1911633,
},
Thumbnail: gtsmodel.Thumbnail{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPHFSCVGGH02FX9VJMXGXN45.webp",
ContentType: "image/webp",
FileSize: 10056,
URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPHFSCVGGH02FX9VJMXGXN45.webp",
RemoteURL: "",
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
// diarrhea
"local_account_3_status_2_attachment_3": {
ID: "01JPHFW5HKFWQNQ954P5KNXWSR",
StatusID: "01JPCNJAPHJKJC4EXWA6N9BXDD",
URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPHFW5HKFWQNQ954P5KNXWSR.gif",
RemoteURL: "",
CreatedAt: TimeMustParse("2025-03-17T08:25:15+01:00"),
Type: gtsmodel.FileTypeImage,
FileMeta: gtsmodel.FileMeta{
Original: gtsmodel.Original{
Width: 320,
Height: 214,
Size: 68480,
Aspect: 1.495327,
Duration: util.Ptr[float32](3.100000),
Framerate: util.Ptr[float32](10.000000),
Bitrate: util.Ptr[uint64](2011086),
},
Small: gtsmodel.Small{
Width: 320,
Height: 214,
Size: 68480,
Aspect: 1.495327,
},
Focus: gtsmodel.Focus{
X: 0,
Y: 0,
},
},
AccountID: "01JPCMD83Y4WR901094YES3QC5",
Description: "DESCRIPTION_GOES_HERE",
ScheduledStatusID: "",
Blurhash: "L78qTmNG00xZkWxsIURQ01s;?aR*",
Processing: 2,
File: gtsmodel.File{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPHFW5HKFWQNQ954P5KNXWSR.gif",
ContentType: "image/gif",
FileSize: 779296,
},
Thumbnail: gtsmodel.Thumbnail{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPHFW5HKFWQNQ954P5KNXWSR.webp",
ContentType: "image/webp",
FileSize: 10238,
URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPHFW5HKFWQNQ954P5KNXWSR.webp",
RemoteURL: "",
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
// ffmpreg
"local_account_3_status_2_attachment_4": {
ID: "01JPHFZP2VNS1M2RQ646BXBZQG",
StatusID: "01JPCNJAPHJKJC4EXWA6N9BXDD",
URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPHFZP2VNS1M2RQ646BXBZQG.jpeg",
RemoteURL: "",
CreatedAt: TimeMustParse("2025-03-17T08:27:10+01:00"),
Type: gtsmodel.FileTypeImage,
FileMeta: gtsmodel.FileMeta{
Original: gtsmodel.Original{
Width: 1280,
Height: 720,
Size: 921600,
Aspect: 1.777778,
},
Small: gtsmodel.Small{
Width: 512,
Height: 288,
Size: 147456,
Aspect: 1.777778,
},
Focus: gtsmodel.Focus{
X: 0,
Y: 0,
},
},
AccountID: "01JPCMD83Y4WR901094YES3QC5",
Description: "DESCRIPTION_GOES_HERE",
ScheduledStatusID: "",
Blurhash: "LOCX.y}rIpE3,?w{S4W;9vENX8t6",
Processing: 2,
File: gtsmodel.File{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPHFZP2VNS1M2RQ646BXBZQG.jpeg",
ContentType: "image/jpeg",
FileSize: 137328,
},
Thumbnail: gtsmodel.Thumbnail{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPHFZP2VNS1M2RQ646BXBZQG.jpeg",
ContentType: "image/jpeg",
FileSize: 19775,
URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPHFZP2VNS1M2RQ646BXBZQG.jpeg",
RemoteURL: "",
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
// notabug
"local_account_3_status_2_attachment_5": {
ID: "01JPHG32F7M6F084WKEGAYJ40X",
StatusID: "01JPCNJAPHJKJC4EXWA6N9BXDD",
URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPHG32F7M6F084WKEGAYJ40X.jpeg",
RemoteURL: "",
CreatedAt: TimeMustParse("2025-03-17T08:29:01+01:00"),
Type: gtsmodel.FileTypeImage,
FileMeta: gtsmodel.FileMeta{
Original: gtsmodel.Original{
Width: 500,
Height: 739,
Size: 369500,
Aspect: 0.676590,
},
Small: gtsmodel.Small{
Width: 346,
Height: 512,
Size: 177152,
Aspect: 0.676590,
},
Focus: gtsmodel.Focus{
X: 0,
Y: 0,
},
},
AccountID: "01JPCMD83Y4WR901094YES3QC5",
Description: "DESCRIPTION_GOES_HERE",
ScheduledStatusID: "",
Blurhash: "LTGbrRxAE1og0OR:xve-OFs6kCWY",
Processing: 2,
File: gtsmodel.File{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/original/01JPHG32F7M6F084WKEGAYJ40X.jpeg",
ContentType: "image/jpeg",
FileSize: 106636,
},
Thumbnail: gtsmodel.Thumbnail{
Path: "01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPHG32F7M6F084WKEGAYJ40X.jpeg",
ContentType: "image/jpeg",
FileSize: 27483,
URL: "http://localhost:8080/fileserver/01JPCMD83Y4WR901094YES3QC5/attachment/small/01JPHG32F7M6F084WKEGAYJ40X.jpeg",
RemoteURL: "",
},
Avatar: util.Ptr(false),
Header: util.Ptr(false),
Cached: util.Ptr(true),
},
"remote_account_1_status_1_attachment_1": {
ID: "01FVW7RXPQ8YJHTEXYPE7Q8ZY0",
StatusID: "01FVW7JHQFSFK166WWKR8CBA6M",
@ -1372,6 +2091,58 @@ func newTestStoredAttachments() map[string]filenames {
Original: "ghosts-original.mp3",
Small: "ghosts-small.webp",
},
"local_account_3_status_1_attachment_1": {
Original: "sickos-original.jpeg",
Small: "sickos-small.jpeg",
},
"local_account_3_status_1_attachment_2": {
Original: "marge-original.png",
Small: "marge-small.webp",
},
"local_account_3_status_1_attachment_3": {
Original: "sloth-gear-original.webp",
Small: "sloth-gear-small.jpeg",
},
"local_account_3_status_1_attachment_4": {
Original: "you-posted-original.webp",
Small: "you-posted-small.webp",
},
"local_account_3_status_1_attachment_5": {
Original: "buscemi-original.jpeg",
Small: "buscemi-small.jpeg",
},
"local_account_3_avatar": {
Original: "dollar-original.jpeg",
Small: "dollar-small.jpeg",
},
"local_account_3_header": {
Original: "dollar2-original.png",
Small: "dollar2-small.webp",
},
"local_account_3_status_1_attachment_6": {
Original: "butt-original.gif",
Small: "butt-small.webp",
},
"local_account_3_status_2_attachment_1": {
Original: "bunny-original.webm",
Small: "bunny-small.webp",
},
"local_account_3_status_2_attachment_2": {
Original: "computerbye-original.gif",
Small: "computerbye-small.webp",
},
"local_account_3_status_2_attachment_3": {
Original: "diarrhea-original.gif",
Small: "diarrhea-small.webp",
},
"local_account_3_status_2_attachment_4": {
Original: "ffmpreg-original.jpeg",
Small: "ffmpreg-small.jpeg",
},
"local_account_3_status_2_attachment_5": {
Original: "notabug-original.jpeg",
Small: "notabug-small.jpeg",
},
"remote_account_1_status_1_attachment_1": {
Original: "thoughtsofdog-original.jpg",
Small: "thoughtsofdog-small.jpeg",
@ -1941,6 +2712,54 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
Federated: util.Ptr(true),
ActivityStreamsType: ap.ObjectNote,
},
"local_account_3_status_1": {
ID: "01JPCNB4417JG3XHHP0WS60RM3",
URI: "http://localhost:8080/users/media_mogul/statuses/01JPCNB4417JG3XHHP0WS60RM3",
URL: "http://localhost:8080/@media_mogul/statuses/01JPCNB4417JG3XHHP0WS60RM3",
AttachmentIDs: []string{
"01JPCPRMPPGWKBCAE7X81XA0PK",
"01JPCPTSFNQDAGTHP49DXSD0BM",
"01JPCPYJ6N2E2R7GAJ1XECXNV5",
"01JPCQ4WXEA52VVR9V1HN7E0RS",
"01JPCQ9VBZBMSTVN56QN3R5188",
"01JPG1RZPRH3Y00VSA3RQ2SJWP",
},
ContentType: gtsmodel.StatusContentTypePlain,
CreatedAt: TimeMustParse("2025-03-15T11:26:17Z"),
Local: util.Ptr(true),
AccountURI: "http://localhost:8080/users/media_mogul",
AccountID: "01JPCMD83Y4WR901094YES3QC5",
Visibility: gtsmodel.VisibilityUnlocked,
Sensitive: util.Ptr(false),
Language: "en",
CreatedWithApplicationID: "01F8MGY43H3N2C8EWPR2FPYEXG",
Federated: util.Ptr(true),
ActivityStreamsType: ap.ObjectNote,
PinnedAt: TimeMustParse("2025-03-15T11:27:00Z"),
},
"local_account_3_status_2": {
ID: "01JPCNJAPHJKJC4EXWA6N9BXDD",
URI: "http://localhost:8080/users/media_mogul/statuses/01JPCNJAPHJKJC4EXWA6N9BXDD",
URL: "http://localhost:8080/@media_mogul/statuses/01JPCNJAPHJKJC4EXWA6N9BXDD",
AttachmentIDs: []string{
"01JPHFKQ86GT9W76SWPHE9P8JB",
"01JPHFSCVGGH02FX9VJMXGXN45",
"01JPHFW5HKFWQNQ954P5KNXWSR",
"01JPHFZP2VNS1M2RQ646BXBZQG",
"01JPHG32F7M6F084WKEGAYJ40X",
},
ContentType: gtsmodel.StatusContentTypePlain,
CreatedAt: TimeMustParse("2025-03-15T11:28:42Z"),
Local: util.Ptr(true),
AccountURI: "http://localhost:8080/users/media_mogul",
AccountID: "01JPCMD83Y4WR901094YES3QC5",
Visibility: gtsmodel.VisibilityUnlocked,
Sensitive: util.Ptr(false),
Language: "en",
CreatedWithApplicationID: "01F8MGY43H3N2C8EWPR2FPYEXG",
Federated: util.Ptr(true),
ActivityStreamsType: ap.ObjectNote,
},
"remote_account_1_status_1": {
ID: "01FVW7JHQFSFK166WWKR8CBA6M",
URI: "http://fossbros-anonymous.io/users/foss_satan/statuses/01FVW7JHQFSFK166WWKR8CBA6M",

View file

@ -135,12 +135,12 @@ html, body {
}
/* Make show more/less buttons more legible */
.status button, .status .button {
.button {
background-color: var(--almost-white);
color: var(--almost-black);
border: var(--dashed-border);
}
.status button:hover, .status .button:hover {
.button:hover {
background-color: var(--almost-black);
color: var(--almost-white);
border: var(--dashed-border);

View file

@ -130,12 +130,12 @@ html, body {
}
/* Make show more/less buttons more legible */
.status button, .status .button {
.button {
background-color: var(--almost-black);
color: var(--almost-white);
border: var(--dashed-border);
}
.status button:hover, .status .button:hover {
.button:hover {
background-color: var(--almost-white);
color: var(--almost-black);
border: var(--dashed-border);

View file

@ -0,0 +1,207 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
@import "photoswipe/dist/photoswipe.css";
@import "photoswipe-dynamic-caption-plugin/photoswipe-dynamic-caption-plugin.css";
@import "plyr/dist/plyr.css";
.media-wrapper {
height: 100%;
width: 100%;
box-sizing: border-box;
border: 0.15rem solid $gray1;
border-radius: $br;
position: relative;
overflow: hidden;
z-index: 2;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
details {
position: absolute;
height: 100%;
width: 100%;
&[open] summary {
height: auto;
width: auto;
margin: 1rem;
padding: 0;
.show, video, img {
display: none;
}
.eye.button .hide {
display: inline-block;
grid-column: 1 / span 3;
grid-row: 1 / span 2;
}
}
summary {
position: absolute;
height: 100%;
width: 100%;
z-index: 3;
overflow: hidden;
display: grid;
padding: 1rem;
grid-template-columns: 1fr auto 1fr;
grid-template-rows: 1fr 1fr;
grid-template-areas:
"eye sensitive ."
". sensitive .";
&::-webkit-details-marker {
display: none; /* Safari */
}
.eye.button {
grid-area: eye;
align-self: start;
justify-self: start;
margin: 0;
padding: 0.4rem;
.fa-fw {
line-height: $fa-fw;
}
.hide {
display: none;
}
}
.show.sensitive {
grid-area: sensitive;
align-self: center;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
.button {
cursor: pointer;
align-self: center;
}
}
video, img {
z-index: -1;
position: absolute;
height: calc(100% + 1.2rem);
width: calc(100% + 1.2rem);
top: -0.6rem;
left: -0.6rem;
filter: blur(1.2rem);
}
}
video.plyr-video, .plyr {
position: absolute;
height: 100%;
width: 100%;
object-fit: contain;
background: $gray1;
}
.unknown-attachment {
.placeholder {
width: 100%;
height: 100%;
padding: 0.8rem;
border: 0.2rem dashed $white2;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
color: $white2;
.placeholder-external-link {
align-self: end;
font-size: 2.5rem;
}
.placeholder-icon {
width: 100%;
font-size: 3.5rem;
text-align: center;
margin-top: auto;
}
.placeholder-link-to {
width: 100%;
text-align: center;
margin-bottom: auto;
}
}
}
}
}
.pswp__button--open-post-link {
display: flex;
align-items: center;
justify-content: center;
span > i {
background: $status-bg;
color: $fg;
border-radius: 25%;
}
}
.plyr--video {
flex-direction: column-reverse;
.plyr__video-wrapper {
position: relative;
}
.plyr__controls {
align-self: stretch;
position: initial;
padding: 0.1rem;
padding-top: 0.2rem;
}
.plyr__control {
box-shadow: none;
}
.plyr__control--overlaid {
top: calc(50% - 18px);
}
}
.pswp__content {
padding: 2rem;
.plyr {
max-height: 100%;
}
}

View file

@ -0,0 +1,343 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
.profile .profile-header {
background: $profile-bg;
border-radius: $br;
overflow: hidden;
margin-bottom: 1rem;
.moved-to {
padding: 1rem;
text-align: center;
}
.header-image-wrapper {
position: relative;
padding-top: 33.33%; /* aspect-ratio 1/3 */
img {
position: absolute;
top: 0;
left: 0;
right: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
}
/*
Basic info container has the user's avatar, display- and username, and role
It's partially overlapped over the header image, by a negative margin-top.
*/
$avatar-size: 8.5rem;
$name-size: 3rem;
$username-size: 2rem;
$overlap: calc($avatar-size - $name-size - $username-size);
.basic-info {
position: relative;
display: grid;
box-sizing: border-box;
grid-template-columns: $avatar-size auto 1fr;
grid-template-rows: $overlap $name-size auto;
grid-template-areas:
"avatar . ."
"avatar namerole namerole"
"avatar namerole namerole";
margin: 1rem;
margin-top: calc(-1 * $overlap);
gap: 0 1rem;
.avatar-image-wrapper {
grid-area: avatar;
border: 0.2rem solid $avatar-border;
border-radius: $br;
/*
Wrapper always same
size + proportions no
matter image inside.
*/
height: $avatar-size;
width: $avatar-size;
.avatar {
/*
Fit 100% of the wrapper.
*/
height: 100%;
width: 100%;
/*
Normalize non-square images.
*/
object-fit: cover;
/*
Prevent image extending
beyond rounded borders.
*/
border-radius: $br-inner;
}
}
.namerole {
grid-area: namerole;
display: grid;
gap: 0 1rem;
box-sizing: border-box;
grid-template-columns: 1fr auto;
grid-template-rows: $name-size auto;
grid-template-areas:
"displayname displayname"
"username role";
.displayname {
grid-area: displayname;
line-height: $name-size;
font-size: 1.5rem;
font-weight: bold;
}
.bot-username-wrapper {
display: flex;
gap: 0.5rem;
grid-area: username;
align-items: center;
.bot-legend-wrapper {
display: flex;
gap: 0.25rem;
align-items: center;
background: $bg;
color: $fg;
border-radius: $br;
padding: 0.1rem 0.4rem 0.2rem 0.4rem;
font-variant: small-caps;
font-weight: bold;
cursor: default;
.bot-icon {
/*
FA icon is weirdly
aligned so tweak it
*/
margin-top: 0.25rem;
}
}
.username {
min-width: 0;
line-height: $username-size;
font-size: 1rem;
font-weight: bold;
color: $fg-accent;
user-select: all;
}
}
.role {
background: $bg;
color: $fg;
border: 0.13rem solid $bg;
grid-area: role;
align-self: center;
justify-self: start;
border-radius: $br;
padding: 0.3rem;
line-height: 1.1rem;
font-size: 0.9rem;
font-variant: small-caps;
font-weight: bold;
&.admin {
color: $role-admin;
border-color: $role-admin;
}
&.moderator {
color: $role-mod;
border-color: $role-mod;
}
}
}
}
}
.profile .about-user {
flex: 35 14rem;
border-radius: $br;
overflow: hidden;
.col-header {
margin-bottom: -0.25rem;
}
dt {
font-weight: bold;
}
.fields {
background: $profile-bg;
display: flex;
flex-direction: column;
padding: 0 0.5rem;
padding-top: 0.25rem;
.field {
padding: 0.25rem;
display: flex;
flex-direction: column;
border-bottom: 0.1rem solid $gray2;
> dt, > dd {
word-break: break-word;
}
&:first-child {
border-top: 0.1rem solid $gray2;
}
}
}
.bio {
background: $profile-bg;
padding: 1rem 0.75rem;
padding-bottom: 1.25rem;
}
.accountstats {
background: $bg-accent;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
.stats-item {
display: flex;
dt {
width: 7rem;
}
}
}
}
/*
RSS icon isn't really part of the profile header exactly,
but also it sort of is, and we want it styled the same for
both microblog and gallery view anyway, so include it here.
*/
.rss-icon {
display: block;
margin: -0.25rem 0;
.fa {
font-size: 2rem;
object-fit: contain;
vertical-align: middle;
color: $orange2;
/*
Can't size a single-color background, so we use
a linear-gradient that's effectively white.
*/
background: linear-gradient(to right, $white1 100%, transparent 0) no-repeat center center;
background-size: 1.2rem 1.4rem;
/* light mode */
@media (prefers-color-scheme: light) {
background: linear-gradient(to right, $white 100%, transparent 0) no-repeat center center;
background-size: 1.2rem 1.4rem;
}
}
}
/*
Tablet-ish-kinda size.
*/
@media screen and (max-width: 750px) {
.profile .profile-header {
.basic-info {
grid-template-columns: auto 1fr;
grid-template-rows: $avatar-size $name-size auto;
grid-template-areas:
"avatar avatar"
"namerole namerole"
"namerole namerole";
/*
Make display name a bit smaller
so there's more chance of being
able to read everything.
*/
.namerole {
.displayname {
font-size: 1.2rem;
line-height: 2rem;
margin-top: 0.5rem;
}
}
}
}
}
/*
Phone-ish-kinda size.
*/
@media screen and (max-width: 500px) {
.profile
.profile-header
.basic-info
.namerole {
/*
Line up in smallest possible
horizontal space to avoid overflow.
*/
display: flex;
flex-direction: column;
gap: 0.5rem;
/*
Don't hug the right anymore
(good life advice in general).
*/
.role {
align-self: flex-start;
}
/*
Allow this to wrap in case
of a really skinny screen.
*/
.bot-username-wrapper {
flex-wrap: wrap;
}
}
}

View file

@ -0,0 +1,44 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
@import "./_media-wrapper.css";
.media {
grid-column: span 3;
display: grid;
grid-template-columns: 50% 50%;
grid-auto-rows: 10rem;
overflow: hidden;
&.single .media-wrapper {
grid-column: span 2;
}
&.odd .media-wrapper:first-child,
&.double .media-wrapper {
grid-row: span 2;
}
@media screen and (max-width: 42rem) {
.media-wrapper {
grid-column: span 2;
grid-row: span 2;
}
}
}

View file

@ -22,7 +22,7 @@
****************************************/
@import "modern-normalize/modern-normalize.css";
@import "./prism.css";
@import "./_prism.css";
/* noto-sans-regular - latin */
@font-face {

View file

@ -0,0 +1,108 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
@import "./_profile-header.css";
@import "./_media-wrapper.css";
.page {
/*
Profile gallery can be wider than default.
*/
grid-template-columns: 1fr min(95%, 65rem) 1fr;
}
.profile {
.about-user {
margin-bottom: 1rem;
.accountstats {
flex-direction: row;
justify-content: space-between;
.stats-item {
gap: 0.5rem;
width: 25%;
justify-content: space-around;
dt {
width: fit-content;
margin-left: auto;
}
dd {
margin-right: auto;
}
}
@media screen and (max-width: 750px) {
flex-direction: column;
.stats-item {
width: fit-content;
dt {
width: 7rem;
}
}
}
}
}
.media-galleries-wrapper {
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 0%;
.media-gallery {
margin-top: 0.15rem;
margin-bottom: 0.15rem;
display: grid;
gap: 0.15rem;
/* Desktop-ish width, show 3 cols of media */
grid-template-columns: repeat(3, 1fr);
@media screen and (max-width: 55rem) {
/* Tablet-ish width, switch to 2 cols */
grid-template-columns: repeat(2, 1fr);
}
@media screen and (max-width: 36rem) {
/* Mobile-ish width, switch to 1 col */
grid-template-columns: repeat(1, 1fr);
}
.media-wrapper {
aspect-ratio: 4/3;
border: 0;
border-radius: 0;
background: $bg;
}
}
.backnextlinks {
display: flex;
justify-content: space-between;
.next {
margin-left: auto;
}
}
}
}

View file

@ -17,13 +17,14 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
@import "./_profile-header.css";
.page {
/*
Profile page can be a little wider than default
page, since we're using a side-by-side column view.
*/
grid-template-columns: 1fr minmax(auto, 60rem) 1fr;
grid-template-columns: 1fr min(92%, 65rem) 1fr;
grid-template-columns: 1fr min(95%, 65rem) 1fr;
}
.profile .column-split {
@ -32,244 +33,6 @@
gap: 1rem;
}
.profile .profile-header {
background: $profile-bg;
border-radius: $br;
overflow: hidden;
margin-bottom: 1rem;
.moved-to {
padding: 1rem;
text-align: center;
}
.header-image-wrapper {
position: relative;
padding-top: 33.33%; /* aspect-ratio 1/3 */
img {
position: absolute;
top: 0;
left: 0;
right: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
}
/*
Basic info container has the user's avatar, display- and username, and role
It's partially overlapped over the header image, by a negative margin-top.
*/
$avatar-size: 8.5rem;
$name-size: 3rem;
$username-size: 2rem;
$overlap: calc($avatar-size - $name-size - $username-size);
.basic-info {
position: relative;
display: grid;
box-sizing: border-box;
grid-template-columns: $avatar-size auto 1fr;
grid-template-rows: $overlap $name-size auto;
grid-template-areas:
"avatar . ."
"avatar namerole namerole"
"avatar namerole namerole";
margin: 1rem;
margin-top: calc(-1 * $overlap);
gap: 0 1rem;
.avatar-image-wrapper {
grid-area: avatar;
border: 0.2rem solid $avatar-border;
border-radius: $br;
/*
Wrapper always same
size + proportions no
matter image inside.
*/
height: $avatar-size;
width: $avatar-size;
.avatar {
/*
Fit 100% of the wrapper.
*/
height: 100%;
width: 100%;
/*
Normalize non-square images.
*/
object-fit: cover;
/*
Prevent image extending
beyond rounded borders.
*/
border-radius: $br-inner;
}
}
.namerole {
grid-area: namerole;
display: grid;
gap: 0 1rem;
box-sizing: border-box;
grid-template-columns: 1fr auto;
grid-template-rows: $name-size auto;
grid-template-areas:
"displayname displayname"
"username role";
.displayname {
grid-area: displayname;
line-height: $name-size;
font-size: 1.5rem;
font-weight: bold;
}
.bot-username-wrapper {
display: flex;
gap: 0.5rem;
grid-area: username;
align-items: center;
.bot-legend-wrapper {
display: flex;
gap: 0.25rem;
align-items: center;
background: $bg;
color: $fg;
border-radius: $br;
padding: 0.1rem 0.4rem 0.2rem 0.4rem;
font-variant: small-caps;
font-weight: bold;
cursor: default;
.bot-icon {
/*
FA icon is weirdly
aligned so tweak it
*/
margin-top: 0.25rem;
}
}
.username {
min-width: 0;
line-height: $username-size;
font-size: 1rem;
font-weight: bold;
color: $fg-accent;
user-select: all;
}
}
.role {
background: $bg;
color: $fg;
border: 0.13rem solid $bg;
grid-area: role;
align-self: center;
justify-self: start;
border-radius: $br;
padding: 0.3rem;
line-height: 1.1rem;
font-size: 0.9rem;
font-variant: small-caps;
font-weight: bold;
&.admin {
color: $role-admin;
border-color: $role-admin;
}
&.moderator {
color: $role-mod;
border-color: $role-mod;
}
}
}
}
}
/*
Tablet-ish-kinda size.
*/
@media screen and (max-width: 750px) {
.profile .profile-header {
.basic-info {
grid-template-columns: auto 1fr;
grid-template-rows: $avatar-size $name-size auto;
grid-template-areas:
"avatar avatar"
"namerole namerole"
"namerole namerole";
/*
Make display name a bit smaller
so there's more chance of being
able to read everything.
*/
.namerole {
.displayname {
font-size: 1.2rem;
line-height: 2rem;
margin-top: 0.5rem;
}
}
}
}
}
/*
Phone-ish-kinda size.
*/
@media screen and (max-width: 500px) {
.profile
.profile-header
.basic-info
.namerole {
/*
Line up in smallest possible
horizontal space to avoid overflow.
*/
display: flex;
flex-direction: column;
gap: 0.5rem;
/*
Don't hug the right anymore
(good life advice in general).
*/
.role {
align-self: flex-start;
}
/*
Allow this to wrap in case
of a really skinny screen.
*/
.bot-username-wrapper {
flex-wrap: wrap;
}
}
}
.profile .statuses-wrapper {
flex: 65 25rem;
display: flex;
@ -283,29 +46,6 @@
flex-direction: column;
gap: 0.4rem;
.rss-icon {
display: block;
margin: -0.25rem 0;
.fa {
font-size: 2rem;
object-fit: contain;
vertical-align: middle;
color: $orange2;
/*
Can't size a single-color background, so we use
a linear-gradient that's effectively white.
*/
background: linear-gradient(to right, $white1 100%, transparent 0) no-repeat center center;
background-size: 1.2rem 1.4rem;
/* light mode */
@media (prefers-color-scheme: light) {
background: linear-gradient(to right, $white 100%, transparent 0) no-repeat center center;
background-size: 1.2rem 1.4rem;
}
}
}
.backnextlinks {
display: flex;
justify-content: space-between;
@ -315,55 +55,3 @@
}
}
}
.profile .about-user {
flex: 35 14rem;
border-radius: $br;
overflow: hidden;
.col-header {
margin-bottom: -0.25rem;
}
dt {
font-weight: bold;
}
.fields {
background: $profile-bg;
display: flex;
flex-direction: column;
padding: 0 0.5rem;
padding-top: 0.25rem;
.field {
padding: 0.25rem;
display: flex;
flex-direction: column;
border-bottom: 0.1rem solid $gray2;
> dt, > dd {
word-break: break-word;
}
&:first-child {
border-top: 0.1rem solid $gray2;
}
}
}
.bio {
background: $profile-bg;
padding: 1rem 0.75rem;
padding-bottom: 1.25rem;
}
.accountstats {
background: $bg-accent;
padding: 0.75rem;
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 1rem;
}
}

View file

@ -17,14 +17,7 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
@import "photoswipe/dist/photoswipe.css";
@import "photoswipe-dynamic-caption-plugin/photoswipe-dynamic-caption-plugin.css";
@import "plyr/dist/plyr.css";
main {
background: transparent;
grid-auto-rows: auto;
}
@import "./_status-media.css";
.status {
background: $status-bg;
@ -257,172 +250,6 @@ main {
}
}
.media {
grid-column: span 3;
display: grid;
grid-template-columns: 50% 50%;
grid-auto-rows: 10rem;
overflow: hidden;
.media-wrapper {
height: 100%;
width: 100%;
box-sizing: border-box;
border: 0.15rem solid $gray1;
border-radius: $br;
position: relative;
overflow: hidden;
z-index: 2;
details {
position: absolute;
height: 100%;
width: 100%;
&[open] summary {
height: auto;
width: auto;
margin: 1rem;
padding: 0;
.show, video, img {
display: none;
}
.eye.button .hide {
display: inline-block;
grid-column: 1 / span 3;
grid-row: 1 / span 2;
}
}
summary {
position: absolute;
height: 100%;
width: 100%;
z-index: 3;
overflow: hidden;
display: grid;
padding: 1rem;
grid-template-columns: 1fr auto 1fr;
grid-template-rows: 1fr 1fr;
grid-template-areas:
"eye sensitive ."
". sensitive .";
&::-webkit-details-marker {
display: none; /* Safari */
}
.eye.button {
grid-area: eye;
align-self: start;
justify-self: start;
margin: 0;
padding: 0.4rem;
.fa-fw {
line-height: $fa-fw;
}
.hide {
display: none;
}
}
.show.sensitive {
grid-area: sensitive;
align-self: center;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
.button {
cursor: pointer;
align-self: center;
}
}
video, img {
z-index: -1;
position: absolute;
height: calc(100% + 1.2rem);
width: calc(100% + 1.2rem);
top: -0.6rem;
left: -0.6rem;
filter: blur(1.2rem);
}
}
video.plyr-video, .plyr {
position: absolute;
height: 100%;
width: 100%;
object-fit: contain;
background: $gray1;
}
.unknown-attachment {
.placeholder {
width: 100%;
height: 100%;
padding: 0.8rem;
border: 0.2rem dashed $white2;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
color: $white2;
.placeholder-external-link {
align-self: end;
font-size: 2.5rem;
}
.placeholder-icon {
width: 100%;
font-size: 3.5rem;
text-align: center;
margin-top: auto;
}
.placeholder-link-to {
width: 100%;
text-align: center;
margin-bottom: auto;
}
}
}
}
}
&.single .media-wrapper {
grid-column: span 2;
}
&.odd .media-wrapper:first-child,
&.double .media-wrapper {
grid-row: span 2;
}
@media screen and (max-width: 42rem) {
.media-wrapper {
grid-column: span 2;
grid-row: span 2;
}
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.status-info {
background: $status-info-bg;
color: $fg-reduced;
@ -448,10 +275,6 @@ main {
gap: 0.4rem;
}
.stats-item.published-at {
text-decoration: underline;
}
.stats-item:not(.published-at):not(.edited-at) {
z-index: 1;
user-select: none;
@ -497,34 +320,3 @@ main {
}
}
}
.plyr--video {
flex-direction: column-reverse;
.plyr__video-wrapper {
position: relative;
}
.plyr__controls {
align-self: stretch;
position: initial;
padding: 0.1rem;
padding-top: 0.2rem;
}
.plyr__control {
box-shadow: none;
}
.plyr__control--overlaid {
top: calc(50% - 18px);
}
}
.pswp__content {
padding: 2rem;
.plyr {
max-height: 100%;
}
}

View file

@ -18,8 +18,8 @@
*/
.thread {
#tag-name {
/* Ensure ridiculous length tags get wrapped */
word-wrap: anywhere;
}
#tag-name {
/* Ensure ridiculous length tags get wrapped */
word-wrap: anywhere;
}
}

View file

@ -40,6 +40,9 @@ const lightbox = new PhotoswipeLightbox({
gallery: '.photoswipe-gallery',
children: '.photoswipe-slide',
pswpModule: Photoswipe,
// Bit darker than default 0.8.
bgOpacity: 0.9,
loop: false,
});
new PhotoswipeCaptionPlugin(lightbox, {
@ -71,7 +74,9 @@ lightbox.addFilter('itemData', (item) => {
}
},
width: parseInt(el.dataset.pswpWidth),
height: parseInt(el.dataset.pswpHeight)
height: parseInt(el.dataset.pswpHeight),
parentStatus: el.dataset.pswpParentStatus,
attachmentId: el.dataset.pswpAttachmentId,
};
}
return item;
@ -98,6 +103,26 @@ lightbox.on("close", function () {
}
});
lightbox.on('uiRegister', function() {
lightbox.pswp.ui.registerElement({
name: 'open-post-link',
ariaLabel: 'Open post',
order: 8,
isButton: true,
tagName: "a",
html: '<span title="Open post"><span class="sr-only">Open post</span><i class="fa fa-lg fa-external-link-square" aria-hidden="true"></i></span>',
onInit: (el, pswp) => {
el.setAttribute('target', '_blank');
el.setAttribute('rel', 'noopener');
pswp.on('change', () => {
el.href = pswp.currSlide.data.parentStatus
? pswp.currSlide.data.parentStatus
: pswp.currSlide.data.element.dataset.pswpParentStatus;
});
}
});
});
lightbox.init();
function dynamicSpoiler(className, updateFunc) {
@ -156,22 +181,40 @@ Array.from(document.getElementsByClassName("plyr-video")).forEach((video) => {
let player = new Plyr(video, {
title: video.title,
settings: ["loop"],
settings: [],
controls: ['play-large', 'play', 'progress', 'current-time', 'volume', 'mute', 'fullscreen'],
disableContextMenu: false,
hideControls: false,
tooltips: { contrors: true, seek: true },
tooltips: { controls: true, seek: true },
iconUrl: "/assets/plyr.svg",
invertTime: false,
listeners: {
fullscreen: () => {
if (player.playing) {
setTimeout(() => {
player.play();
}, 1);
// Check if the photoswipe lightbox is
// open with this as the current slide.
const alreadyInLightbox = (
lightbox.pswp !== undefined &&
video.dataset.pswpAttachmentId === lightbox.pswp.currSlide.data.attachmentId
);
if (alreadyInLightbox) {
// If this video is already open as the
// current photoswipe slide, the fullscreen
// button toggles proper fullscreen.
player.fullscreen.toggle();
} else {
// Otherwise the fullscreen button opens
// the video as current photoswipe slide.
//
// (Don't pause the video while it's
// being transitioned to a slide.)
if (player.playing) {
setTimeout(() => player.play(), 1);
}
lightbox.loadAndOpen(parseInt(video.dataset.pswpIndex), {
gallery: video.closest(".photoswipe-gallery")
});
}
lightbox.loadAndOpen(parseInt(video.dataset.pswpIndex), {
gallery: video.closest(".photoswipe-gallery")
});
return false;
}
}

View file

@ -24,7 +24,7 @@
"object-to-formdata": "^4.4.2",
"papaparse": "^5.3.2",
"parse-link-header": "^2.0.0",
"photoswipe": "^5.3.3",
"photoswipe": "^5.4.4",
"photoswipe-dynamic-caption-plugin": "^1.2.7",
"plyr": "^3.7.8",
"psl": "^1.9.0",

View file

@ -79,6 +79,8 @@ export interface AccountSource {
privacy: string;
sensitive: boolean;
status_content_type: string;
web_visibility: string;
web_layout: string;
}
export interface SearchAccountParams {

View file

@ -61,20 +61,6 @@ interface UserProfileFormProps {
}
function UserProfileForm({ data: profile }: UserProfileFormProps) {
/*
User profile update form keys
- bool bot
- bool locked
- string display_name
- string note
- file avatar
- file header
- bool enable_rss
- bool hide_collections
- string custom_css (if enabled)
- string theme
*/
const { data: instance } = useInstanceV1Query();
const instanceConfig = React.useMemo(() => {
return {
@ -120,7 +106,8 @@ function UserProfileForm({ data: profile }: UserProfileFormProps) {
discoverable: useBoolInput("discoverable", { source: profile}),
enableRSS: useBoolInput("enable_rss", { source: profile }),
hideCollections: useBoolInput("hide_collections", { source: profile }),
webVisibility: useTextInput("web_visibility", { source: profile, valueSelector: (p) => p.source?.web_visibility }),
webVisibility: useTextInput("web_visibility", { source: profile, valueSelector: (p: Account) => p.source?.web_visibility }),
webLayout: useTextInput("web_layout", { source: profile, valueSelector: (p: Account) => p.source?.web_layout }),
fields: useFieldArrayInput("fields_attributes", {
defaultValue: profile?.source?.fields,
length: instanceConfig.maxPinnedFields
@ -185,18 +172,24 @@ function UserProfileForm({ data: profile }: UserProfileFormProps) {
/>
</fieldset>
<div className="theme">
<div>
<b id="theme-label">Theme</b>
<br/>
<span>After choosing theme and saving, <a href={profile.url} target="_blank">open your profile</a> and refresh to see changes.</span>
</div>
<Select
aria-labelledby="theme-label"
field={form.theme}
options={<>{themeOptions}</>}
/>
</div>
<span>After choosing theme or layout and saving, <a href={profile.url} target="_blank">open your profile</a> and refresh to see changes.</span>
<Select
label="Theme for the web view of your profile"
field={form.theme}
options={<>{themeOptions}</>}
/>
<Select
field={form.webLayout}
label="Layout for the web view of your profile"
options={
<>
<option value="microblog">Classic microblog layout (show recent + pinned posts; media shown alongside its parent post)</option>
<option value="gallery">'Gram-style gallery layout (show recent + pinned media; parent posts still accessible by link)</option>
</>
}
/>
</div>
<div className="form-section-docs">

View file

@ -5399,10 +5399,10 @@ photoswipe-dynamic-caption-plugin@^1.2.7:
resolved "https://registry.yarnpkg.com/photoswipe-dynamic-caption-plugin/-/photoswipe-dynamic-caption-plugin-1.2.7.tgz#53aa5059f1c4dccc8aa36196ff3e09baa5e537c2"
integrity sha512-5XXdXLf2381nwe7KqQvcyStiUBi9TitYXppUQTrzPwYAi4lZsmWNnNKMclM7I4QGlX6fXo42v3bgb6rlK9pY1Q==
photoswipe@^5.3.3:
version "5.4.2"
resolved "https://registry.yarnpkg.com/photoswipe/-/photoswipe-5.4.2.tgz#bed976c27f876bd9c86085a022701a8cea484f7e"
integrity sha512-z5hr36nAIPOZbHJPbCJ/mQ3+ZlizttF9za5gKXKH/us1k4KNHaRbC63K1Px5sVVKUtGb/2+ixHpKqtwl0WAwvA==
photoswipe@^5.4.4:
version "5.4.4"
resolved "https://registry.yarnpkg.com/photoswipe/-/photoswipe-5.4.4.tgz#e045dc036453493188d5c8665b0e8f1000ac4d6e"
integrity sha512-WNFHoKrkZNnvFFhbHL93WDkW3ifwVOXSW3w1UuZZelSmgXpIGiZSNlZJq37rR8YejqME2rHs9EhH9ZvlvFH2NA==
picocolors@^1.0.0:
version "1.0.0"

View file

@ -0,0 +1,87 @@
{{- /*
// 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/>.
*/ -}}
{{- with . }}
<main class="profile h-card">
{{- with . }}
{{- include "profile_header.tmpl" . | indent 1 }}
{{- end }}
{{- with . }}
{{- include "profile_about_user.tmpl" . | indent 1 }}
{{- end }}
<div
class="media-galleries-wrapper"
role="region"
aria-label="Media by {{ .account.Username -}}"
>
{{- if .pinned_statuses }}
<section class="pinned h-feed" aria-labelledby="pinned">
<div class="col-header">
<h3 class="p-name" id="pinned">Pinned media</h3>
<a href="#recent">jump to recent</a>
</div>
<div
class="media-gallery photoswipe-gallery"
role="group"
>
{{- range $index, $attachment := .pinnedGalleryItems }}
{{- includeIndex "status_attachment.tmpl" $attachment $index | indent 4 }}
{{- end }}
</div>
</section>
{{- end }}
<section class="recent h-feed" aria-labelledby="recent">
<div class="col-header">
<h3 id="recent p-name" tabindex="-1">Recent media</h3>
{{- if .rssFeed }}
<a href="{{- .rssFeed -}}" class="rss-icon" aria-label="RSS feed">
<i class="fa fa-rss-square" aria-hidden="true"></i>
</a>
{{- end }}
</div>
{{- if not .galleryItems }}
<div data-nosnippet class="nothinghere">
{{- if .show_back_to_top }}
Reached the end of visible media!
{{- else }}
Nothing to see here! {{ .account.Username }} has either not posted any public media yet, or has opted not to make posts visible via the World Wide Web.
{{- end }}
</div>
{{- else }}
<div
class="media-gallery photoswipe-gallery"
role="group"
>
{{- range $index, $attachment := .galleryItems }}
{{- includeIndex "status_attachment.tmpl" $attachment $index | indent 4 }}
{{- end }}
</div>
{{- end }}
<nav class="backnextlinks">
{{- if .show_back_to_top }}
<a href="/@{{- .account.Username -}}">Back to top</a>
{{- end }}
{{- if .statuses_next }}
<a href="{{- .statuses_next -}}" class="next">Show older</a>
{{- end }}
</nav>
</section>
</div>
</main>
{{- end }}

View file

@ -17,200 +17,15 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ -}}
{{- define "profileMovedTo" -}}
{{- with .account.Moved }}
<div class="moved-to">
<b>
This account has permanently moved to
<a
href="{{ .URL }}"
class="nounderline"
rel="nofollow noreferrer noopener"
target="_blank"
>
@{{ .Username }}
</a>
</b>
</div>
{{- end }}
{{- end -}}
{{- define "defaultAvatarDimension" -}}
{{- /* 136 is the default width/height for 8.5rem avatars, double it to get a good look when expanded. */ -}}
272
{{- end -}}
{{- define "avatarWidth" -}}
{{- with .account }}
{{- if isNil .AvatarAttachment -}}
{{- template "defaultAvatarDimension" . -}}
{{- else -}}
{{- /* Use the avatar's proper dimensions. */ -}}
{{- .AvatarAttachment.Meta.Original.Width -}}
{{- end -}}
{{- end }}
{{- end -}}
{{- define "avatarHeight" -}}
{{- with .account }}
{{- if isNil .AvatarAttachment -}}
{{- template "defaultAvatarDimension" . -}}
{{- else -}}
{{- /* Use the avatar's proper dimensions. */ -}}
{{- .AvatarAttachment.Meta.Original.Height -}}
{{- end -}}
{{- end }}
{{- end -}}
{{- define "avatarAlt" -}}
Avatar for {{ .account.Username -}}
{{- if .account.AvatarDescription }}
{{- /* Add the avatar's image description. */ -}}
: {{ .account.AvatarDescription -}}
{{- end -}}
{{- end -}}
{{- define "headerAlt" -}}
Header for {{ .account.Username -}}
{{- if .account.HeaderDescription }}
{{- /* Add the header's image description. */ -}}
: {{ .account.HeaderDescription -}}
{{- end -}}
{{- end -}}
{{- define "avatar" -}}
{{- with . }}
<div
class="media photoswipe-gallery odd single avatar-image-wrapper"
role="group"
>
<a
class="photoswipe-slide"
href="{{- .account.Avatar -}}"
target="_blank"
data-pswp-width="{{- template "avatarWidth" . -}}px"
data-pswp-height="{{- template "avatarHeight" . -}}px"
data-cropped="true"
alt="{{- template "avatarAlt" . -}}"
title="{{- template "avatarAlt" . -}}"
>
<picture
aria-hidden="true"
>
{{- if .account.AvatarAttachment }}
<source
class="avatar"
srcset="{{- .account.AvatarStatic -}}"
type="{{- .account.AvatarAttachment.PreviewMIMEType -}}"
media="(prefers-reduced-motion: reduce)"
/>
{{- end }}
<img
class="avatar u-photo"
src="{{- .account.Avatar -}}"
alt="{{- template "avatarAlt" . -}}"
title="{{- template "avatarAlt" . -}}"
width="{{- template "avatarWidth" . -}}"
height="{{- template "avatarHeight" . -}}"
/>
</picture>
</a>
</div>
{{- end }}
{{- end -}}
{{- with . }}
<main class="profile h-card">
<h2 class="sr-only">Profile for {{ .account.Username -}}</h2>
<section class="profile-header" role="region" aria-label="Basic info">
{{- if .account.Moved }}
{{- include "profileMovedTo" . | indent 2 }}
{{- end }}
<div class="header-image-wrapper">
<picture>
{{- if .account.HeaderAttachment }}
<source
srcset="{{- .account.HeaderStatic -}}"
type="{{- .account.HeaderAttachment.PreviewMIMEType -}}"
media="(prefers-reduced-motion: reduce)"
/>
{{- end }}
<img
src="{{- .account.Header -}}"
alt="{{- template "headerAlt" . -}}"
title="{{- template "headerAlt" . -}}"
/>
</picture>
</div>
<div class="basic-info">
{{- with . }}
{{- include "avatar" . | indent 3 }}
{{- end }}
<dl class="namerole">
<dt class="sr-only">Display name</dt>
<dd class="displayname text-cutoff p-name">
{{- if .account.DisplayName -}}
{{- emojify .account.Emojis (escape .account.DisplayName) -}}
{{- else -}}
{{- .account.Username -}}
{{- end -}}
</dd>
<div class="bot-username-wrapper">
{{- if .account.Bot }}
<dt class="sr-only">Bot account</dt>
<dd>
<span class="sr-only">true</span>
<div
class="bot-legend-wrapper"
aria-hidden="true"
title="This is a bot account."
>
<i class="bot-icon fa fa-microchip"></i>
<span class="bot-legend">bot</span>
</div>
</dd>
{{- end }}
<dt class="sr-only">Username</dt>
<dd class="username text-cutoff p-nickname">@{{- .account.Username -}}@{{- .instance.AccountDomain -}}</dd>
</div>
{{- if .account.Roles }}
<dt class="sr-only">Role</dt>
{{- range .account.Roles }}
<dd class="role {{ .Name -}}">{{- .Name -}}</dd>
{{- end }}
{{- end }}
</dl>
<a class="u-url u-uid hidden" rel="me" href="/@{{- .account.Username -}}"></a>
</div>
</section>
{{- with . }}
{{- include "profile_header.tmpl" . | indent 1 }}
{{- end }}
<div class="column-split">
<section class="about-user" role="region" aria-labelledby="about-header">
<div class="col-header">
<h3 id="about-header">About<span class="sr-only">&nbsp;{{- .account.Username -}}</span></h3>
</div>
{{- if .account.Fields }}
{{- include "profile_fields.tmpl" . | indent 3 }}
{{- end }}
<h4 class="sr-only">Bio</h4>
<div class="bio p-note">
{{- if .account.Note }}
{{ emojify .account.Emojis (noescape .account.Note) }}
{{- else }}
<p>This GoToSocial user hasn't written a bio yet!</p>
{{- end }}
</div>
<h4 class="sr-only">Stats</h4>
<dl class="accountstats">
<dt>Joined</dt>
<dd><time datetime="{{- .account.CreatedAt -}}">{{- .account.CreatedAt | timestampVague -}}</time></dd>
<dt>Posts</dt>
<dd>{{- .account.StatusesCount -}}</dd>
<dt>Followed by</dt>
<dd>{{- if .account.HideCollections -}}<i>hidden</i>{{- else -}}{{- .account.FollowersCount -}}{{- end -}}</dd>
<dt>Following</dt>
<dd>{{- if .account.HideCollections -}}<i>hidden</i>{{- else -}}{{- .account.FollowingCount -}}{{- end -}}</dd>
</dl>
</section>
{{- with . }}
{{- include "profile_about_user.tmpl" . | indent 2 }}
{{- end }}
<div class="statuses-wrapper" role="region" aria-label="Posts by {{ .account.Username -}}">
{{- if .pinned_statuses }}
<section class="pinned statuses h-feed" aria-labelledby="pinned">

View file

@ -0,0 +1,56 @@
{{- /*
// 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/>.
*/ -}}
{{- with . }}
<section class="about-user" role="region" aria-labelledby="about-header">
<div class="col-header">
<h3 id="about-header">About<span class="sr-only">&nbsp;{{- .account.Username -}}</span></h3>
</div>
{{- if .account.Fields }}
{{- include "profile_fields.tmpl" . | indent 1 }}
{{- end }}
<h4 class="sr-only">Bio</h4>
<div class="bio p-note">
{{- if .account.Note }}
{{ emojify .account.Emojis (noescape .account.Note) }}
{{- else }}
<p>This GoToSocial user hasn't written a bio yet!</p>
{{- end }}
</div>
<h4 class="sr-only">Stats</h4>
<dl class="accountstats">
<div class="stats-item">
<dt class="joineddt text-cutoff">Joined</dt>
<dd class="joineddd text-cutoff"><time datetime="{{- .account.CreatedAt -}}">{{- .account.CreatedAt | timestampVague -}}</time></dd>
</div>
<div class="stats-item">
<dt class="postsdt text-cutoff">Posts</dt>
<dd class="postsdd text-cutoff">{{- .account.StatusesCount -}}</dd>
</div>
<div class="stats-item">
<dt class="followeddt text-cutoff">Followed by</dt>
<dd class="followeddd text-cutoff">{{- if .account.HideCollections -}}<i>hidden</i>{{- else -}}{{- .account.FollowersCount -}}{{- end -}}</dd>
</div>
<div class="stats-item">
<dt class="followingdt text-cutoff">Following</dt>
<dd class="followingdd text-cutoff">{{- if .account.HideCollections -}}<i>hidden</i>{{- else -}}{{- .account.FollowingCount -}}{{- end -}}</dd>
</div>
</dl>
</section>
{{- end }}

View file

@ -0,0 +1,185 @@
{{- /*
// 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/>.
*/ -}}
{{- define "profileMovedTo" -}}
{{- with .account.Moved }}
<div class="moved-to">
<b>
This account has permanently moved to
<a
href="{{ .URL }}"
class="nounderline"
rel="nofollow noreferrer noopener"
target="_blank"
>
@{{ .Username }}
</a>
</b>
</div>
{{- end }}
{{- end -}}
{{- define "defaultAvatarDimension" -}}
{{- /* 136 is the default width/height for 8.5rem avatars, double it to get a good look when expanded. */ -}}
272
{{- end -}}
{{- define "avatarWidth" -}}
{{- with .account }}
{{- if isNil .AvatarAttachment -}}
{{- template "defaultAvatarDimension" . -}}
{{- else -}}
{{- /* Use the avatar's proper dimensions. */ -}}
{{- .AvatarAttachment.Meta.Original.Width -}}
{{- end -}}
{{- end }}
{{- end -}}
{{- define "avatarHeight" -}}
{{- with .account }}
{{- if isNil .AvatarAttachment -}}
{{- template "defaultAvatarDimension" . -}}
{{- else -}}
{{- /* Use the avatar's proper dimensions. */ -}}
{{- .AvatarAttachment.Meta.Original.Height -}}
{{- end -}}
{{- end }}
{{- end -}}
{{- define "avatarAlt" -}}
Avatar for {{ .account.Username -}}
{{- if .account.AvatarDescription }}
{{- /* Add the avatar's image description. */ -}}
: {{ .account.AvatarDescription -}}
{{- end -}}
{{- end -}}
{{- define "headerAlt" -}}
Header for {{ .account.Username -}}
{{- if .account.HeaderDescription }}
{{- /* Add the header's image description. */ -}}
: {{ .account.HeaderDescription -}}
{{- end -}}
{{- end -}}
{{- define "avatar" -}}
{{- with . }}
<div
class="photoswipe-gallery odd single avatar-image-wrapper"
role="group"
>
<a
class="photoswipe-slide"
href="{{- .account.Avatar -}}"
target="_blank"
data-pswp-width="{{- template "avatarWidth" . -}}px"
data-pswp-height="{{- template "avatarHeight" . -}}px"
data-cropped="true"
alt="{{- template "avatarAlt" . -}}"
title="{{- template "avatarAlt" . -}}"
>
<picture
aria-hidden="true"
>
{{- if .account.AvatarAttachment }}
<source
class="avatar"
srcset="{{- .account.AvatarStatic -}}"
type="{{- .account.AvatarAttachment.PreviewMIMEType -}}"
media="(prefers-reduced-motion: reduce)"
/>
{{- end }}
<img
class="avatar u-photo"
src="{{- .account.Avatar -}}"
alt="{{- template "avatarAlt" . -}}"
title="{{- template "avatarAlt" . -}}"
width="{{- template "avatarWidth" . -}}"
height="{{- template "avatarHeight" . -}}"
/>
</picture>
</a>
</div>
{{- end }}
{{- end -}}
{{- with . }}
<h2 class="sr-only">Profile for {{ .account.Username -}}</h2>
<section class="profile-header" role="region" aria-label="Basic info">
{{- if .account.Moved }}
{{- include "profileMovedTo" . | indent 2 }}
{{- end }}
<div class="header-image-wrapper">
<picture>
{{- if .account.HeaderAttachment }}
<source
srcset="{{- .account.HeaderStatic -}}"
type="{{- .account.HeaderAttachment.PreviewMIMEType -}}"
media="(prefers-reduced-motion: reduce)"
/>
{{- end }}
<img
src="{{- .account.Header -}}"
alt="{{- template "headerAlt" . -}}"
title="{{- template "headerAlt" . -}}"
/>
</picture>
</div>
<div class="basic-info">
{{- with . }}
{{- include "avatar" . | indent 3 }}
{{- end }}
<dl class="namerole">
<dt class="sr-only">Display name</dt>
<dd class="displayname text-cutoff p-name">
{{- if .account.DisplayName -}}
{{- emojify .account.Emojis (escape .account.DisplayName) -}}
{{- else -}}
{{- .account.Username -}}
{{- end -}}
</dd>
<div class="bot-username-wrapper">
{{- if .account.Bot }}
<dt class="sr-only">Bot account</dt>
<dd>
<span class="sr-only">true</span>
<div
class="bot-legend-wrapper"
aria-hidden="true"
title="This is a bot account."
>
<i class="bot-icon fa fa-microchip"></i>
<span class="bot-legend">bot</span>
</div>
</dd>
{{- end }}
<dt class="sr-only">Username</dt>
<dd class="username text-cutoff p-nickname">@{{- .account.Username -}}@{{- .instance.AccountDomain -}}</dd>
</div>
{{- if .account.Roles }}
<dt class="sr-only">Role</dt>
{{- range .account.Roles }}
<dd class="role {{ .Name -}}">{{- .Name -}}</dd>
{{- end }}
{{- end }}
</dl>
<a class="u-url u-uid hidden" rel="me" href="/@{{- .account.Username -}}"></a>
</div>
</section>
{{- end }}

View file

@ -30,6 +30,16 @@
it in an appropriate <article></article>!
*/ -}}
{{- /* Produces something like "1 attachment", "2 attachments", etc */ -}}
{{- define "attachmentsLength" -}}
{{- (len .) }}{{- if eq (len .) 1 }} attachment{{- else }} attachments{{- end -}}
{{- end -}}
{{- /* Produces something like "media photoswipe-gallery odd single" */ -}}
{{- define "galleryClass" -}}
media photoswipe-gallery {{ (len .) | oddOrEven }} {{ if eq (len .) 1 }}single{{ else if eq (len .) 2 }}double{{ end }}
{{- end -}}
{{- with . }}
<header class="status-header">
{{- include "status_header.tmpl" . | indent 1 }}
@ -63,7 +73,15 @@
</div>
{{- end }}
{{- if .MediaAttachments }}
{{- include "status_attachments.tmpl" . | indent 1 }}
<div
class="{{- template "galleryClass" .MediaAttachments -}}"
role="group"
aria-label="{{- template "attachmentsLength" .MediaAttachments -}}"
>
{{- range $index, $media := .MediaAttachments }}
{{- includeIndex "status_attachment.tmpl" $media $index | indent 2 }}
{{- end }}
</div>
{{- end }}
</div>
<aside class="status-info" aria-hidden="true">

View file

@ -0,0 +1,179 @@
{{- /*
// 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/>.
*/ -}}
{{- define "imagePreview" }}
<img
src="{{- .PreviewURL -}}"
loading="lazy"
{{- if .Description }}
alt="{{- .Description -}}"
title="{{- .Description -}}"
{{- end }}
width="{{- .Meta.Original.Width -}}"
height="{{- .Meta.Original.Height -}}"
/>
{{- end }}
{{- define "videoPreview" }}
<img
src="{{- .PreviewURL -}}"
loading="lazy"
{{- if .Description }}
alt="{{- .Description -}}"
title="{{- .Description -}}"
{{- end }}
width="{{- .Meta.Small.Width -}}"
height="{{- .Meta.Small.Height -}}"
/>
{{- end }}
{{- define "audioPreview" }}
{{- if and .PreviewURL .Meta.Small.Width }}
<img
src="{{- .PreviewURL -}}"
loading="lazy"
{{- if .Description }}
alt="{{- .Description -}}"
title="{{- .Description -}}"
{{- end }}
width="{{- .Meta.Small.Width -}}"
height="{{- .Meta.Small.Height -}}"
/>
{{- else }}
<img
src="/assets/logo.webp"
loading="lazy"
{{- if .Description }}
alt="{{- .Description -}}"
title="{{- .Description -}}"
{{- end }}
width="518"
height="460"
/>
{{- end }}
{{- end }}
{{- with . }}
<div class="media-wrapper">
<details class="{{- .Item.Type -}}-spoiler media-spoiler" {{- if not .Item.Sensitive }} open{{- end -}}>
<summary>
<div class="show sensitive button" aria-hidden="true">Show sensitive</div>
<span class="eye button" role="button" tabindex="0" aria-label="Toggle media">
<i class="hide fa fa-fw fa-eye-slash" aria-hidden="true"></i>
<i class="show fa fa-fw fa-eye" aria-hidden="true"></i>
</span>
{{- if or (eq .Item.Type "video") (eq .Item.Type "gifv") }}
{{- include "videoPreview" .Item | indent 3 }}
{{- else if eq .Item.Type "image" }}
{{- include "imagePreview" .Item | indent 3 }}
{{- else if eq .Item.Type "audio" }}
{{- include "audioPreview" .Item | indent 3 }}
{{- end }}
</summary>
{{- if or (eq .Item.Type "video") (eq .Item.Type "gifv") }}
<video
{{- if eq .Item.Type "video" }}
preload="none"
{{- else }}
preload="auto"
muted
{{- end }}
class="plyr-video photoswipe-slide{{- if eq .Item.Type "gifv" }} gifv{{ end }}"
controls
playsinline
data-pswp-index="{{- .Index -}}"
data-pswp-parent-status="{{- .Item.ParentStatusLink -}}"
data-pswp-attachment-id="{{- .Item.ID -}}"
poster="{{- .Item.PreviewURL -}}"
data-pswp-width="{{- .Item.Meta.Original.Width -}}px"
data-pswp-height="{{- .Item.Meta.Original.Height -}}px"
{{- if .Item.Description }}
alt="{{- .Item.Description -}}"
title="{{- .Item.Description -}}"
{{- end }}
>
<source type="{{- .Item.MIMEType -}}" src="{{- .Item.URL -}}"/>
</video>
{{- else if eq .Item.Type "audio" }}
<video
preload="none"
class="plyr-video photoswipe-slide"
controls
playsinline
data-pswp-index="{{- .Index -}}"
data-pswp-parent-status="{{- .Item.ParentStatusLink -}}"
data-pswp-attachment-id="{{- .Item.ID -}}"
{{- if and .Item.PreviewURL .Item.Meta.Small.Width }}
poster="{{- .Item.PreviewURL -}}"
data-pswp-width="{{- .Item.Meta.Small.Width -}}px"
data-pswp-height="{{- .Item.Meta.Small.Height -}}px"
{{- else }}
poster="/assets/logo.webp"
width="518px"
height="460px"
{{- end }}
{{- if .Item.Description }}
alt="{{- .Item.Description -}}"
title="{{- .Item.Description -}}"
{{- end }}
>
<source type="{{- .Item.MIMEType -}}" src="{{- .Item.URL -}}"/>
</video>
{{- else if eq .Item.Type "image" }}
<a
class="photoswipe-slide"
data-pswp-index="{{- .Index -}}"
data-pswp-parent-status="{{- .Item.ParentStatusLink -}}"
data-pswp-attachment-id="{{- .Item.ID -}}"
href="{{- .Item.URL -}}"
target="_blank"
data-pswp-width="{{- .Item.Meta.Original.Width -}}px"
data-pswp-height="{{- .Item.Meta.Original.Height -}}px"
data-cropped="true"
{{- if .Item.Description }}
alt="{{- .Item.Description -}}"
title="{{- .Item.Description -}}"
{{- end }}
>
{{- with .Item }}
{{- include "imagePreview" . | indent 3 }}
{{- end }}
</a>
{{- else }}
<a
class="unknown-attachment"
href="{{- .Item.RemoteURL -}}"
rel="nofollow noreferrer noopener"
target="_blank"
{{- if .Item.Description }}
title="Open external media: {{ .Item.Description -}}&#10;&#13;{{- .Item.RemoteURL -}}"
{{- else }}
title="Open external media.&#10;&#13;{{- .Item.RemoteURL -}}"
{{- end }}
>
<div class="placeholder" aria-hidden="true">
<i class="placeholder-external-link fa fa-external-link"></i>
<i class="placeholder-icon fa fa-file-text"></i>
<div class="placeholder-link-to">External media</div>
</div>
</a>
{{- end }}
</details>
</div>
{{- end }}

View file

@ -1,195 +0,0 @@
{{- /*
// 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/>.
*/ -}}
{{- /*
Template for rendering a gallery of status media attachments.
To use this template, pass a web view status into it.
*/ -}}
{{- define "imagePreview" }}
<img
src="{{- .PreviewURL -}}"
loading="lazy"
{{- if .Description }}
alt="{{- .Description -}}"
title="{{- .Description -}}"
{{- end }}
width="{{- .Meta.Original.Width -}}"
height="{{- .Meta.Original.Height -}}"
/>
{{- end }}
{{- define "videoPreview" }}
<img
src="{{- .PreviewURL -}}"
loading="lazy"
{{- if .Description }}
alt="{{- .Description -}}"
title="{{- .Description -}}"
{{- end }}
width="{{- .Meta.Small.Width -}}"
height="{{- .Meta.Small.Height -}}"
/>
{{- end }}
{{- define "audioPreview" }}
{{- if and .PreviewURL .Meta.Small.Width }}
<img
src="{{- .PreviewURL -}}"
loading="lazy"
{{- if .Description }}
alt="{{- .Description -}}"
title="{{- .Description -}}"
{{- end }}
width="{{- .Meta.Small.Width -}}"
height="{{- .Meta.Small.Height -}}"
/>
{{- else }}
<img
src="/assets/logo.webp"
loading="lazy"
{{- if .Description }}
alt="{{- .Description -}}"
title="{{- .Description -}}"
{{- end }}
width="518"
height="460"
/>
{{- end }}
{{- end }}
{{- /* Produces something like "1 attachment", "2 attachments", etc */ -}}
{{- define "attachmentsLength" -}}
{{- (len .) }}{{- if eq (len .) 1 }} attachment{{- else }} attachments{{- end -}}
{{- end -}}
{{- /* Produces something like "media photoswipe-gallery odd single" */ -}}
{{- define "galleryClass" -}}
media photoswipe-gallery {{ (len .) | oddOrEven }} {{ if eq (len .) 1 }}single{{ else if eq (len .) 2 }}double{{ end }}
{{- end -}}
{{- with .MediaAttachments }}
<div
class="{{- template "galleryClass" . -}}"
role="group"
aria-label="{{- template "attachmentsLength" . -}}"
>
{{- range $index, $media := . }}
<div class="media-wrapper">
<details class="{{- $media.Type -}}-spoiler media-spoiler" {{- if not $media.Sensitive }} open{{- end -}}>
<summary>
<div class="show sensitive button" aria-hidden="true">Show sensitive media</div>
<span class="eye button" role="button" tabindex="0" aria-label="Toggle media">
<i class="hide fa fa-fw fa-eye-slash" aria-hidden="true"></i>
<i class="show fa fa-fw fa-eye" aria-hidden="true"></i>
</span>
{{- if or (eq .Type "video") (eq .Type "gifv") }}
{{- include "videoPreview" $media | indent 4 }}
{{- else if eq .Type "image" }}
{{- include "imagePreview" $media | indent 4 }}
{{- else if eq .Type "audio" }}
{{- include "audioPreview" $media | indent 4 }}
{{- end }}
</summary>
{{- if or (eq .Type "video") (eq .Type "gifv") }}
<video
{{- if eq .Type "video" }}
preload="none"
{{- else }}
preload="auto"
muted
{{- end }}
class="plyr-video photoswipe-slide{{- if eq .Type "gifv" }} gifv{{ end }}"
controls
playsinline
data-pswp-index="{{- $index -}}"
poster="{{- .PreviewURL -}}"
data-pswp-width="{{- $media.Meta.Small.Width -}}px"
data-pswp-height="{{- $media.Meta.Small.Height -}}px"
{{- if .Description }}
alt="{{- $media.Description -}}"
title="{{- $media.Description -}}"
{{- end }}
>
<source type="{{- $media.MIMEType -}}" src="{{- $media.URL -}}"/>
</video>
{{- else if eq .Type "audio" }}
<video
preload="none"
class="plyr-video photoswipe-slide"
controls
playsinline
data-pswp-index="{{- $index -}}"
{{- if and $media.PreviewURL $media.Meta.Small.Width }}
poster="{{- .PreviewURL -}}"
data-pswp-width="{{- $media.Meta.Small.Width -}}px"
data-pswp-height="{{- $media.Meta.Small.Height -}}px"
{{- else }}
poster="/assets/logo.webp"
width="518px"
height="460px"
{{- end }}
{{- if .Description }}
alt="{{- $media.Description -}}"
title="{{- $media.Description -}}"
{{- end }}
>
<source type="{{- $media.MIMEType -}}" src="{{- $media.URL -}}"/>
</video>
{{- else if eq .Type "image" }}
<a
class="photoswipe-slide"
href="{{- $media.URL -}}"
target="_blank"
data-pswp-width="{{- $media.Meta.Original.Width -}}px"
data-pswp-height="{{- $media.Meta.Original.Height -}}px"
data-cropped="true"
{{- if .Description }}
alt="{{- $media.Description -}}"
title="{{- $media.Description -}}"
{{- end }}
>
{{- with $media }}
{{- include "imagePreview" . | indent 4 }}
{{- end }}
</a>
{{- else }}
<a
class="unknown-attachment"
href="{{- $media.RemoteURL -}}"
rel="nofollow noreferrer noopener"
target="_blank"
{{- if .Description }}
title="Open external media: {{ $media.Description -}}&#10;&#13;{{- $media.RemoteURL -}}"
{{- else }}
title="Open external media.&#10;&#13;{{- $media.RemoteURL -}}"
{{- end }}
>
<div class="placeholder" aria-hidden="true">
<i class="placeholder-external-link fa fa-external-link"></i>
<i class="placeholder-icon fa fa-file-text"></i>
<div class="placeholder-link-to">External media</div>
</div>
</a>
{{- end }}
</details>
</div>
{{- end }}
</div>
{{- end }}

View file

@ -17,36 +17,9 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ -}}
{{- define "visibility_icon" -}}
{{- if eq .Visibility "public" -}}
globe
{{- else if eq .Visibility "unlisted" -}}
unlock
{{- else -}}
question
{{- end -}}
{{- end -}}
{{- define "visibility_title" -}}
{{- if eq .Visibility "public" -}}
Public
{{- else if eq .Visibility "unlisted" -}}
Unlisted
{{- else -}}
Unknown
{{- end -}}
{{- end -}}
{{- with . }}
<dl class="status-stats">
<div class="stats-grouping">
<div class="stats-item visibility-level" title="{{- template "visibility_title" . -}}">
<dt class="sr-only">Visibility</dt>
<dd>
<i class="fa fa-{{- template "visibility_icon" . -}}" aria-hidden="true"></i>
<span class="sr-only">{{- template "visibility_title" . -}}</span>
</dd>
</div>
<div class="stats-item published-at text-cutoff">
<dt class="sr-only">Published</dt>
<dd>
@ -57,7 +30,7 @@
<div class="stats-item edited-at text-cutoff">
<dt class="sr-only">Edited</dt>
<dd>
edited <time class="dt-updated" datetime="{{- .EditedAt -}}">{{- .EditedAt | timestampPrecise -}}</time>
(edited <time class="dt-updated" datetime="{{- .EditedAt -}}">{{- .EditedAt | timestampPrecise -}}</time>)
</dd>
</div>
{{ end }}