mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-31 09:52:26 -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 |                 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 | ||||||
|  |  | ||||||
|  | @ -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 && | ||||||
|  |  | ||||||
|  | @ -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, | ||||||
|  |  | ||||||
|  | @ -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, | ||||||
|  |  | ||||||
|  | @ -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, | ||||||
|  |  | ||||||
|  | @ -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. | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -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 { | ||||||
|  |  | ||||||
|  | @ -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, | ||||||
|  |  | ||||||
|  | @ -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, | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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, | ||||||
|  |  | ||||||
|  | @ -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; | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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> | ||||||
|  |  | ||||||
|  | @ -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 { | ||||||
|  |  | ||||||
|  | @ -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"> | ||||||
|  |  | ||||||
|  | @ -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"> | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue