mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2024-12-04 17:42:46 +00:00
[feature/frontend] Respect prefers-reduced-motion
for avatars, headers, and emojis
This commit is contained in:
parent
50c9b5498b
commit
a25e00a12a
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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"`
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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" `)
|
||||
// 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"/>`)
|
||||
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(`/>`)
|
||||
|
||||
// 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(`:" `)
|
||||
// Limit size to avoid showing
|
||||
// huge emojis in RSS readers.
|
||||
buf.WriteString(`width="25" height="25"/>`)
|
||||
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(`/>`)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
@ -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()
|
||||
},
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
nil, // No authed requester.
|
||||
statusfilter.FilterContextNone,
|
||||
nil, // No filters.
|
||||
nil, // No mutes.
|
||||
apiStatus, err := c.statusToFrontend(ctx, s,
|
||||
nil, // No authed requester.
|
||||
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,
|
||||
Status: apiStatus,
|
||||
Account: webAccount,
|
||||
}
|
||||
|
||||
// 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 {
|
||||
ogAttachment := ogAttachments[apiAttachment.ID]
|
||||
webStatus.MediaAttachments[i] = &apimodel.WebAttachment{
|
||||
Attachment: apiAttachment,
|
||||
Sensitive: apiStatus.Sensitive,
|
||||
MIMEType: ogAttachment.File.ContentType,
|
||||
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,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 {
|
||||
return nil, gtserror.Newf("error converting status author: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
repliesCount, err := c.state.DB.CountStatusReplies(ctx, s.ID)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -187,18 +187,20 @@ input, select, textarea, .input {
|
|||
margin: -0.2em 0.02em 0;
|
||||
object-fit: contain;
|
||||
vertical-align: middle;
|
||||
transition: 0.1s;
|
||||
|
||||
/*
|
||||
Enlarge emojis on hover to give
|
||||
viewer a good look at them.
|
||||
*/
|
||||
&:hover, &:active {
|
||||
transform: scale(2);
|
||||
background-color: $bg;
|
||||
box-shadow: $boxshadow;
|
||||
border: $boxshadow-border;
|
||||
border-radius: $br-inner;
|
||||
|
||||
@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;
|
||||
box-shadow: $boxshadow;
|
||||
border: $boxshadow-border;
|
||||
border-radius: $br-inner;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -193,14 +193,6 @@ main {
|
|||
font-size: 1rem;
|
||||
line-height: initial;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
margin: 5px auto;
|
||||
}
|
||||
img[alt~="!center"] {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.poll {
|
||||
|
|
|
@ -94,14 +94,26 @@
|
|||
alt="{{- template "avatarAlt" . -}}"
|
||||
title="{{- template "avatarAlt" . -}}"
|
||||
>
|
||||
<img
|
||||
class="avatar"
|
||||
src="{{- .account.Avatar -}}"
|
||||
alt="{{- template "avatarAlt" . -}}"
|
||||
title="{{- template "avatarAlt" . -}}"
|
||||
width="{{- template "avatarWidth" . -}}"
|
||||
height="{{- template "avatarHeight" . -}}"
|
||||
/>
|
||||
<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 -}}"
|
||||
alt="{{- template "avatarAlt" . -}}"
|
||||
title="{{- template "avatarAlt" . -}}"
|
||||
width="{{- template "avatarWidth" . -}}"
|
||||
height="{{- template "avatarHeight" . -}}"
|
||||
/>
|
||||
</picture>
|
||||
</a>
|
||||
</div>
|
||||
{{- end }}
|
||||
|
@ -115,11 +127,20 @@
|
|||
{{- include "profileMovedTo" . | indent 2 }}
|
||||
{{- end }}
|
||||
<div class="header-image-wrapper">
|
||||
<img
|
||||
src="{{- .account.Header -}}"
|
||||
alt="{{- template "headerAlt" . -}}"
|
||||
title="{{- template "headerAlt" . -}}"
|
||||
/>
|
||||
<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 . }}
|
||||
|
|
|
@ -32,13 +32,23 @@
|
|||
title="Open remote profile (opens in a new window)"
|
||||
>
|
||||
{{- end }}
|
||||
<img
|
||||
<picture
|
||||
class="avatar"
|
||||
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">
|
||||
<span class="displayname text-cutoff">
|
||||
{{- if .DisplayName -}}
|
||||
|
|
Loading…
Reference in a new issue