[feature/frontend] Respect prefers-reduced-motion for avatars, headers, and emojis

This commit is contained in:
tobi 2024-07-20 13:27:18 +02:00
parent 50c9b5498b
commit a25e00a12a
13 changed files with 222 additions and 98 deletions

View file

@ -110,17 +110,27 @@ 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 // WebAccount is like Account, but with
// (used only internally for templating etc). // additional fields not exposed via JSON;
// used only internally for templating etc.
//
// swagger:ignore
type WebAccount struct {
*Account
// Proper attachment model for the avatar. // Proper attachment model for the avatar.
// //
// Only set if this model was converted via // Only set if this account had an avatar set
// AccountToWebAccount, AND this account had // (and not just the default "blank" image.)
// an avatar set (and not just the default AvatarAttachment *WebAttachment `json:"-"`
// "blank" avatar image.)
AvatarAttachment *Attachment `json:"-"` // Proper attachment model for the header.
//
// Only set if this account had a header set
// (and not just the default "blank" image.)
HeaderAttachment *WebAttachment `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.

View file

@ -107,6 +107,10 @@ type WebAttachment struct {
// MIME type of // MIME type of
// the attachment. // the attachment.
MIMEType string MIMEType string
// MIME type of
// the thumbnail.
PreviewMIMEType string
} }
// MediaMeta models media metadata. // MediaMeta models media metadata.

View file

@ -113,6 +113,9 @@ type Status struct {
type WebStatus struct { type WebStatus struct {
*Status *Status
// Override API account with web account.
Account *WebAccount `json:"account"`
// Web version of media // Web version of media
// attached to this status. // attached to this status.
MediaAttachments []*WebAttachment `json:"media_attachments"` MediaAttachments []*WebAttachment `json:"media_attachments"`

View file

@ -84,7 +84,7 @@ func OGBase(instance *apimodel.InstanceV1) *OGMeta {
// WithAccount uses the given account to build an ogMeta // WithAccount uses the given account to build an ogMeta
// struct specific to that account. It's suitable for serving // struct specific to that account. It's suitable for serving
// at account profile pages. // at account profile pages.
func (og *OGMeta) WithAccount(account *apimodel.Account) *OGMeta { func (og *OGMeta) WithAccount(account *apimodel.WebAccount) *OGMeta {
og.Title = AccountTitle(account, og.SiteName) og.Title = AccountTitle(account, og.SiteName)
og.Type = "profile" og.Type = "profile"
og.URL = account.URL og.URL = account.URL
@ -148,7 +148,7 @@ func (og *OGMeta) WithStatus(status *apimodel.WebStatus) *OGMeta {
} }
// AccountTitle parses a page title from account and accountDomain // AccountTitle parses a page title from account and accountDomain
func AccountTitle(account *apimodel.Account, accountDomain string) string { func AccountTitle(account *apimodel.WebAccount, accountDomain string) string {
user := "@" + account.Acct + "@" + accountDomain user := "@" + account.Acct + "@" + accountDomain
if len(account.DisplayName) == 0 { if len(account.DisplayName) == 0 {

View file

@ -51,13 +51,15 @@ func (suite *OpenGraphTestSuite) TestWithAccountWithNote() {
Languages: []string{"en"}, Languages: []string{"en"},
}) })
accountMeta := baseMeta.WithAccount(&apimodel.Account{ acct := &apimodel.Account{
Acct: "example_account", Acct: "example_account",
DisplayName: "example person!!", DisplayName: "example person!!",
URL: "https://example.org/@example_account", URL: "https://example.org/@example_account",
Note: "<p>This is my profile, read it and weep! Weep then!</p>", Note: "<p>This is my profile, read it and weep! Weep then!</p>",
Username: "example_account", Username: "example_account",
}) }
accountMeta := baseMeta.WithAccount(&apimodel.WebAccount{Account: acct})
suite.EqualValues(OGMeta{ suite.EqualValues(OGMeta{
Title: "example person!!, @example_account@example.org", Title: "example person!!, @example_account@example.org",
@ -84,13 +86,15 @@ func (suite *OpenGraphTestSuite) TestWithAccountNoNote() {
Languages: []string{"en"}, Languages: []string{"en"},
}) })
accountMeta := baseMeta.WithAccount(&apimodel.Account{ acct := &apimodel.Account{
Acct: "example_account", Acct: "example_account",
DisplayName: "example person!!", DisplayName: "example person!!",
URL: "https://example.org/@example_account", URL: "https://example.org/@example_account",
Note: "", // <- empty Note: "", // <- empty
Username: "example_account", Username: "example_account",
}) }
accountMeta := baseMeta.WithAccount(&apimodel.WebAccount{Account: acct})
suite.EqualValues(OGMeta{ suite.EqualValues(OGMeta{
Title: "example person!!, @example_account@example.org", Title: "example person!!, @example_account@example.org",

View file

@ -98,7 +98,7 @@ func (p *Processor) Get(ctx context.Context, requestingAccount *gtsmodel.Account
} }
// GetWeb returns the web model of a local account by username. // GetWeb returns the web model of a local account by username.
func (p *Processor) GetWeb(ctx context.Context, username string) (*apimodel.Account, gtserror.WithCode) { func (p *Processor) GetWeb(ctx context.Context, username string) (*apimodel.WebAccount, gtserror.WithCode) {
targetAccount, err := p.state.DB.GetAccountByUsernameDomain(ctx, username, "") targetAccount, err := p.state.DB.GetAccountByUsernameDomain(ctx, username, "")
if err != nil { if err != nil {
if errors.Is(err, db.ErrNoEntries) { if errors.Is(err, db.ErrNoEntries) {

View file

@ -32,20 +32,41 @@ func EmojifyWeb(emojis []apimodel.Emoji, html template.HTML) template.HTML {
out := emojify( out := emojify(
emojis, emojis,
string(html), string(html),
func(url, code string, buf *bytes.Buffer) { func(url, staticURL, code string, buf *bytes.Buffer) {
buf.WriteString(`<img src="`) // Open a picture tag so we
buf.WriteString(url) // can present multiple options.
buf.WriteString(`" title=":`) buf.WriteString(`<picture>`)
buf.WriteString(code)
buf.WriteString(`:" alt=":`) // Static version.
buf.WriteString(code) buf.WriteString(`<source `)
buf.WriteString(`:" class="emoji" `) {
// Lazy load emojis when buf.WriteString(`class="emoji" `)
// they scroll into view. buf.WriteString(`srcset="` + staticURL + `" `)
buf.WriteString(`loading="lazy" `) buf.WriteString(`type="image/png" `)
// Limit size to avoid showing // Limit size to avoid showing
// huge emojis when unstyled. // huge emojis when unstyled.
buf.WriteString(`width="25" height="25"/>`) buf.WriteString(`width="25" height="25" `)
}
buf.WriteString(`/>`)
// Original image source.
buf.WriteString(`<img `)
{
buf.WriteString(`class="emoji" `)
buf.WriteString(`src="` + url + `" `)
buf.WriteString(`title=":` + code + `:" `)
buf.WriteString(`alt=":` + code + `:" `)
// Lazy load emojis when
// they scroll into view.
buf.WriteString(`loading="lazy" `)
// Limit size to avoid showing
// huge emojis when unstyled.
buf.WriteString(`width="25" height="25" `)
}
buf.WriteString(`/>`)
// Close the picture tag.
buf.WriteString(`</picture>`)
}, },
) )
@ -60,17 +81,18 @@ func EmojifyRSS(emojis []apimodel.Emoji, text string) string {
return emojify( return emojify(
emojis, emojis,
text, text,
func(url, code string, buf *bytes.Buffer) { func(url, staticURL, code string, buf *bytes.Buffer) {
buf.WriteString(`<img src="`) // Original image source.
buf.WriteString(url) buf.WriteString(`<img `)
buf.WriteString(`" title=":`) {
buf.WriteString(code) buf.WriteString(`src="` + url + `" `)
buf.WriteString(`:" alt=":`) buf.WriteString(`title=":` + code + `:" `)
buf.WriteString(code) buf.WriteString(`alt=":` + code + `:" `)
buf.WriteString(`:" `) // Limit size to avoid showing
// Limit size to avoid showing // huge emojis in RSS readers.
// huge emojis in RSS readers. buf.WriteString(`width="25" height="25" `)
buf.WriteString(`width="25" height="25"/>`) }
buf.WriteString(`/>`)
}, },
) )
} }
@ -85,7 +107,7 @@ func Demojify(text string) string {
func emojify( func emojify(
emojis []apimodel.Emoji, emojis []apimodel.Emoji,
input string, input string,
write func(url, code string, buf *bytes.Buffer), write func(url, staticURL, code string, buf *bytes.Buffer),
) string { ) string {
// Build map of shortcodes. Normalize each // Build map of shortcodes. Normalize each
// shortcode by readding closing colons. // shortcode by readding closing colons.
@ -107,10 +129,11 @@ func(shortcode string, buf *bytes.Buffer) string {
// Escape raw emoji content. // Escape raw emoji content.
url := html.EscapeString(emoji.URL) url := html.EscapeString(emoji.URL)
staticURL := html.EscapeString(emoji.StaticURL)
code := html.EscapeString(emoji.Shortcode) code := html.EscapeString(emoji.Shortcode)
// Write emoji repr to buffer. // Write emoji repr to buffer.
write(url, code, buf) write(url, staticURL, code, buf)
return buf.String() return buf.String()
}, },
) )

