diff options
-rw-r--r-- | docs/api/swagger.yaml | 30 | ||||
-rw-r--r-- | internal/api/client/accounts/accountupdate.go | 14 | ||||
-rw-r--r-- | internal/api/client/admin/accountsgetv2_test.go | 3 | ||||
-rw-r--r-- | internal/api/client/statuses/statushistory_test.go | 2 | ||||
-rw-r--r-- | internal/api/client/statuses/statusmute_test.go | 4 | ||||
-rw-r--r-- | internal/api/model/account.go | 21 | ||||
-rw-r--r-- | internal/processing/account/get.go | 111 | ||||
-rw-r--r-- | internal/processing/account/update.go | 36 | ||||
-rw-r--r-- | internal/typeutils/internaltofrontend.go | 90 | ||||
-rw-r--r-- | internal/typeutils/internaltofrontend_test.go | 10 | ||||
-rw-r--r-- | internal/web/profile.go | 11 | ||||
-rw-r--r-- | internal/web/thread.go | 11 | ||||
-rw-r--r-- | testrig/testmodels.go | 2 | ||||
-rw-r--r-- | web/source/css/profile.css | 29 | ||||
-rw-r--r-- | web/source/settings/components/profile.tsx | 8 | ||||
-rw-r--r-- | web/source/settings/style.css | 23 | ||||
-rw-r--r-- | web/source/settings/views/user/profile.tsx | 44 | ||||
-rw-r--r-- | web/template/profile.tmpl | 86 |
18 files changed, 395 insertions, 140 deletions
diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index 367dae72f..4ce234374 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -193,6 +193,11 @@ definitions: example: https://example.org/media/some_user/avatar/original/avatar.jpeg type: string x-go-name: Avatar + avatar_description: + description: Description of this account's avatar, for alt text. + example: A cute drawing of a smiling sloth. + type: string + x-go-name: AvatarDescription avatar_static: description: |- Web location of a static version of the account's avatar. @@ -259,6 +264,11 @@ definitions: example: https://example.org/media/some_user/header/original/header.jpeg type: string x-go-name: Header + header_description: + description: Description of this account's header, for alt text. + example: A sunlit field with purple flowers. + type: string + x-go-name: HeaderDescription header_static: description: |- Web location of a static version of the account's header. @@ -1948,6 +1958,11 @@ definitions: example: https://example.org/media/some_user/avatar/original/avatar.jpeg type: string x-go-name: Avatar + avatar_description: + description: Description of this account's avatar, for alt text. + example: A cute drawing of a smiling sloth. + type: string + x-go-name: AvatarDescription avatar_static: description: |- Web location of a static version of the account's avatar. @@ -2014,6 +2029,11 @@ definitions: example: https://example.org/media/some_user/header/original/header.jpeg type: string x-go-name: Header + header_description: + description: Description of this account's header, for alt text. + example: A sunlit field with purple flowers. + type: string + x-go-name: HeaderDescription header_static: description: |- Web location of a static version of the account's header. @@ -4072,10 +4092,20 @@ paths: in: formData name: avatar type: file + - allowEmptyValue: true + description: Description of avatar image, for alt-text. + in: formData + name: avatar_description + type: string - description: Header of the user. in: formData name: header type: file + - allowEmptyValue: true + description: Description of header image, for alt-text. + in: formData + name: header_description + type: string - description: Require manual approval of follow requests. in: formData name: locked diff --git a/internal/api/client/accounts/accountupdate.go b/internal/api/client/accounts/accountupdate.go index cd8ee35f4..f81f54db0 100644 --- a/internal/api/client/accounts/accountupdate.go +++ b/internal/api/client/accounts/accountupdate.go @@ -78,11 +78,23 @@ import ( // description: Avatar of the user. // type: file // - +// name: avatar_description +// in: formData +// description: Description of avatar image, for alt-text. +// type: string +// allowEmptyValue: true +// - // name: header // in: formData // description: Header of the user. // type: file // - +// name: header_description +// in: formData +// description: Description of header image, for alt-text. +// type: string +// allowEmptyValue: true +// - // name: locked // in: formData // description: Require manual approval of follow requests. @@ -315,7 +327,9 @@ func parseUpdateAccountForm(c *gin.Context) (*apimodel.UpdateCredentialsRequest, form.DisplayName == nil && form.Note == nil && form.Avatar == nil && + form.AvatarDescription == nil && form.Header == nil && + form.HeaderDescription == nil && form.Locked == nil && form.Source.Privacy == nil && form.Source.Sensitive == nil && diff --git a/internal/api/client/admin/accountsgetv2_test.go b/internal/api/client/admin/accountsgetv2_test.go index fdd6c6c30..85d58cce8 100644 --- a/internal/api/client/admin/accountsgetv2_test.go +++ b/internal/api/client/admin/accountsgetv2_test.go @@ -234,8 +234,10 @@ func (suite *AccountsGetTestSuite) TestAccountsGetFromTop() { "url": "http://localhost:8080/@the_mighty_zork", "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", "avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg", + "avatar_description": "a green goblin looking nasty", "header": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", "header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", + "header_description": "A very old-school screenshot of the original team fortress mod for quake", "followers_count": 2, "following_count": 2, "statuses_count": 7, @@ -409,6 +411,7 @@ func (suite *AccountsGetTestSuite) TestAccountsGetFromTop() { "avatar_static": "", "header": "http://localhost:8080/fileserver/062G5WYKY35KKD12EMSM3F8PJ8/header/original/01PFPMWK2FF0D9WMHEJHR07C3R.jpg", "header_static": "http://localhost:8080/fileserver/062G5WYKY35KKD12EMSM3F8PJ8/header/small/01PFPMWK2FF0D9WMHEJHR07C3R.jpg", + "header_description": "tweet from thoughts of dog: i drank. all the water. in my bowl. earlier. but just now. i returned. to the same bowl. and it was. full again.. the bowl. is haunted", "followers_count": 0, "following_count": 0, "statuses_count": 0, diff --git a/internal/api/client/statuses/statushistory_test.go b/internal/api/client/statuses/statushistory_test.go index a0cb3d482..a88abdb8f 100644 --- a/internal/api/client/statuses/statushistory_test.go +++ b/internal/api/client/statuses/statushistory_test.go @@ -108,8 +108,10 @@ func (suite *StatusHistoryTestSuite) TestGetHistory() { "url": "http://localhost:8080/@the_mighty_zork", "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", "avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg", + "avatar_description": "a green goblin looking nasty", "header": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", "header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", + "header_description": "A very old-school screenshot of the original team fortress mod for quake", "followers_count": 2, "following_count": 2, "statuses_count": 7, diff --git a/internal/api/client/statuses/statusmute_test.go b/internal/api/client/statuses/statusmute_test.go index a83720a20..83effd0c2 100644 --- a/internal/api/client/statuses/statusmute_test.go +++ b/internal/api/client/statuses/statusmute_test.go @@ -126,8 +126,10 @@ func (suite *StatusMuteTestSuite) TestMuteUnmuteStatus() { "url": "http://localhost:8080/@the_mighty_zork", "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", "avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg", + "avatar_description": "a green goblin looking nasty", "header": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", "header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", + "header_description": "A very old-school screenshot of the original team fortress mod for quake", "followers_count": 2, "following_count": 2, "statuses_count": 7, @@ -189,8 +191,10 @@ func (suite *StatusMuteTestSuite) TestMuteUnmuteStatus() { "url": "http://localhost:8080/@the_mighty_zork", "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", "avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg", + "avatar_description": "a green goblin looking nasty", "header": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", "header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", + "header_description": "A very old-school screenshot of the original team fortress mod for quake", "followers_count": 2, "following_count": 2, "statuses_count": 7, diff --git a/internal/api/model/account.go b/internal/api/model/account.go index b3a92d36f..cf39dd08e 100644 --- a/internal/api/model/account.go +++ b/internal/api/model/account.go @@ -62,6 +62,9 @@ type Account struct { // Only relevant when the account's main avatar is a video or a gif. // example: https://example.org/media/some_user/avatar/static/avatar.png AvatarStatic string `json:"avatar_static"` + // Description of this account's avatar, for alt text. + // example: A cute drawing of a smiling sloth. + AvatarDescription string `json:"avatar_description,omitempty"` // Web location of the account's header image. // example: https://example.org/media/some_user/header/original/header.jpeg Header string `json:"header"` @@ -69,6 +72,9 @@ type Account struct { // Only relevant when the account's main header is a video or a gif. // example: https://example.org/media/some_user/header/static/header.png HeaderStatic string `json:"header_static"` + // Description of this account's header, for alt text. + // example: A sunlit field with purple flowers. + HeaderDescription string `json:"header_description,omitempty"` // Number of accounts following this account, according to our instance. FollowersCount int `json:"followers_count"` // Number of account's followed by this account, according to our instance. @@ -104,6 +110,17 @@ type Account struct { // If set, indicates that this account is currently inactive, and has migrated to the given account. // Key/value omitted for accounts that haven't moved, and for suspended accounts. Moved *Account `json:"moved,omitempty"` + + // Additional fields not exposed via JSON + // (used only internally for templating etc). + + // Proper attachment model for the avatar. + // + // Only set if this model was converted via + // AccountToWebAccount, AND this account had + // an avatar set (and not just the default + // "blank" avatar image.) + AvatarAttachment *Attachment `json:"-"` } // MutedAccount extends Account with a field used only by the muted user list. @@ -168,8 +185,12 @@ type UpdateCredentialsRequest struct { Note *string `form:"note" json:"note"` // Avatar image encoded using multipart/form-data. Avatar *multipart.FileHeader `form:"avatar" json:"-"` + // Description of the avatar image, for alt-text. + AvatarDescription *string `form:"avatar_description" json:"avatar_description"` // Header image encoded using multipart/form-data Header *multipart.FileHeader `form:"header" json:"-"` + // Description of the header image, for alt-text. + HeaderDescription *string `form:"header_description" json:"header_description"` // Require manual approval of follow requests. Locked *bool `form:"locked" json:"locked"` // New Source values for this account. diff --git a/internal/processing/account/get.go b/internal/processing/account/get.go index 500c0c2e5..32d45054d 100644 --- a/internal/processing/account/get.go +++ b/internal/processing/account/get.go @@ -20,7 +20,6 @@ package account import ( "context" "errors" - "fmt" "net/url" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" @@ -36,66 +35,42 @@ func (p *Processor) Get(ctx context.Context, requestingAccount *gtsmodel.Account targetAccount, err := p.state.DB.GetAccountByID(ctx, targetAccountID) if err != nil { if errors.Is(err, db.ErrNoEntries) { - return nil, gtserror.NewErrorNotFound(errors.New("account not found")) + err := gtserror.New("account not found") + return nil, gtserror.NewErrorNotFound(err) } - return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error: %w", err)) + err := gtserror.Newf("db error getting account: %w", err) + return nil, gtserror.NewErrorInternalError(err) } - return p.getFor(ctx, requestingAccount, targetAccount) -} - -// GetLocalByUsername processes the given request for account information targeting a local account by username. -func (p *Processor) GetLocalByUsername(ctx context.Context, requestingAccount *gtsmodel.Account, username string) (*apimodel.Account, gtserror.WithCode) { - targetAccount, err := p.state.DB.GetAccountByUsernameDomain(ctx, username, "") - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, gtserror.NewErrorNotFound(errors.New("account not found")) - } - return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error: %w", err)) - } - - return p.getFor(ctx, requestingAccount, targetAccount) -} - -// GetCustomCSSForUsername returns custom css for the given local username. -func (p *Processor) GetCustomCSSForUsername(ctx context.Context, username string) (string, gtserror.WithCode) { - customCSS, err := p.state.DB.GetAccountCustomCSSByUsername(ctx, username) + blocked, err := p.state.DB.IsEitherBlocked(ctx, requestingAccount.ID, targetAccount.ID) if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return "", gtserror.NewErrorNotFound(errors.New("account not found")) - } - return "", gtserror.NewErrorInternalError(fmt.Errorf("db error: %w", err)) + err := gtserror.Newf("db error checking blocks: %w", err) + return nil, gtserror.NewErrorInternalError(err) } - return customCSS, nil -} - -func (p *Processor) getFor(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) (*apimodel.Account, gtserror.WithCode) { - var err error - - if requestingAccount != nil { - blocked, err := p.state.DB.IsEitherBlocked(ctx, requestingAccount.ID, targetAccount.ID) + if blocked { + apiAccount, err := p.converter.AccountToAPIAccountBlocked(ctx, targetAccount) if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error checking account block: %w", err)) - } - - if blocked { - apiAccount, err := p.converter.AccountToAPIAccountBlocked(ctx, targetAccount) - if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting account: %w", err)) - } - return apiAccount, nil + err := gtserror.Newf("error converting account: %w", err) + return nil, gtserror.NewErrorInternalError(err) } + return apiAccount, nil } if targetAccount.Domain != "" { targetAccountURI, err := url.Parse(targetAccount.URI) if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error parsing url %s: %w", targetAccount.URI, err)) + err := gtserror.Newf("error parsing account URI: %w", err) + return nil, gtserror.NewErrorInternalError(err) } - // Perform a last-minute fetch of target account to ensure remote account header / avatar is cached. - latest, _, err := p.federator.GetAccountByURI(gtscontext.SetFastFail(ctx), requestingAccount.Username, targetAccountURI) + // Perform a last-minute fetch of target account to + // ensure remote account header / avatar is cached. + latest, _, err := p.federator.GetAccountByURI( + gtscontext.SetFastFail(ctx), + requestingAccount.Username, + targetAccountURI, + ) if err != nil { log.Errorf(ctx, "error fetching latest target account: %v", err) } else { @@ -105,15 +80,53 @@ func (p *Processor) getFor(ctx context.Context, requestingAccount *gtsmodel.Acco } var apiAccount *apimodel.Account - - if requestingAccount != nil && targetAccount.ID == requestingAccount.ID { + if targetAccount.ID == requestingAccount.ID { + // This is requester's own account, + // show additional details. apiAccount, err = p.converter.AccountToAPIAccountSensitive(ctx, targetAccount) } else { + // This is a different account, + // show the "public" view. apiAccount, err = p.converter.AccountToAPIAccountPublic(ctx, targetAccount) } if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting account: %w", err)) + err := gtserror.Newf("error converting account: %w", err) + return nil, gtserror.NewErrorInternalError(err) } return apiAccount, nil } + +// GetWeb returns the web model of a local account by username. +func (p *Processor) GetWeb(ctx context.Context, username string) (*apimodel.Account, gtserror.WithCode) { + targetAccount, err := p.state.DB.GetAccountByUsernameDomain(ctx, username, "") + if err != nil { + if errors.Is(err, db.ErrNoEntries) { + err := gtserror.New("account not found") + return nil, gtserror.NewErrorNotFound(err) + } + err := gtserror.Newf("db error getting account: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + webAccount, err := p.converter.AccountToWebAccount(ctx, targetAccount) + if err != nil { + err := gtserror.Newf("error converting account: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return webAccount, nil +} + +// GetCustomCSSForUsername returns custom css for the given local username. +func (p *Processor) GetCustomCSSForUsername(ctx context.Context, username string) (string, gtserror.WithCode) { + customCSS, err := p.state.DB.GetAccountCustomCSSByUsername(ctx, username) + if err != nil { + if errors.Is(err, db.ErrNoEntries) { + return "", gtserror.NewErrorNotFound(gtserror.New("account not found")) + } + return "", gtserror.NewErrorInternalError(gtserror.Newf("db error: %w", err)) + } + + return customCSS, nil +} diff --git a/internal/processing/account/update.go b/internal/processing/account/update.go index 61e88501f..ba9360c36 100644 --- a/internal/processing/account/update.go +++ b/internal/processing/account/update.go @@ -204,11 +204,16 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form } } + if form.AvatarDescription != nil { + desc := text.SanitizeToPlaintext(*form.AvatarDescription) + form.AvatarDescription = util.Ptr(desc) + } + if form.Avatar != nil && form.Avatar.Size != 0 { avatarInfo, errWithCode := p.UpdateAvatar(ctx, account, form.Avatar, - nil, + form.AvatarDescription, ) if errWithCode != nil { return nil, errWithCode @@ -216,13 +221,29 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form account.AvatarMediaAttachmentID = avatarInfo.ID account.AvatarMediaAttachment = avatarInfo log.Tracef(ctx, "new avatar info for account %s is %+v", account.ID, avatarInfo) + } else if form.AvatarDescription != nil && account.AvatarMediaAttachment != nil { + // Update just existing description if possible. + account.AvatarMediaAttachment.Description = *form.AvatarDescription + if err := p.state.DB.UpdateAttachment( + ctx, + account.AvatarMediaAttachment, + "description", + ); err != nil { + err := gtserror.Newf("db error updating account avatar description: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + } + + if form.HeaderDescription != nil { + desc := text.SanitizeToPlaintext(*form.HeaderDescription) + form.HeaderDescription = util.Ptr(desc) } if form.Header != nil && form.Header.Size != 0 { headerInfo, errWithCode := p.UpdateHeader(ctx, account, form.Header, - nil, + form.HeaderDescription, ) if errWithCode != nil { return nil, errWithCode @@ -230,6 +251,17 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form account.HeaderMediaAttachmentID = headerInfo.ID account.HeaderMediaAttachment = headerInfo log.Tracef(ctx, "new header info for account %s is %+v", account.ID, headerInfo) + } else if form.HeaderDescription != nil && account.HeaderMediaAttachment != nil { + // Update just existing description if possible. + account.HeaderMediaAttachment.Description = *form.HeaderDescription + if err := p.state.DB.UpdateAttachment( + ctx, + account.HeaderMediaAttachment, + "description", + ); err != nil { + err := gtserror.Newf("db error updating account avatar description: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } } if form.Locked != nil { diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index a8f9b7f8f..733a21b75 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -162,6 +162,38 @@ func (c *Converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A return account, nil } +// AccountToWebAccount converts a gts model account into an +// api representation suitable for serving into a web template. +// +// Should only be used when preparing to template an account, +// callers looking to serialize an account into a model for +// serving over the client API should always use one of the +// AccountToAPIAccount functions instead. +func (c *Converter) AccountToWebAccount( + ctx context.Context, + a *gtsmodel.Account, +) (*apimodel.Account, error) { + webAccount, err := c.AccountToAPIAccountPublic(ctx, a) + if err != nil { + return nil, err + } + + // Set additional avatar information for + // serving the avatar in a nice photobox. + if a.AvatarMediaAttachment != nil { + avatarAttachment, err := c.AttachmentToAPIAttachment(ctx, a.AvatarMediaAttachment) + if err != nil { + // This is just extra data so just + // log but don't return any error. + log.Errorf(ctx, "error converting account avatar attachment: %v", err) + } else { + webAccount.AvatarAttachment = &avatarAttachment + } + } + + return webAccount, nil +} + // accountToAPIAccountPublic provides all the logic for AccountToAPIAccount, MINUS fetching moved account, to prevent possible recursion. func (c *Converter) accountToAPIAccountPublic(ctx context.Context, a *gtsmodel.Account) (*apimodel.Account, error) { @@ -210,18 +242,22 @@ func (c *Converter) accountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A var ( aviURL string aviURLStatic string + aviDesc string headerURL string headerURLStatic string + headerDesc string ) if a.AvatarMediaAttachment != nil { aviURL = a.AvatarMediaAttachment.URL aviURLStatic = a.AvatarMediaAttachment.Thumbnail.URL + aviDesc = a.AvatarMediaAttachment.Description } if a.HeaderMediaAttachment != nil { headerURL = a.HeaderMediaAttachment.URL headerURLStatic = a.HeaderMediaAttachment.Thumbnail.URL + headerDesc = a.HeaderMediaAttachment.Description } // convert account gts model fields to front api model fields @@ -294,32 +330,34 @@ func (c *Converter) accountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A // can be populated directly below. accountFrontend := &apimodel.Account{ - ID: a.ID, - Username: a.Username, - Acct: acct, - DisplayName: a.DisplayName, - Locked: locked, - Discoverable: discoverable, - Bot: bot, - CreatedAt: util.FormatISO8601(a.CreatedAt), - Note: a.Note, - URL: a.URL, - Avatar: aviURL, - AvatarStatic: aviURLStatic, - Header: headerURL, - HeaderStatic: headerURLStatic, - FollowersCount: followersCount, - FollowingCount: followingCount, - StatusesCount: statusesCount, - LastStatusAt: lastStatusAt, - Emojis: apiEmojis, - Fields: fields, - Suspended: !a.SuspendedAt.IsZero(), - Theme: theme, - CustomCSS: customCSS, - EnableRSS: enableRSS, - HideCollections: hideCollections, - Role: role, + ID: a.ID, + Username: a.Username, + Acct: acct, + DisplayName: a.DisplayName, + Locked: locked, + Discoverable: discoverable, + Bot: bot, + CreatedAt: util.FormatISO8601(a.CreatedAt), + Note: a.Note, + URL: a.URL, + Avatar: aviURL, + AvatarStatic: aviURLStatic, + AvatarDescription: aviDesc, + Header: headerURL, + HeaderStatic: headerURLStatic, + HeaderDescription: headerDesc, + FollowersCount: followersCount, + FollowingCount: followingCount, + StatusesCount: statusesCount, + LastStatusAt: lastStatusAt, + Emojis: apiEmojis, + Fields: fields, + Suspended: !a.SuspendedAt.IsZero(), + Theme: theme, + CustomCSS: customCSS, + EnableRSS: enableRSS, + HideCollections: hideCollections, + Role: role, } // Bodge default avatar + header in, diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index 16dc27c87..522bf6401 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -57,8 +57,10 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontend() { "url": "http://localhost:8080/@the_mighty_zork", "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", "avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg", + "avatar_description": "a green goblin looking nasty", "header": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", "header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", + "header_description": "A very old-school screenshot of the original team fortress mod for quake", "followers_count": 2, "following_count": 2, "statuses_count": 7, @@ -108,8 +110,10 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendAliasedAndMoved() "url": "http://localhost:8080/@the_mighty_zork", "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", "avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg", + "avatar_description": "a green goblin looking nasty", "header": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", "header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", + "header_description": "A very old-school screenshot of the original team fortress mod for quake", "followers_count": 2, "following_count": 2, "statuses_count": 7, @@ -199,8 +203,10 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendWithEmojiStruct() "url": "http://localhost:8080/@the_mighty_zork", "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", "avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg", + "avatar_description": "a green goblin looking nasty", "header": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", "header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", + "header_description": "A very old-school screenshot of the original team fortress mod for quake", "followers_count": 2, "following_count": 2, "statuses_count": 7, @@ -247,8 +253,10 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendWithEmojiIDs() { "url": "http://localhost:8080/@the_mighty_zork", "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", "avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg", + "avatar_description": "a green goblin looking nasty", "header": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", "header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", + "header_description": "A very old-school screenshot of the original team fortress mod for quake", "followers_count": 2, "following_count": 2, "statuses_count": 7, @@ -291,8 +299,10 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendSensitive() { "url": "http://localhost:8080/@the_mighty_zork", "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", "avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg", + "avatar_description": "a green goblin looking nasty", "header": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", "header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", + "header_description": "A very old-school screenshot of the original team fortress mod for quake", "followers_count": 2, "following_count": 2, "statuses_count": 7, diff --git a/internal/web/profile.go b/internal/web/profile.go index 1dbf5c73d..ca613900f 100644 --- a/internal/web/profile.go +++ b/internal/web/profile.go @@ -28,7 +28,6 @@ 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/oauth" ) func (m *Module) profileGETHandler(c *gin.Context) { @@ -79,16 +78,8 @@ func (m *Module) profileGETHandler(c *gin.Context) { // text/html has been requested. Proceed with getting the web view of the account. - // Don't require auth for web endpoints, but do take it if it was provided. - // authed.Account might end up nil here, but that's fine in case of public pages. - authed, err := oauth.Authed(c, false, false, false, false) - if err != nil { - apiutil.WebErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) - return - } - // Fetch the target account so we can do some checks on it. - targetAccount, errWithCode := m.processor.Account().GetLocalByUsername(ctx, authed.Account, targetUsername) + targetAccount, errWithCode := m.processor.Account().GetWeb(ctx, targetUsername) if errWithCode != nil { apiutil.WebErrorHandler(c, errWithCode, instanceGet) return diff --git a/internal/web/thread.go b/internal/web/thread.go index 05bd63ebe..492d40103 100644 --- a/internal/web/thread.go +++ b/internal/web/thread.go @@ -29,7 +29,6 @@ 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/oauth" ) func (m *Module) threadGETHandler(c *gin.Context) { @@ -88,16 +87,8 @@ func (m *Module) threadGETHandler(c *gin.Context) { // text/html has been requested. Proceed with getting the web view of the status. - // Don't require auth for web endpoints, but do take it if it was provided. - // authed.Account might end up nil here, but that's fine in case of public pages. - authed, err := oauth.Authed(c, false, false, false, false) - if err != nil { - apiutil.WebErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) - return - } - // Fetch the target account so we can do some checks on it. - targetAccount, errWithCode := m.processor.Account().GetLocalByUsername(ctx, authed.Account, targetUsername) + targetAccount, errWithCode := m.processor.Account().GetWeb(ctx, targetUsername) if errWithCode != nil { apiutil.WebErrorHandler(c, errWithCode, instanceGet) return diff --git a/testrig/testmodels.go b/testrig/testmodels.go index 3db8ef62f..de6e97142 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -969,7 +969,7 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { }, }, AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", - Description: "A very old-school screenshot of the original team fortress mod for quake ", + Description: "A very old-school screenshot of the original team fortress mod for quake", ScheduledStatusID: "", Blurhash: "L26j{^WCs+R-N}jsxWj@4;WWxDoK", Processing: 2, diff --git a/web/source/css/profile.css b/web/source/css/profile.css index a966d768a..3f7f43d0d 100644 --- a/web/source/css/profile.css +++ b/web/source/css/profile.css @@ -82,18 +82,37 @@ margin-top: calc(-1 * $overlap); gap: 0 1rem; - .avatar { + .avatar-image-wrapper { grid-area: avatar; - height: $avatar-size; - width: $avatar-size; + border: 0.2rem solid $avatar-border; border-radius: $br; - overflow: hidden; /* prevents image extending beyond rounded borders */ + + /* + Wrapper always same + size + proportions no + matter image inside. + */ + height: $avatar-size; + width: $avatar-size; - img { + .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; } } diff --git a/web/source/settings/components/profile.tsx b/web/source/settings/components/profile.tsx index 4a5157378..24cb3c4c2 100644 --- a/web/source/settings/components/profile.tsx +++ b/web/source/settings/components/profile.tsx @@ -27,9 +27,11 @@ export default function FakeProfile({ avatar, header, display_name, username, ro <img src={header} alt={header ? `header image for ${username}` : "None set"} /> </div> <div className="basic-info" aria-hidden="true"> - <a className="avatar" href={avatar}> - <img src={avatar} alt={avatar ? `avatar image for ${username}` : "None set"} /> - </a> + <div className="avatar-image-wrapper"> + <a href={avatar}> + <img className="avatar" src={avatar} alt={avatar ? `avatar image for ${username}` : "None set"} /> + </a> + </div> <dl className="namerole"> <dt className="sr-only">Display name</dt> <dd className="displayname text-cutoff">{display_name.trim().length > 0 ? display_name : username}</dd> diff --git a/web/source/settings/style.css b/web/source/settings/style.css index cdae6b972..f9c098ace 100644 --- a/web/source/settings/style.css +++ b/web/source/settings/style.css @@ -400,12 +400,13 @@ section.with-sidebar > form { width: 24rem; } } - - .file-input-with-image-description { - display: flex; - flex-direction: column; - justify-content: space-around; - } +} + +.file-input-with-image-description { + display: flex; + flex-direction: column; + justify-content: space-around; + gap: 0.5rem; } /* @@ -422,11 +423,13 @@ section.with-sidebar > form { } .user-profile { + .profile { + max-width: 42rem; + } + .overview { - display: grid; - max-width: 60rem; - grid-template-columns: 70% 30%; - grid-template-rows: auto; + display: flex; + flex-direction: column; gap: 1rem; .files { diff --git a/web/source/settings/views/user/profile.tsx b/web/source/settings/views/user/profile.tsx index 17827ce9e..f4088b353 100644 --- a/web/source/settings/views/user/profile.tsx +++ b/web/source/settings/views/user/profile.tsx @@ -93,7 +93,9 @@ function UserProfileForm({ data: profile }) { const form = { avatar: useFileInput("avatar", { withPreview: true }), + avatarDescription: useTextInput("avatar_description", { source: profile }), header: useFileInput("header", { withPreview: true }), + headerDescription: useTextInput("header_description", { source: profile }), displayName: useTextInput("display_name", { source: profile }), note: useTextInput("note", { source: profile, valueSelector: (p) => p.source?.note }), bot: useBoolInput("bot", { source: profile }), @@ -131,21 +133,33 @@ function UserProfileForm({ data: profile }) { username={profile.username} role={profile.role} /> - <div className="files"> - <div> - <FileInput - label="Header" - field={form.header} - accept="image/*" - /> - </div> - <div> - <FileInput - label="Avatar" - field={form.avatar} - accept="image/*" - /> - </div> + + <div className="file-input-with-image-description"> + <FileInput + label="Header" + field={form.header} + accept="image/png, image/jpeg, image/webp, image/gif" + /> + <TextInput + field={form.headerDescription} + label="Header image description" + placeholder="A green field with pink flowers." + autoCapitalize="sentences" + /> + </div> + + <div className="file-input-with-image-description"> + <FileInput + label="Avatar (1:1 images look best)" + field={form.avatar} + accept="image/png, image/jpeg, image/webp, image/gif" + /> + <TextInput + field={form.avatarDescription} + label="Avatar image description" + placeholder="A cute drawing of a smiling sloth." + autoCapitalize="sentences" + /> </div> <div className="theme"> diff --git a/web/template/profile.tmpl b/web/template/profile.tmpl index f0467e004..256bbdccf 100644 --- a/web/template/profile.tmpl +++ b/web/template/profile.tmpl @@ -35,6 +35,78 @@ {{- 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" . -}}" + > + <img + class="avatar" + src="{{- .account.Avatar -}}" + alt="{{- template "avatarAlt" . -}}" + title="{{- template "avatarAlt" . -}}" + width="{{- template "avatarWidth" . -}}" + height="{{- template "avatarHeight" . -}}" + /> + </a> +</div> +{{- end }} +{{- end -}} + {{- with . }} <main class="profile"> <h2 class="sr-only">Profile for {{ .account.Username -}}</h2> @@ -45,18 +117,14 @@ <div class="header-image-wrapper"> <img src="{{- .account.Header -}}" - alt="Header for {{ .account.Username -}}" - title="Header for {{ .account.Username -}}" + alt="{{- template "headerAlt" . -}}" + title="{{- template "headerAlt" . -}}" /> </div> <div class="basic-info"> - <a class="avatar" href="{{- .account.Avatar -}}"> - <img - src="{{- .account.Avatar -}}" - alt="Avatar for {{ .account.Username -}}" - title="Avatar for {{ .account.Username -}}" - /> - </a> + {{- with . }} + {{- include "avatar" . | indent 3 }} + {{- end }} <dl class="namerole"> <dt class="sr-only">Display name</dt> <dd class="displayname text-cutoff"> |