[feature/frontend] Allow setting alt-text for avatar + header (#3086)

This commit is contained in:
tobi 2024-07-08 15:47:03 +02:00 committed by GitHub
parent 43c480aec4
commit d70f4e166d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 395 additions and 140 deletions

View file

@ -193,6 +193,11 @@ definitions:
example: https://example.org/media/some_user/avatar/original/avatar.jpeg example: https://example.org/media/some_user/avatar/original/avatar.jpeg
type: string type: string
x-go-name: Avatar 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: avatar_static:
description: |- description: |-
Web location of a static version of the account's avatar. 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 example: https://example.org/media/some_user/header/original/header.jpeg
type: string type: string
x-go-name: Header 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: header_static:
description: |- description: |-
Web location of a static version of the account's header. 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 example: https://example.org/media/some_user/avatar/original/avatar.jpeg
type: string type: string
x-go-name: Avatar 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: avatar_static:
description: |- description: |-
Web location of a static version of the account's avatar. 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 example: https://example.org/media/some_user/header/original/header.jpeg
type: string type: string
x-go-name: Header 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: header_static:
description: |- description: |-
Web location of a static version of the account's header. Web location of a static version of the account's header.
@ -4072,10 +4092,20 @@ paths:
in: formData in: formData
name: avatar name: avatar
type: file type: file
- allowEmptyValue: true
description: Description of avatar image, for alt-text.
in: formData
name: avatar_description
type: string
- description: Header of the user. - description: Header of the user.
in: formData in: formData
name: header name: header
type: file 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. - description: Require manual approval of follow requests.
in: formData in: formData
name: locked name: locked

View file

@ -78,11 +78,23 @@ import (
// description: Avatar of the user. // description: Avatar of the user.
// type: file // type: file
// - // -
// name: avatar_description
// in: formData
// description: Description of avatar image, for alt-text.
// type: string
// allowEmptyValue: true
// -
// name: header // name: header
// in: formData // in: formData
// description: Header of the user. // description: Header of the user.
// type: file // type: file
// - // -
// name: header_description
// in: formData
// description: Description of header image, for alt-text.
// type: string
// allowEmptyValue: true
// -
// name: locked // name: locked
// in: formData // in: formData
// description: Require manual approval of follow requests. // description: Require manual approval of follow requests.
@ -315,7 +327,9 @@ func parseUpdateAccountForm(c *gin.Context) (*apimodel.UpdateCredentialsRequest,
form.DisplayName == nil && form.DisplayName == nil &&
form.Note == nil && form.Note == nil &&
form.Avatar == nil && form.Avatar == nil &&
form.AvatarDescription == nil &&
form.Header == nil && form.Header == nil &&
form.HeaderDescription == nil &&
form.Locked == nil && form.Locked == nil &&
form.Source.Privacy == nil && form.Source.Privacy == nil &&
form.Source.Sensitive == nil && form.Source.Sensitive == nil &&

View file

@ -234,8 +234,10 @@ func (suite *AccountsGetTestSuite) TestAccountsGetFromTop() {
"url": "http://localhost:8080/@the_mighty_zork", "url": "http://localhost:8080/@the_mighty_zork",
"avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg",
"avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/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": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg",
"header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/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, "followers_count": 2,
"following_count": 2, "following_count": 2,
"statuses_count": 7, "statuses_count": 7,
@ -409,6 +411,7 @@ func (suite *AccountsGetTestSuite) TestAccountsGetFromTop() {
"avatar_static": "", "avatar_static": "",
"header": "http://localhost:8080/fileserver/062G5WYKY35KKD12EMSM3F8PJ8/header/original/01PFPMWK2FF0D9WMHEJHR07C3R.jpg", "header": "http://localhost:8080/fileserver/062G5WYKY35KKD12EMSM3F8PJ8/header/original/01PFPMWK2FF0D9WMHEJHR07C3R.jpg",
"header_static": "http://localhost:8080/fileserver/062G5WYKY35KKD12EMSM3F8PJ8/header/small/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, "followers_count": 0,
"following_count": 0, "following_count": 0,
"statuses_count": 0, "statuses_count": 0,

View file

@ -108,8 +108,10 @@ func (suite *StatusHistoryTestSuite) TestGetHistory() {
"url": "http://localhost:8080/@the_mighty_zork", "url": "http://localhost:8080/@the_mighty_zork",
"avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg",
"avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/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": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg",
"header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/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, "followers_count": 2,
"following_count": 2, "following_count": 2,
"statuses_count": 7, "statuses_count": 7,

View file

@ -126,8 +126,10 @@ func (suite *StatusMuteTestSuite) TestMuteUnmuteStatus() {
"url": "http://localhost:8080/@the_mighty_zork", "url": "http://localhost:8080/@the_mighty_zork",
"avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg",
"avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/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": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg",
"header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/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, "followers_count": 2,
"following_count": 2, "following_count": 2,
"statuses_count": 7, "statuses_count": 7,
@ -189,8 +191,10 @@ func (suite *StatusMuteTestSuite) TestMuteUnmuteStatus() {
"url": "http://localhost:8080/@the_mighty_zork", "url": "http://localhost:8080/@the_mighty_zork",
"avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg",
"avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/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": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg",
"header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/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, "followers_count": 2,
"following_count": 2, "following_count": 2,
"statuses_count": 7, "statuses_count": 7,

View file

@ -62,6 +62,9 @@ type Account struct {
// Only relevant when the account's main avatar is a video or a gif. // 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 // example: https://example.org/media/some_user/avatar/static/avatar.png
AvatarStatic string `json:"avatar_static"` 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. // Web location of the account's header image.
// example: https://example.org/media/some_user/header/original/header.jpeg // example: https://example.org/media/some_user/header/original/header.jpeg
Header string `json:"header"` 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. // 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 // example: https://example.org/media/some_user/header/static/header.png
HeaderStatic string `json:"header_static"` 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. // Number of accounts following this account, according to our instance.
FollowersCount int `json:"followers_count"` FollowersCount int `json:"followers_count"`
// Number of account's followed by this account, according to our instance. // 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. // 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. // Key/value omitted for accounts that haven't moved, and for suspended accounts.
Moved *Account `json:"moved,omitempty"` 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. // 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"` Note *string `form:"note" json:"note"`
// Avatar image encoded using multipart/form-data. // Avatar image encoded using multipart/form-data.
Avatar *multipart.FileHeader `form:"avatar" json:"-"` 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 image encoded using multipart/form-data
Header *multipart.FileHeader `form:"header" json:"-"` 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. // Require manual approval of follow requests.
Locked *bool `form:"locked" json:"locked"` Locked *bool `form:"locked" json:"locked"`
// New Source values for this account. // New Source values for this account.

View file

@ -20,7 +20,6 @@ package account
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"net/url" "net/url"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" 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) targetAccount, err := p.state.DB.GetAccountByID(ctx, targetAccountID)
if err != nil { if err != nil {
if errors.Is(err, db.ErrNoEntries) { 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) blocked, err := p.state.DB.IsEitherBlocked(ctx, requestingAccount.ID, targetAccount.ID)
}
// 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 err != nil {
if errors.Is(err, db.ErrNoEntries) { err := gtserror.Newf("db error checking blocks: %w", err)
return nil, gtserror.NewErrorNotFound(errors.New("account not found")) return nil, gtserror.NewErrorInternalError(err)
}
return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error: %w", err))
} }
return p.getFor(ctx, requestingAccount, targetAccount) if blocked {
} apiAccount, err := p.converter.AccountToAPIAccountBlocked(ctx, 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)
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))
}
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 err != nil { if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error checking account block: %w", err)) err := gtserror.Newf("error converting account: %w", err)
} return nil, gtserror.NewErrorInternalError(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
} }
return apiAccount, nil
} }
if targetAccount.Domain != "" { if targetAccount.Domain != "" {
targetAccountURI, err := url.Parse(targetAccount.URI) targetAccountURI, err := url.Parse(targetAccount.URI)
if err != nil { 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. // Perform a last-minute fetch of target account to
latest, _, err := p.federator.GetAccountByURI(gtscontext.SetFastFail(ctx), requestingAccount.Username, targetAccountURI) // ensure remote account header / avatar is cached.
latest, _, err := p.federator.GetAccountByURI(
gtscontext.SetFastFail(ctx),
requestingAccount.Username,
targetAccountURI,
)
if err != nil { if err != nil {
log.Errorf(ctx, "error fetching latest target account: %v", err) log.Errorf(ctx, "error fetching latest target account: %v", err)
} else { } else {
@ -105,15 +80,53 @@ func (p *Processor) getFor(ctx context.Context, requestingAccount *gtsmodel.Acco
} }
var apiAccount *apimodel.Account var apiAccount *apimodel.Account
if targetAccount.ID == requestingAccount.ID {
if requestingAccount != nil && targetAccount.ID == requestingAccount.ID { // This is requester's own account,
// show additional details.
apiAccount, err = p.converter.AccountToAPIAccountSensitive(ctx, targetAccount) apiAccount, err = p.converter.AccountToAPIAccountSensitive(ctx, targetAccount)
} else { } else {
// This is a different account,
// show the "public" view.
apiAccount, err = p.converter.AccountToAPIAccountPublic(ctx, targetAccount) apiAccount, err = p.converter.AccountToAPIAccountPublic(ctx, targetAccount)
} }
if err != nil { 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 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
}

View file

@ -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 { if form.Avatar != nil && form.Avatar.Size != 0 {
avatarInfo, errWithCode := p.UpdateAvatar(ctx, avatarInfo, errWithCode := p.UpdateAvatar(ctx,
account, account,
form.Avatar, form.Avatar,
nil, form.AvatarDescription,
) )
if errWithCode != nil { if errWithCode != nil {
return nil, errWithCode return nil, errWithCode
@ -216,13 +221,29 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form
account.AvatarMediaAttachmentID = avatarInfo.ID account.AvatarMediaAttachmentID = avatarInfo.ID
account.AvatarMediaAttachment = avatarInfo account.AvatarMediaAttachment = avatarInfo
log.Tracef(ctx, "new avatar info for account %s is %+v", account.ID, 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 { if form.Header != nil && form.Header.Size != 0 {
headerInfo, errWithCode := p.UpdateHeader(ctx, headerInfo, errWithCode := p.UpdateHeader(ctx,
account, account,
form.Header, form.Header,
nil, form.HeaderDescription,
) )
if errWithCode != nil { if errWithCode != nil {
return nil, errWithCode return nil, errWithCode
@ -230,6 +251,17 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form
account.HeaderMediaAttachmentID = headerInfo.ID account.HeaderMediaAttachmentID = headerInfo.ID
account.HeaderMediaAttachment = headerInfo account.HeaderMediaAttachment = headerInfo
log.Tracef(ctx, "new header info for account %s is %+v", account.ID, 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 { if form.Locked != nil {

View file

@ -162,6 +162,38 @@ func (c *Converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A
return account, nil 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. // 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) { 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 ( var (
aviURL string aviURL string
aviURLStatic string aviURLStatic string
aviDesc string
headerURL string headerURL string
headerURLStatic string headerURLStatic string
headerDesc string
) )
if a.AvatarMediaAttachment != nil { if a.AvatarMediaAttachment != nil {
aviURL = a.AvatarMediaAttachment.URL aviURL = a.AvatarMediaAttachment.URL
aviURLStatic = a.AvatarMediaAttachment.Thumbnail.URL aviURLStatic = a.AvatarMediaAttachment.Thumbnail.URL
aviDesc = a.AvatarMediaAttachment.Description
} }
if a.HeaderMediaAttachment != nil { if a.HeaderMediaAttachment != nil {
headerURL = a.HeaderMediaAttachment.URL headerURL = a.HeaderMediaAttachment.URL
headerURLStatic = a.HeaderMediaAttachment.Thumbnail.URL headerURLStatic = a.HeaderMediaAttachment.Thumbnail.URL
headerDesc = a.HeaderMediaAttachment.Description
} }
// convert account gts model fields to front api model fields // 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. // can be populated directly below.
accountFrontend := &apimodel.Account{ accountFrontend := &apimodel.Account{
ID: a.ID, ID: a.ID,
Username: a.Username, Username: a.Username,
Acct: acct, Acct: acct,
DisplayName: a.DisplayName, DisplayName: a.DisplayName,
Locked: locked, Locked: locked,
Discoverable: discoverable, Discoverable: discoverable,
Bot: bot, Bot: bot,
CreatedAt: util.FormatISO8601(a.CreatedAt), CreatedAt: util.FormatISO8601(a.CreatedAt),
Note: a.Note, Note: a.Note,
URL: a.URL, URL: a.URL,
Avatar: aviURL, Avatar: aviURL,
AvatarStatic: aviURLStatic, AvatarStatic: aviURLStatic,
Header: headerURL, AvatarDescription: aviDesc,
HeaderStatic: headerURLStatic, Header: headerURL,
FollowersCount: followersCount, HeaderStatic: headerURLStatic,
FollowingCount: followingCount, HeaderDescription: headerDesc,
StatusesCount: statusesCount, FollowersCount: followersCount,
LastStatusAt: lastStatusAt, FollowingCount: followingCount,
Emojis: apiEmojis, StatusesCount: statusesCount,
Fields: fields, LastStatusAt: lastStatusAt,
Suspended: !a.SuspendedAt.IsZero(), Emojis: apiEmojis,
Theme: theme, Fields: fields,
CustomCSS: customCSS, Suspended: !a.SuspendedAt.IsZero(),
EnableRSS: enableRSS, Theme: theme,
HideCollections: hideCollections, CustomCSS: customCSS,
Role: role, EnableRSS: enableRSS,
HideCollections: hideCollections,
Role: role,
} }
// Bodge default avatar + header in, // Bodge default avatar + header in,

View file

@ -57,8 +57,10 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontend() {
"url": "http://localhost:8080/@the_mighty_zork", "url": "http://localhost:8080/@the_mighty_zork",
"avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg",
"avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/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": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg",
"header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/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, "followers_count": 2,
"following_count": 2, "following_count": 2,
"statuses_count": 7, "statuses_count": 7,
@ -108,8 +110,10 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendAliasedAndMoved()
"url": "http://localhost:8080/@the_mighty_zork", "url": "http://localhost:8080/@the_mighty_zork",
"avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg",
"avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/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": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg",
"header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/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, "followers_count": 2,
"following_count": 2, "following_count": 2,
"statuses_count": 7, "statuses_count": 7,
@ -199,8 +203,10 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendWithEmojiStruct()
"url": "http://localhost:8080/@the_mighty_zork", "url": "http://localhost:8080/@the_mighty_zork",
"avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg",
"avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/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": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg",
"header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/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, "followers_count": 2,
"following_count": 2, "following_count": 2,
"statuses_count": 7, "statuses_count": 7,
@ -247,8 +253,10 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendWithEmojiIDs() {
"url": "http://localhost:8080/@the_mighty_zork", "url": "http://localhost:8080/@the_mighty_zork",
"avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg",
"avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/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": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg",
"header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/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, "followers_count": 2,
"following_count": 2, "following_count": 2,
"statuses_count": 7, "statuses_count": 7,
@ -291,8 +299,10 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendSensitive() {
"url": "http://localhost:8080/@the_mighty_zork", "url": "http://localhost:8080/@the_mighty_zork",
"avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg",
"avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/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": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg",
"header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/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, "followers_count": 2,
"following_count": 2, "following_count": 2,
"statuses_count": 7, "statuses_count": 7,

View file

@ -28,7 +28,6 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
) )
func (m *Module) profileGETHandler(c *gin.Context) { 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. // 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. // 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 { if errWithCode != nil {
apiutil.WebErrorHandler(c, errWithCode, instanceGet) apiutil.WebErrorHandler(c, errWithCode, instanceGet)
return return

View file

@ -29,7 +29,6 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
) )
func (m *Module) threadGETHandler(c *gin.Context) { 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. // 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. // 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 { if errWithCode != nil {
apiutil.WebErrorHandler(c, errWithCode, instanceGet) apiutil.WebErrorHandler(c, errWithCode, instanceGet)
return return

View file

@ -969,7 +969,7 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
}, },
}, },
AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", 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: "", ScheduledStatusID: "",
Blurhash: "L26j{^WCs+R-N}jsxWj@4;WWxDoK", Blurhash: "L26j{^WCs+R-N}jsxWj@4;WWxDoK",
Processing: 2, Processing: 2,

View file

@ -82,18 +82,37 @@
margin-top: calc(-1 * $overlap); margin-top: calc(-1 * $overlap);
gap: 0 1rem; gap: 0 1rem;
.avatar { .avatar-image-wrapper {
grid-area: avatar; grid-area: avatar;
height: $avatar-size;
width: $avatar-size;
border: 0.2rem solid $avatar-border; border: 0.2rem solid $avatar-border;
border-radius: $br; 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%; height: 100%;
width: 100%; width: 100%;
/*
Normalize non-square images.
*/
object-fit: cover; object-fit: cover;
/*
Prevent image extending
beyond rounded borders.
*/
border-radius: $br-inner;
} }
} }

View file

@ -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"} /> <img src={header} alt={header ? `header image for ${username}` : "None set"} />
</div> </div>
<div className="basic-info" aria-hidden="true"> <div className="basic-info" aria-hidden="true">
<a className="avatar" href={avatar}> <div className="avatar-image-wrapper">
<img src={avatar} alt={avatar ? `avatar image for ${username}` : "None set"} /> <a href={avatar}>
</a> <img className="avatar" src={avatar} alt={avatar ? `avatar image for ${username}` : "None set"} />
</a>
</div>
<dl className="namerole"> <dl className="namerole">
<dt className="sr-only">Display name</dt> <dt className="sr-only">Display name</dt>
<dd className="displayname text-cutoff">{display_name.trim().length > 0 ? display_name : username}</dd> <dd className="displayname text-cutoff">{display_name.trim().length > 0 ? display_name : username}</dd>

View file

@ -400,12 +400,13 @@ section.with-sidebar > form {
width: 24rem; width: 24rem;
} }
} }
}
.file-input-with-image-description {
display: flex; .file-input-with-image-description {
flex-direction: column; display: flex;
justify-content: space-around; flex-direction: column;
} justify-content: space-around;
gap: 0.5rem;
} }
/* /*
@ -422,11 +423,13 @@ section.with-sidebar > form {
} }
.user-profile { .user-profile {
.profile {
max-width: 42rem;
}
.overview { .overview {
display: grid; display: flex;
max-width: 60rem; flex-direction: column;
grid-template-columns: 70% 30%;
grid-template-rows: auto;
gap: 1rem; gap: 1rem;
.files { .files {

View file

@ -93,7 +93,9 @@ function UserProfileForm({ data: profile }) {
const form = { const form = {
avatar: useFileInput("avatar", { withPreview: true }), avatar: useFileInput("avatar", { withPreview: true }),
avatarDescription: useTextInput("avatar_description", { source: profile }),
header: useFileInput("header", { withPreview: true }), header: useFileInput("header", { withPreview: true }),
headerDescription: useTextInput("header_description", { source: profile }),
displayName: useTextInput("display_name", { source: profile }), displayName: useTextInput("display_name", { source: profile }),
note: useTextInput("note", { source: profile, valueSelector: (p) => p.source?.note }), note: useTextInput("note", { source: profile, valueSelector: (p) => p.source?.note }),
bot: useBoolInput("bot", { source: profile }), bot: useBoolInput("bot", { source: profile }),
@ -131,21 +133,33 @@ function UserProfileForm({ data: profile }) {
username={profile.username} username={profile.username}
role={profile.role} role={profile.role}
/> />
<div className="files">
<div> <div className="file-input-with-image-description">
<FileInput <FileInput
label="Header" label="Header"
field={form.header} field={form.header}
accept="image/*" accept="image/png, image/jpeg, image/webp, image/gif"
/> />
</div> <TextInput
<div> field={form.headerDescription}
<FileInput label="Header image description"
label="Avatar" placeholder="A green field with pink flowers."
field={form.avatar} autoCapitalize="sentences"
accept="image/*" />
/> </div>
</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>
<div className="theme"> <div className="theme">

View file

@ -35,6 +35,78 @@
{{- end }} {{- end }}
{{- 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 . }} {{- with . }}
<main class="profile"> <main class="profile">
<h2 class="sr-only">Profile for {{ .account.Username -}}</h2> <h2 class="sr-only">Profile for {{ .account.Username -}}</h2>
@ -45,18 +117,14 @@
<div class="header-image-wrapper"> <div class="header-image-wrapper">
<img <img
src="{{- .account.Header -}}" src="{{- .account.Header -}}"
alt="Header for {{ .account.Username -}}" alt="{{- template "headerAlt" . -}}"
title="Header for {{ .account.Username -}}" title="{{- template "headerAlt" . -}}"
/> />
</div> </div>
<div class="basic-info"> <div class="basic-info">
<a class="avatar" href="{{- .account.Avatar -}}"> {{- with . }}
<img {{- include "avatar" . | indent 3 }}
src="{{- .account.Avatar -}}" {{- end }}
alt="Avatar for {{ .account.Username -}}"
title="Avatar for {{ .account.Username -}}"
/>
</a>
<dl class="namerole"> <dl class="namerole">
<dt class="sr-only">Display name</dt> <dt class="sr-only">Display name</dt>
<dd class="displayname text-cutoff"> <dd class="displayname text-cutoff">