mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-31 04:12:25 -05:00 
			
		
		
		
	[feature/frontend] Allow setting alt-text for avatar + header (#3086)
This commit is contained in:
		
					parent
					
						
							
								43c480aec4
							
						
					
				
			
			
				commit
				
					
						d70f4e166d
					
				
			
		
					 18 changed files with 395 additions and 140 deletions
				
			
		|  | @ -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 | ||||
|  |  | |||
|  | @ -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 && | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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. | ||||
|  |  | |||
|  | @ -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) | ||||
| 	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 { | ||||
| 			return nil, gtserror.NewErrorInternalError(fmt.Errorf("error checking account block: %w", err)) | ||||
| 		err := gtserror.Newf("db error checking blocks: %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)) | ||||
| 			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 | ||||
| } | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -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 | ||||
|  | @ -306,8 +342,10 @@ func (c *Converter) accountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A | |||
| 		URL:               a.URL, | ||||
| 		Avatar:            aviURL, | ||||
| 		AvatarStatic:      aviURLStatic, | ||||
| 		AvatarDescription: aviDesc, | ||||
| 		Header:            headerURL, | ||||
| 		HeaderStatic:      headerURLStatic, | ||||
| 		HeaderDescription: headerDesc, | ||||
| 		FollowersCount:    followersCount, | ||||
| 		FollowingCount:    followingCount, | ||||
| 		StatusesCount:     statusesCount, | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 */ | ||||
| 			 | ||||
| 			img { | ||||
| 			/* | ||||
| 				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; | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
|  |  | |||
|  | @ -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"} /> | ||||
| 					<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> | ||||
|  |  | |||
|  | @ -400,12 +400,13 @@ section.with-sidebar > form { | |||
| 			width: 24rem; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| .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 { | ||||
|  |  | |||
|  | @ -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> | ||||
| 
 | ||||
| 				<div className="file-input-with-image-description"> | ||||
| 					<FileInput | ||||
| 						label="Header" | ||||
| 						field={form.header} | ||||
| 							accept="image/*" | ||||
| 						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> | ||||
| 				 | ||||
| 				<div className="file-input-with-image-description"> | ||||
| 					<FileInput | ||||
| 							label="Avatar" | ||||
| 						label="Avatar (1:1 images look best)" | ||||
| 						field={form.avatar} | ||||
| 							accept="image/*" | ||||
| 						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"> | ||||
|  |  | |||
|  | @ -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"> | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue