[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.
// 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).
// WebAccount is like Account, but with
// additional fields not exposed via JSON;
// used only internally for templating etc.
//
// swagger:ignore
type WebAccount struct {
*Account
// 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:"-"`
// Only set if this account had an avatar set
// (and not just the default "blank" image.)
AvatarAttachment *WebAttachment `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.

View file

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

View file

@ -113,6 +113,9 @@ type Status struct {
type WebStatus struct {
*Status
// Override API account with web account.
Account *WebAccount `json:"account"`
// Web version of media
// attached to this status.
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
// struct specific to that account. It's suitable for serving
// 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.Type = "profile"
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
func AccountTitle(account *apimodel.Account, accountDomain string) string {
func AccountTitle(account *apimodel.WebAccount, accountDomain string) string {
user := "@" + account.Acct + "@" + accountDomain
if len(account.DisplayName) == 0 {

View file

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

View file

@ -32,20 +32,41 @@ func EmojifyWeb(emojis []apimodel.Emoji, html template.HTML) template.HTML {
out := emojify(
emojis,
string(html),
func(url, code string, buf *bytes.Buffer) {
buf.WriteString(`<img src="`)
buf.WriteString(url)
buf.WriteString(`" title=":`)
buf.WriteString(code)
buf.WriteString(`:" alt=":`)
buf.WriteString(code)
buf.WriteString(`:" class="emoji" `)
func(url, staticURL, code string, buf *bytes.Buffer) {
// Open a picture tag so we
// can present multiple options.
buf.WriteString(`<picture>`)
// Static version.
buf.WriteString(`<source `)
{
buf.WriteString(`class="emoji" `)
buf.WriteString(`srcset="` + staticURL + `" `)
buf.WriteString(`type="image/png" `)
// Limit size to avoid showing
// huge emojis when unstyled.
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(`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(
emojis,
text,
func(url, code string, buf *bytes.Buffer) {
buf.WriteString(`<img src="`)
buf.WriteString(url)
buf.WriteString(`" title=":`)
buf.WriteString(code)
buf.WriteString(`:" alt=":`)
buf.WriteString(code)
buf.WriteString(`:" `)
func(url, staticURL, code string, buf *bytes.Buffer) {
// Original image source.
buf.WriteString(`<img `)
{
buf.WriteString(`src="` + url + `" `)
buf.WriteString(`title=":` + code + `:" `)
buf.WriteString(`alt=":` + code + `:" `)
// Limit size to avoid showing
// 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(
emojis []apimodel.Emoji,
input string,
write func(url, code string, buf *bytes.Buffer),
write func(url, staticURL, code string, buf *bytes.Buffer),
) string {
// Build map of shortcodes. Normalize each
// shortcode by readding closing colons.
@ -107,10 +129,11 @@ func(shortcode string, buf *bytes.Buffer) string {
// Escape raw emoji content.
url := html.EscapeString(emoji.URL)
staticURL := html.EscapeString(emoji.StaticURL)
code := html.EscapeString(emoji.Shortcode)
// Write emoji repr to buffer.
write(url, code, buf)
write(url, staticURL, code, buf)
return buf.String()
},
)

View file

@ -170,22 +170,47 @@ func (c *Converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A
func (c *Converter) AccountToWebAccount(
ctx context.Context,
a *gtsmodel.Account,
) (*apimodel.Account, error) {
webAccount, err := c.AccountToAPIAccountPublic(ctx, a)
) (*apimodel.WebAccount, error) {
apiAccount, err := c.AccountToAPIAccountPublic(ctx, a)
if err != nil {
return nil, err
}
webAccount := &apimodel.WebAccount{
Account: apiAccount,
}
// Set additional avatar information for
// serving the avatar in a nice photobox.
if a.AvatarMediaAttachment != nil {
avatarAttachment, err := c.AttachmentToAPIAttachment(ctx, a.AvatarMediaAttachment)
// serving the avatar in a nice <picture>.
if ogAvi := a.AvatarMediaAttachment; ogAvi != nil {
avatarAttachment, err := c.AttachmentToAPIAttachment(ctx, ogAvi)
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
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,
mutes *usermute.CompiledUserMuteList,
) (*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 {
return nil, err
}
@ -958,20 +991,25 @@ func (c *Converter) StatusToWebStatus(
ctx context.Context,
s *gtsmodel.Status,
) (*apimodel.WebStatus, error) {
apiStatus, err := c.statusToFrontend(
ctx,
s,
apiStatus, err := c.statusToFrontend(ctx, s,
nil, // No authed requester.
statusfilter.FilterContextNone,
statusfilter.FilterContextNone, // No filters.
nil, // No filters.
nil, // No mutes.
true, // Web status.
)
if err != nil {
return nil, err
}
webAccount, err := c.AccountToWebAccount(ctx, s.Account)
if err != nil {
return nil, err
}
webStatus := &apimodel.WebStatus{
Status: apiStatus,
Account: webAccount,
}
// Whack a newline before and after each "pre" to make it easier to outdent it.
@ -1065,6 +1103,7 @@ func (c *Converter) StatusToWebStatus(
Attachment: apiAttachment,
Sensitive: apiStatus.Sensitive,
MIMEType: ogAttachment.File.ContentType,
PreviewMIMEType: ogAttachment.Thumbnail.ContentType,
}
}
@ -1097,6 +1136,7 @@ func (c *Converter) statusToFrontend(
filterContext statusfilter.FilterContext,
filters []*gtsmodel.Filter,
mutes *usermute.CompiledUserMuteList,
web bool,
) (
*apimodel.Status,
error,
@ -1107,6 +1147,7 @@ func (c *Converter) statusToFrontend(
filterContext,
filters,
mutes,
web,
)
if err != nil {
return nil, err
@ -1119,6 +1160,7 @@ func (c *Converter) statusToFrontend(
filterContext,
filters,
mutes,
web,
)
if errors.Is(err, statusfilter.ErrHideStatus) {
// If we'd hide the original status, hide the boost.
@ -1149,6 +1191,7 @@ func (c *Converter) baseStatusToFrontend(
filterContext statusfilter.FilterContext,
filters []*gtsmodel.Filter,
mutes *usermute.CompiledUserMuteList,
web bool,
) (
*apimodel.Status,
error,
@ -1169,10 +1212,22 @@ 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 {
return nil, gtserror.Newf("error converting status author: %w", err)
}
}
repliesCount, err := c.state.DB.CountStatusReplies(ctx, s.ID)
if err != nil {

View file

@ -108,7 +108,7 @@ func (m *Module) threadGETHandler(c *gin.Context) {
}
// 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)
apiutil.WebErrorHandler(c, gtserror.NewErrorNotFound(err), instanceGet)
return

View file

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

View file

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

View file

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

View file

@ -32,13 +32,23 @@
title="Open remote profile (opens in a new window)"
>
{{- end }}
<img
<picture
class="avatar"
aria-hidden="true"
>
{{- 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">
<span class="displayname text-cutoff">
{{- if .DisplayName -}}