View file

@ -170,22 +170,47 @@ func (c *Converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A
func (c *Converter) AccountToWebAccount( func (c *Converter) AccountToWebAccount(
ctx context.Context, ctx context.Context,
a *gtsmodel.Account, a *gtsmodel.Account,
) (*apimodel.Account, error) { ) (*apimodel.WebAccount, error) {
webAccount, err := c.AccountToAPIAccountPublic(ctx, a) apiAccount, err := c.AccountToAPIAccountPublic(ctx, a)
if err != nil { if err != nil {
return nil, err return nil, err
} }
webAccount := &apimodel.WebAccount{
Account: apiAccount,
}
// Set additional avatar information for // Set additional avatar information for
// serving the avatar in a nice photobox. // serving the avatar in a nice <picture>.
if a.AvatarMediaAttachment != nil { if ogAvi := a.AvatarMediaAttachment; ogAvi != nil {
avatarAttachment, err := c.AttachmentToAPIAttachment(ctx, a.AvatarMediaAttachment) avatarAttachment, err := c.AttachmentToAPIAttachment(ctx, ogAvi)
if err != nil { if err != nil {
// This is just extra data so just // This is just extra data so just
// log but don't return any error. // log but don't return any error.
log.Errorf(ctx, "error converting account avatar attachment: %v", err) log.Errorf(ctx, "error converting account avatar attachment: %v", err)
} else { } else {
webAccount.AvatarAttachment = &avatarAttachment webAccount.AvatarAttachment = &apimodel.WebAttachment{
Attachment: &avatarAttachment,
MIMEType: ogAvi.File.ContentType,
PreviewMIMEType: ogAvi.Thumbnail.ContentType,
}
}
}
// Set additional header information for
// serving the header in a nice <picture>.
if ogHeader := a.HeaderMediaAttachment; ogHeader != nil {
headerAttachment, err := c.AttachmentToAPIAttachment(ctx, ogHeader)
if err != nil {
// This is just extra data so just
// log but don't return any error.
log.Errorf(ctx, "error converting account header attachment: %v", err)
} else {
webAccount.HeaderAttachment = &apimodel.WebAttachment{
Attachment: &headerAttachment,
MIMEType: ogHeader.File.ContentType,
PreviewMIMEType: ogHeader.Thumbnail.ContentType,
}
} }
} }
@ -747,7 +772,15 @@ func (c *Converter) StatusToAPIStatus(
filters []*gtsmodel.Filter, filters []*gtsmodel.Filter,
mutes *usermute.CompiledUserMuteList, mutes *usermute.CompiledUserMuteList,
) (*apimodel.Status, error) { ) (*apimodel.Status, error) {
apiStatus, err := c.statusToFrontend(ctx, s, requestingAccount, filterContext, filters, mutes) apiStatus, err := c.statusToFrontend(
ctx,
s,
requestingAccount, // Can be nil.
filterContext, // Can be empty.
filters,
mutes,
false, // This is not a web status.
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -958,20 +991,25 @@ func (c *Converter) StatusToWebStatus(
ctx context.Context, ctx context.Context,
s *gtsmodel.Status, s *gtsmodel.Status,
) (*apimodel.WebStatus, error) { ) (*apimodel.WebStatus, error) {
apiStatus, err := c.statusToFrontend( apiStatus, err := c.statusToFrontend(ctx, s,
ctx, nil, // No authed requester.
s, statusfilter.FilterContextNone, // No filters.
nil, // No authed requester. nil, // No filters.
statusfilter.FilterContextNone, nil, // No mutes.
nil, // No filters. true, // Web status.
nil, // No mutes.
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
webAccount, err := c.AccountToWebAccount(ctx, s.Account)
if err != nil {
return nil, err
}
webStatus := &apimodel.WebStatus{ webStatus := &apimodel.WebStatus{
Status: apiStatus, Status: apiStatus,
Account: webAccount,
} }
// Whack a newline before and after each "pre" to make it easier to outdent it. // Whack a newline before and after each "pre" to make it easier to outdent it.
@ -1062,9 +1100,10 @@ func (c *Converter) StatusToWebStatus(
for i, apiAttachment := range apiStatus.MediaAttachments { for i, apiAttachment := range apiStatus.MediaAttachments {
ogAttachment := ogAttachments[apiAttachment.ID] ogAttachment := ogAttachments[apiAttachment.ID]
webStatus.MediaAttachments[i] = &apimodel.WebAttachment{ webStatus.MediaAttachments[i] = &apimodel.WebAttachment{
Attachment: apiAttachment, Attachment: apiAttachment,
Sensitive: apiStatus.Sensitive, Sensitive: apiStatus.Sensitive,
MIMEType: ogAttachment.File.ContentType, MIMEType: ogAttachment.File.ContentType,
PreviewMIMEType: ogAttachment.Thumbnail.ContentType,
} }
} }
@ -1097,6 +1136,7 @@ func (c *Converter) statusToFrontend(
filterContext statusfilter.FilterContext, filterContext statusfilter.FilterContext,
filters []*gtsmodel.Filter, filters []*gtsmodel.Filter,
mutes *usermute.CompiledUserMuteList, mutes *usermute.CompiledUserMuteList,
web bool,
) ( ) (
*apimodel.Status, *apimodel.Status,
error, error,
@ -1107,6 +1147,7 @@ func (c *Converter) statusToFrontend(
filterContext, filterContext,
filters, filters,
mutes, mutes,
web,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@ -1119,6 +1160,7 @@ func (c *Converter) statusToFrontend(
filterContext, filterContext,
filters, filters,
mutes, mutes,
web,
) )
if errors.Is(err, statusfilter.ErrHideStatus) { if errors.Is(err, statusfilter.ErrHideStatus) {
// If we'd hide the original status, hide the boost. // If we'd hide the original status, hide the boost.
@ -1149,6 +1191,7 @@ func (c *Converter) baseStatusToFrontend(
filterContext statusfilter.FilterContext, filterContext statusfilter.FilterContext,
filters []*gtsmodel.Filter, filters []*gtsmodel.Filter,
mutes *usermute.CompiledUserMuteList, mutes *usermute.CompiledUserMuteList,
web bool,
) ( ) (
*apimodel.Status, *apimodel.Status,
error, error,
@ -1169,9 +1212,21 @@ func (c *Converter) baseStatusToFrontend(
} }
} }
apiAuthorAccount, err := c.AccountToAPIAccountPublic(ctx, s.Account) var (
apiAuthorAccount *apimodel.Account
err error
)
// Only bother converting the author account if
// this is an API status and not a web status.
//
// If it's a web status, the web function will
// convert the account into a web account instead.
if !web {
apiAuthorAccount, err = c.AccountToAPIAccountPublic(ctx, s.Account)
if err != nil { if err != nil {
return nil, gtserror.Newf("error converting status author: %w", err) return nil, gtserror.Newf("error converting status author: %w", err)
}
} }
repliesCount, err := c.state.DB.CountStatusReplies(ctx, s.ID) repliesCount, err := c.state.DB.CountStatusReplies(ctx, s.ID)

View file

@ -108,7 +108,7 @@ func (m *Module) threadGETHandler(c *gin.Context) {
} }
// Ensure status actually belongs to target account. // Ensure status actually belongs to target account.
if context.Status.GetAccountID() != targetAccount.ID { if context.Status.Account.ID != targetAccount.ID {
err := fmt.Errorf("target account %s does not own status %s", targetUsername, targetStatusID) err := fmt.Errorf("target account %s does not own status %s", targetUsername, targetStatusID)
apiutil.WebErrorHandler(c, gtserror.NewErrorNotFound(err), instanceGet) apiutil.WebErrorHandler(c, gtserror.NewErrorNotFound(err), instanceGet)
return return

View file

@ -187,18 +187,20 @@ input, select, textarea, .input {
margin: -0.2em 0.02em 0; margin: -0.2em 0.02em 0;
object-fit: contain; object-fit: contain;
vertical-align: middle; vertical-align: middle;
transition: 0.1s;
@media (prefers-reduced-motion: no-preference) {
/* /*
Enlarge emojis on hover to give Enlarge emojis on hover to give
viewer a good look at them. viewer a good look at them.
*/ */
&:hover, &:active { transition: 0.1s;
transform: scale(2); &:hover, &:active {
background-color: $bg; transform: scale(2);
box-shadow: $boxshadow; background-color: $bg;
border: $boxshadow-border; box-shadow: $boxshadow;
border-radius: $br-inner; border: $boxshadow-border;
border-radius: $br-inner;
}
} }
} }

View file

@ -193,14 +193,6 @@ main {
font-size: 1rem; font-size: 1rem;
line-height: initial; line-height: initial;
} }
img {
max-width: 100%;
margin: 5px auto;
}
img[alt~="!center"] {
display: block;
}
} }
.poll { .poll {

View file

@ -94,14 +94,26 @@
alt="{{- template "avatarAlt" . -}}" alt="{{- template "avatarAlt" . -}}"
title="{{- template "avatarAlt" . -}}" title="{{- template "avatarAlt" . -}}"
> >
<img <picture
class="avatar" aria-hidden="true"
src="{{- .account.Avatar -}}" >
alt="{{- template "avatarAlt" . -}}" {{- if .account.AvatarAttachment }}
title="{{- template "avatarAlt" . -}}" <source
width="{{- template "avatarWidth" . -}}" class="avatar"
height="{{- template "avatarHeight" . -}}" srcset="{{- .account.AvatarStatic -}}"
/> type="{{- .account.AvatarAttachment.PreviewMIMEType -}}"
media="(prefers-reduced-motion: reduce)"
/>
{{- end }}
<img
class="avatar"
src="{{- .account.Avatar -}}"
alt="{{- template "avatarAlt" . -}}"
title="{{- template "avatarAlt" . -}}"
width="{{- template "avatarWidth" . -}}"
height="{{- template "avatarHeight" . -}}"
/>
</picture>
</a> </a>
</div> </div>
{{- end }} {{- end }}
@ -115,11 +127,20 @@
{{- include "profileMovedTo" . | indent 2 }} {{- include "profileMovedTo" . | indent 2 }}
{{- end }} {{- end }}
<div class="header-image-wrapper"> <div class="header-image-wrapper">
<img <picture>
src="{{- .account.Header -}}" {{- if .account.HeaderAttachment }}
alt="{{- template "headerAlt" . -}}" <source
title="{{- template "headerAlt" . -}}" srcset="{{- .account.HeaderStatic -}}"
/> type="{{- .account.HeaderAttachment.PreviewMIMEType -}}"
media="(prefers-reduced-motion: reduce)"
/>
{{- end }}
<img
src="{{- .account.Header -}}"
alt="{{- template "headerAlt" . -}}"
title="{{- template "headerAlt" . -}}"
/>
</picture>
</div> </div>
<div class="basic-info"> <div class="basic-info">
{{- with . }} {{- with . }}

View file

@ -32,13 +32,23 @@
title="Open remote profile (opens in a new window)" title="Open remote profile (opens in a new window)"
> >
{{- end }} {{- end }}
<img <picture
class="avatar" class="avatar"
aria-hidden="true" aria-hidden="true"
src="{{- .Avatar -}}"
alt="Avatar for {{ .Username -}}"
title="Avatar for {{ .Username -}}"
> >
{{- if .AvatarAttachment }}
<source
srcset="{{- .AvatarStatic -}}"
type="{{- .AvatarAttachment.PreviewMIMEType -}}"
media="(prefers-reduced-motion: reduce)"
/>
{{- end }}
<img
src="{{- .Avatar -}}"
alt="Avatar for {{ .Username -}}"
title="Avatar for {{ .Username -}}"
>
</picture>
<div class="author-strap"> <div class="author-strap">
<span class="displayname text-cutoff"> <span class="displayname text-cutoff">
{{- if .DisplayName -}} {{- if .DisplayName -}}