Compare commits

...

5 Commits

Author SHA1 Message Date
Eugen Rochko
3ecae6b853
Merge 81bde8042d into 3b52dca405 2025-07-11 17:06:10 +00:00
Claire
3b52dca405
Fix quote attributes missing from Mastodon's context (#35354)
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
Chromatic / Run Chromatic (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Check formatting / lint (push) Waiting to run
JavaScript Linting / lint (push) Waiting to run
Ruby Linting / lint (push) Waiting to run
JavaScript Testing / test (push) Waiting to run
Historical data migration test / test (14-alpine) (push) Waiting to run
Historical data migration test / test (15-alpine) (push) Waiting to run
Historical data migration test / test (16-alpine) (push) Waiting to run
Historical data migration test / test (17-alpine) (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.2) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
2025-07-11 16:35:06 +00:00
Echo
853a0c466e
Make bio hashtags open the local page instead of the remote instance (#35349) 2025-07-11 15:18:34 +00:00
Claire
81bde8042d Fix FrozenError in BlurhashTranscoder 2025-06-04 09:17:16 +02:00
Eugen Rochko
38b3a35f25 Add color and blurhash extraction for profile pictures 2025-05-31 00:26:48 +02:00
23 changed files with 232 additions and 113 deletions

View File

@ -26,6 +26,12 @@ module ContextHelper
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' }, suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } }, attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
quote_requests: { 'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest' }, quote_requests: { 'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest' },
quotes: {
'quote' => 'https://w3id.org/fep/044f#quote',
'quoteUri' => 'http://fedibird.com/ns#quoteUri',
'_misskey_quote' => 'https://misskey-hub.net/ns#_misskey_quote',
'quoteAuthorization' => 'https://w3id.org/fep/044f#quoteAuthorization',
},
interaction_policies: { interaction_policies: {
'gts' => 'https://gotosocial.org/ns#', 'gts' => 'https://gotosocial.org/ns#',
'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' }, 'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' },

View File

@ -12,11 +12,21 @@ export interface ApiAccountRoleJSON {
name: string; name: string;
} }
export interface ApiMetaJSON {
colors?: {
background: string;
foreground: string;
accent: string;
};
}
// See app/serializers/rest/account_serializer.rb // See app/serializers/rest/account_serializer.rb
export interface BaseApiAccountJSON { export interface BaseApiAccountJSON {
acct: string; acct: string;
avatar: string; avatar: string;
avatar_static: string; avatar_static: string;
avatar_blurhash?: string;
avatar_meta?: ApiMetaJSON;
bot: boolean; bot: boolean;
created_at: string; created_at: string;
discoverable?: boolean; discoverable?: boolean;

View File

@ -16,37 +16,31 @@ exports[`<AvatarOverlay > renders a overlay avatar 1`] = `
className="account__avatar-overlay-base" className="account__avatar-overlay-base"
> >
<div <div
className="account__avatar" className="account__avatar account__avatar--loading"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
style={ style={
{ {
"height": "36px", "height": "36px",
"width": "36px", "width": "36px",
} }
} }
>
<img
alt="alice"
src="/static/alice.jpg"
/> />
</div> </div>
</div>
<div <div
className="account__avatar-overlay-overlay" className="account__avatar-overlay-overlay"
> >
<div <div
className="account__avatar" className="account__avatar account__avatar--loading"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
style={ style={
{ {
"height": "24px", "height": "24px",
"width": "24px", "width": "24px",
} }
} }
>
<img
alt="eve@blackhat.lair"
src="/static/eve.jpg"
/> />
</div> </div>
</div>
</div> </div>
`; `;

View File

@ -1,12 +1,30 @@
import { useCallback } from 'react';
import { useLinks } from 'mastodon/hooks/useLinks'; import { useLinks } from 'mastodon/hooks/useLinks';
export const AccountBio: React.FC<{ interface AccountBioProps {
note: string; note: string;
className: string; className: string;
}> = ({ note, className }) => { dropdownAccountId?: string;
const handleClick = useLinks(); }
if (note.length === 0 || note === '<p></p>') { export const AccountBio: React.FC<AccountBioProps> = ({
note,
className,
dropdownAccountId,
}) => {
const handleClick = useLinks(!!dropdownAccountId);
const handleNodeChange = useCallback(
(node: HTMLDivElement | null) => {
if (!dropdownAccountId || !node || node.childNodes.length === 0) {
return;
}
addDropdownToHashtags(node, dropdownAccountId);
},
[dropdownAccountId],
);
if (note.length === 0) {
return null; return null;
} }
@ -15,6 +33,28 @@ export const AccountBio: React.FC<{
className={`${className} translate`} className={`${className} translate`}
dangerouslySetInnerHTML={{ __html: note }} dangerouslySetInnerHTML={{ __html: note }}
onClickCapture={handleClick} onClickCapture={handleClick}
ref={handleNodeChange}
/> />
); );
}; };
function addDropdownToHashtags(node: HTMLElement | null, accountId: string) {
if (!node) {
return;
}
for (const childNode of node.childNodes) {
if (!(childNode instanceof HTMLElement)) {
continue;
}
if (
childNode instanceof HTMLAnchorElement &&
(childNode.classList.contains('hashtag') ||
childNode.innerText.startsWith('#')) &&
!childNode.dataset.menuHashtag
) {
childNode.dataset.menuHashtag = accountId;
} else if (childNode.childNodes.length > 0) {
addDropdownToHashtags(childNode, accountId);
}
}
}

View File

@ -3,13 +3,17 @@ import { useState, useCallback } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Blurhash } from 'mastodon/components/blurhash';
import { useHovering } from 'mastodon/hooks/useHovering'; import { useHovering } from 'mastodon/hooks/useHovering';
import { autoPlayGif } from 'mastodon/initial_state'; import { autoPlayGif, useBlurhash } from 'mastodon/initial_state';
import type { Account } from 'mastodon/models/account'; import type { Account } from 'mastodon/models/account';
interface Props { interface Props {
account: account:
| Pick<Account, 'id' | 'acct' | 'avatar' | 'avatar_static'> | Pick<
Account,
'id' | 'acct' | 'avatar' | 'avatar_static' | 'avatar_blurhash'
>
| undefined; // FIXME: remove `undefined` once we know for sure its always there | undefined; // FIXME: remove `undefined` once we know for sure its always there
size?: number; size?: number;
style?: React.CSSProperties; style?: React.CSSProperties;
@ -62,6 +66,14 @@ export const Avatar: React.FC<Props> = ({
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
style={style} style={style}
> >
{(loading || error) && account?.avatar_blurhash && (
<Blurhash
hash={account.avatar_blurhash}
className='account__avatar__preview'
dummy={!useBlurhash}
/>
)}
{src && !error && ( {src && !error && (
<img src={src} alt='' onLoad={handleLoad} onError={handleError} /> <img src={src} alt='' onLoad={handleLoad} onError={handleError} />
)} )}

View File

@ -1,3 +1,4 @@
import { Avatar } from 'mastodon/components/avatar';
import { useHovering } from 'mastodon/hooks/useHovering'; import { useHovering } from 'mastodon/hooks/useHovering';
import { autoPlayGif } from 'mastodon/initial_state'; import { autoPlayGif } from 'mastodon/initial_state';
import type { Account } from 'mastodon/models/account'; import type { Account } from 'mastodon/models/account';
@ -19,12 +20,6 @@ export const AvatarOverlay: React.FC<Props> = ({
}) => { }) => {
const { hovering, handleMouseEnter, handleMouseLeave } = const { hovering, handleMouseEnter, handleMouseLeave } =
useHovering(autoPlayGif); useHovering(autoPlayGif);
const accountSrc = hovering
? account?.get('avatar')
: account?.get('avatar_static');
const friendSrc = hovering
? friend?.get('avatar')
: friend?.get('avatar_static');
return ( return (
<div <div
@ -34,20 +29,19 @@ export const AvatarOverlay: React.FC<Props> = ({
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
> >
<div className='account__avatar-overlay-base'> <div className='account__avatar-overlay-base'>
<div <Avatar
className='account__avatar' account={account}
style={{ width: `${baseSize}px`, height: `${baseSize}px` }} size={baseSize}
> animate={hovering || autoPlayGif}
{accountSrc && <img src={accountSrc} alt={account?.get('acct')} />} />
</div>
</div> </div>
<div className='account__avatar-overlay-overlay'> <div className='account__avatar-overlay-overlay'>
<div <Avatar
className='account__avatar' account={friend}
style={{ width: `${overlaySize}px`, height: `${overlaySize}px` }} size={overlaySize}
> animate={hovering || autoPlayGif}
{friendSrc && <img src={friendSrc} alt={friend?.get('acct')} />} />
</div>
</div> </div>
</div> </div>
); );

View File

@ -74,9 +74,9 @@ export default class MediaAttachments extends ImmutablePureComponent {
width={width} width={width}
height={height} height={height}
poster={audio.get('preview_url') || status.getIn(['account', 'avatar_static'])} poster={audio.get('preview_url') || status.getIn(['account', 'avatar_static'])}
backgroundColor={audio.getIn(['meta', 'colors', 'background'])} backgroundColor={audio.getIn(['meta', 'colors', 'background']) ?? status.getIn(['account', 'avatar_meta', 'colors', 'background'])}
foregroundColor={audio.getIn(['meta', 'colors', 'foreground'])} foregroundColor={audio.getIn(['meta', 'colors', 'foreground']) ?? status.getIn(['account', 'avatar_meta', 'colors', 'foreground'])}
accentColor={audio.getIn(['meta', 'colors', 'accent'])} accentColor={audio.getIn(['meta', 'colors', 'accent']) ?? status.getIn(['account', 'avatar_meta', 'colors', 'accent'])}
duration={audio.getIn(['meta', 'original', 'duration'], 0)} duration={audio.getIn(['meta', 'original', 'duration'], 0)}
/> />
)} )}

View File

@ -483,9 +483,9 @@ class Status extends ImmutablePureComponent {
alt={description} alt={description}
lang={language} lang={language}
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])} poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])} backgroundColor={attachment.getIn(['meta', 'colors', 'background']) ?? status.getIn(['account', 'avatar_meta', 'colors', 'background'])}
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])} foregroundColor={attachment.getIn(['meta', 'colors', 'foreground']) ?? status.getIn(['account', 'avatar_meta', 'colors', 'foreground'])}
accentColor={attachment.getIn(['meta', 'colors', 'accent'])} accentColor={attachment.getIn(['meta', 'colors', 'accent']) ?? status.getIn(['account', 'avatar_meta', 'colors', 'accent'])}
duration={attachment.getIn(['meta', 'original', 'duration'], 0)} duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined} deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
sensitive={status.get('sensitive')} sensitive={status.get('sensitive')}

View File

@ -6,6 +6,7 @@ import classNames from 'classnames';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { AccountBio } from '@/mastodon/components/account_bio';
import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react'; import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
@ -773,7 +774,6 @@ export const AccountHeader: React.FC<{
); );
} }
const content = { __html: account.note_emojified };
const displayNameHtml = { __html: account.display_name_html }; const displayNameHtml = { __html: account.display_name_html };
const fields = account.fields; const fields = account.fields;
const isLocal = !account.acct.includes('@'); const isLocal = !account.acct.includes('@');
@ -897,12 +897,11 @@ export const AccountHeader: React.FC<{
<AccountNote accountId={accountId} /> <AccountNote accountId={accountId} />
)} )}
{account.note.length > 0 && account.note !== '<p></p>' && ( <AccountBio
<div note={account.note_emojified}
className='account__header__content translate' dropdownAccountId={accountId}
dangerouslySetInnerHTML={content} className='account__header__content'
/> />
)}
<div className='account__header__fields'> <div className='account__header__fields'>
<dl> <dl>

View File

@ -214,12 +214,19 @@ const Preview: React.FC<{
} }
duration={media.getIn(['meta', 'original', 'duration'], 0) as number} duration={media.getIn(['meta', 'original', 'duration'], 0) as number}
backgroundColor={ backgroundColor={
media.getIn(['meta', 'colors', 'background']) as string (media.getIn(['meta', 'colors', 'background']) as
| string
| undefined) ?? account?.avatar_meta.colors?.background
} }
foregroundColor={ foregroundColor={
media.getIn(['meta', 'colors', 'foreground']) as string (media.getIn(['meta', 'colors', 'foreground']) as
| string
| undefined) ?? account?.avatar_meta.colors?.foreground
}
accentColor={
(media.getIn(['meta', 'colors', 'accent']) as string | undefined) ??
account?.avatar_meta.colors?.accent
} }
accentColor={media.getIn(['meta', 'colors', 'accent']) as string}
editable editable
/> />
); );

View File

@ -194,9 +194,18 @@ export const DetailedStatus: React.FC<{
status.getIn(['account', 'avatar_static']) status.getIn(['account', 'avatar_static'])
} }
duration={attachment.getIn(['meta', 'original', 'duration'], 0)} duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])} backgroundColor={
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])} attachment.getIn(['meta', 'colors', 'background']) ??
accentColor={attachment.getIn(['meta', 'colors', 'accent'])} status.getIn(['account', 'avatar_meta', 'colors', 'background'])
}
foregroundColor={
attachment.getIn(['meta', 'colors', 'foreground']) ??
status.getIn(['account', 'avatar_meta', 'colors', 'foreground'])
}
accentColor={
attachment.getIn(['meta', 'colors', 'accent']) ??
status.getIn(['account', 'avatar_meta', 'colors', 'accent'])
}
sensitive={status.get('sensitive')} sensitive={status.get('sensitive')}
visible={showMedia} visible={showMedia}
blurhash={attachment.get('blurhash')} blurhash={attachment.get('blurhash')}

View File

@ -18,8 +18,8 @@ const AudioModal: React.FC<{
}> = ({ media, statusId, options, onClose, onChangeBackgroundColor }) => { }> = ({ media, statusId, options, onClose, onChangeBackgroundColor }) => {
const status = useAppSelector((state) => state.statuses.get(statusId)); const status = useAppSelector((state) => state.statuses.get(statusId));
const accountId = status?.get('account') as string | undefined; const accountId = status?.get('account') as string | undefined;
const accountStaticAvatar = useAppSelector((state) => const account = useAppSelector((state) =>
accountId ? state.accounts.get(accountId)?.avatar_static : undefined, accountId ? state.accounts.get(accountId) : undefined,
); );
useEffect(() => { useEffect(() => {
@ -47,16 +47,24 @@ const AudioModal: React.FC<{
alt={description} alt={description}
lang={language} lang={language}
poster={ poster={
(media.get('preview_url') as string | null) ?? accountStaticAvatar (media.get('preview_url') as string | null) ??
account?.avatar_static
} }
duration={media.getIn(['meta', 'original', 'duration'], 0) as number} duration={media.getIn(['meta', 'original', 'duration'], 0) as number}
backgroundColor={ backgroundColor={
media.getIn(['meta', 'colors', 'background']) as string (media.getIn(['meta', 'colors', 'background']) as
| string
| undefined) ?? account?.avatar_meta.colors?.background
} }
foregroundColor={ foregroundColor={
media.getIn(['meta', 'colors', 'foreground']) as string (media.getIn(['meta', 'colors', 'foreground']) as
| string
| undefined) ?? account?.avatar_meta.colors?.foreground
}
accentColor={
(media.getIn(['meta', 'colors', 'accent']) as string | undefined) ??
account?.avatar_meta.colors?.accent
} }
accentColor={media.getIn(['meta', 'colors', 'accent']) as string}
startPlaying={options.autoPlay} startPlaying={options.autoPlay}
/> />
</div> </div>

View File

@ -8,13 +8,14 @@ import { openURL } from 'mastodon/actions/search';
import { useAppDispatch } from 'mastodon/store'; import { useAppDispatch } from 'mastodon/store';
const isMentionClick = (element: HTMLAnchorElement) => const isMentionClick = (element: HTMLAnchorElement) =>
element.classList.contains('mention'); element.classList.contains('mention') &&
!element.classList.contains('hashtag');
const isHashtagClick = (element: HTMLAnchorElement) => const isHashtagClick = (element: HTMLAnchorElement) =>
element.textContent?.[0] === '#' || element.textContent?.[0] === '#' ||
element.previousSibling?.textContent?.endsWith('#'); element.previousSibling?.textContent?.endsWith('#');
export const useLinks = () => { export const useLinks = (skipHashtags?: boolean) => {
const history = useHistory(); const history = useHistory();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -61,12 +62,12 @@ export const useLinks = () => {
if (isMentionClick(target)) { if (isMentionClick(target)) {
e.preventDefault(); e.preventDefault();
void handleMentionClick(target); void handleMentionClick(target);
} else if (isHashtagClick(target)) { } else if (isHashtagClick(target) && !skipHashtags) {
e.preventDefault(); e.preventDefault();
handleHashtagClick(target); handleHashtagClick(target);
} }
}, },
[handleMentionClick, handleHashtagClick], [skipHashtags, handleMentionClick, handleHashtagClick],
); );
return handleClick; return handleClick;

View File

@ -63,6 +63,8 @@ export const accountDefaultValues: AccountShape = {
acct: '', acct: '',
avatar: '', avatar: '',
avatar_static: '', avatar_static: '',
avatar_blurhash: '',
avatar_meta: {},
bot: false, bot: false,
created_at: '', created_at: '',
discoverable: false, discoverable: false,
@ -126,6 +128,9 @@ export function createAccountFromServerJSON(serverJSON: ApiAccountJSON) {
? accountJSON.username ? accountJSON.username
: accountJSON.display_name; : accountJSON.display_name;
const accountNote =
accountJSON.note && accountJSON.note !== '<p></p>' ? accountJSON.note : '';
return AccountFactory({ return AccountFactory({
...accountJSON, ...accountJSON,
moved: moved?.id, moved: moved?.id,
@ -142,8 +147,8 @@ export function createAccountFromServerJSON(serverJSON: ApiAccountJSON) {
escapeTextContentForBrowser(displayName), escapeTextContentForBrowser(displayName),
emojiMap, emojiMap,
), ),
note_emojified: emojify(accountJSON.note, emojiMap), note_emojified: emojify(accountNote, emojiMap),
note_plain: unescapeHTML(accountJSON.note), note_plain: unescapeHTML(accountNote),
url: url:
accountJSON.url.startsWith('http://') || accountJSON.url.startsWith('http://') ||
accountJSON.url.startsWith('https://') accountJSON.url.startsWith('https://')

View File

@ -2178,6 +2178,16 @@ body > [data-popper-placement] {
display: inline-block; // to not show broken images display: inline-block; // to not show broken images
} }
&__preview {
position: absolute;
top: 0;
inset-inline-start: 0;
width: 100%;
height: 100%;
border-radius: var(--avatar-border-radius);
object-fit: cover;
}
&--loading { &--loading {
background-color: var(--surface-background-color); background-color: var(--surface-background-color);
} }

View File

@ -5,52 +5,54 @@
# Table name: accounts # Table name: accounts
# #
# id :bigint(8) not null, primary key # id :bigint(8) not null, primary key
# username :string default(""), not null # actor_type :string
# domain :string # also_known_as :string is an Array
# private_key :text # attribution_domains :string default([]), is an Array
# public_key :text default(""), not null # avatar_blurhash :string
# created_at :datetime not null
# updated_at :datetime not null
# note :text default(""), not null
# display_name :string default(""), not null
# uri :string default(""), not null
# url :string
# avatar_file_name :string
# avatar_content_type :string # avatar_content_type :string
# avatar_file_name :string
# avatar_file_size :integer # avatar_file_size :integer
# avatar_updated_at :datetime # avatar_meta :json
# header_file_name :string
# header_content_type :string
# header_file_size :integer
# header_updated_at :datetime
# avatar_remote_url :string # avatar_remote_url :string
# locked :boolean default(FALSE), not null # avatar_storage_schema_version :integer
# header_remote_url :string default(""), not null # avatar_updated_at :datetime
# last_webfingered_at :datetime # discoverable :boolean
# inbox_url :string default(""), not null # display_name :string default(""), not null
# outbox_url :string default(""), not null # domain :string
# shared_inbox_url :string default(""), not null
# followers_url :string default(""), not null
# protocol :integer default("ostatus"), not null
# memorial :boolean default(FALSE), not null
# moved_to_account_id :bigint(8)
# featured_collection_url :string # featured_collection_url :string
# fields :jsonb # fields :jsonb
# actor_type :string # followers_url :string default(""), not null
# discoverable :boolean # header_content_type :string
# also_known_as :string is an Array # header_file_name :string
# header_file_size :integer
# header_remote_url :string default(""), not null
# header_storage_schema_version :integer
# header_updated_at :datetime
# hide_collections :boolean
# inbox_url :string default(""), not null
# indexable :boolean default(FALSE), not null
# last_webfingered_at :datetime
# locked :boolean default(FALSE), not null
# memorial :boolean default(FALSE), not null
# note :text default(""), not null
# outbox_url :string default(""), not null
# private_key :text
# protocol :integer default("ostatus"), not null
# public_key :text default(""), not null
# requested_review_at :datetime
# reviewed_at :datetime
# sensitized_at :datetime
# shared_inbox_url :string default(""), not null
# silenced_at :datetime # silenced_at :datetime
# suspended_at :datetime # suspended_at :datetime
# hide_collections :boolean
# avatar_storage_schema_version :integer
# header_storage_schema_version :integer
# suspension_origin :integer # suspension_origin :integer
# sensitized_at :datetime
# trendable :boolean # trendable :boolean
# reviewed_at :datetime # uri :string default(""), not null
# requested_review_at :datetime # url :string
# indexable :boolean default(FALSE), not null # username :string default(""), not null
# attribution_domains :string default([]), is an Array # created_at :datetime not null
# updated_at :datetime not null
# moved_to_account_id :bigint(8)
# #
class Account < ApplicationRecord class Account < ApplicationRecord

View File

@ -11,7 +11,14 @@ module Account::Avatar
class_methods do class_methods do
def avatar_styles(file) def avatar_styles(file)
styles = { original: { geometry: "#{AVATAR_GEOMETRY}#", file_geometry_parser: FastGeometryParser } } styles = { original: { geometry: "#{AVATAR_GEOMETRY}#", file_geometry_parser: FastGeometryParser } }
styles[:static] = { geometry: "#{AVATAR_GEOMETRY}#", format: 'png', convert_options: '-coalesce', file_geometry_parser: FastGeometryParser } if file.content_type == 'image/gif'
# Color and blurhash extraction must only be run on non-animated files
if file.content_type == 'image/gif'
styles[:static] = { geometry: "#{AVATAR_GEOMETRY}#", format: 'png', convert_options: '-coalesce', file_geometry_parser: FastGeometryParser, blurhash: { x_comp: 3, y_comp: 3, attribute_name: :avatar_blurhash }, extract_colors: { meta_attribute_name: :avatar_meta } }
else
styles[:original].merge!(blurhash: { x_comp: 3, y_comp: 3, attribute_name: :avatar_blurhash }, extract_colors: { meta_attribute_name: :avatar_meta })
end
styles styles
end end
@ -20,7 +27,7 @@ module Account::Avatar
included do included do
# Avatar upload # Avatar upload
has_attached_file :avatar, styles: ->(f) { avatar_styles(f) }, convert_options: { all: '+profile "!icc,*" +set date:modify +set date:create +set date:timestamp' }, processors: [:lazy_thumbnail] has_attached_file :avatar, styles: ->(f) { avatar_styles(f) }, convert_options: { all: '+profile "!icc,*" +set date:modify +set date:create +set date:timestamp' }, processors: [:lazy_thumbnail, :blurhash_transcoder, :color_extractor]
validates_attachment_content_type :avatar, content_type: AVATAR_IMAGE_MIME_TYPES validates_attachment_content_type :avatar, content_type: AVATAR_IMAGE_MIME_TYPES
validates_attachment_size :avatar, less_than: AVATAR_LIMIT validates_attachment_size :avatar, less_than: AVATAR_LIMIT
remotable_attachment :avatar, AVATAR_LIMIT, suppress_errors: false remotable_attachment :avatar, AVATAR_LIMIT, suppress_errors: false

View File

@ -166,7 +166,7 @@ class MediaAttachment < ApplicationRecord
}.freeze }.freeze
THUMBNAIL_STYLES = { THUMBNAIL_STYLES = {
original: IMAGE_STYLES[:small].freeze, original: IMAGE_STYLES[:small].merge(extract_colors: { meta_attribute_name: :file_meta }.freeze).freeze,
}.freeze }.freeze
DEFAULT_STYLES = [:original].freeze DEFAULT_STYLES = [:original].freeze

View File

@ -8,7 +8,7 @@ class REST::AccountSerializer < ActiveModel::Serializer
attributes :id, :username, :acct, :display_name, :locked, :bot, :discoverable, :indexable, :group, :created_at, attributes :id, :username, :acct, :display_name, :locked, :bot, :discoverable, :indexable, :group, :created_at,
:note, :url, :uri, :avatar, :avatar_static, :header, :header_static, :note, :url, :uri, :avatar, :avatar_static, :header, :header_static,
:followers_count, :following_count, :statuses_count, :last_status_at, :hide_collections :followers_count, :following_count, :statuses_count, :last_status_at, :hide_collections, :avatar_meta, :avatar_blurhash
has_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved_and_not_nested? has_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved_and_not_nested?

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class AddAvatarMetaToAccounts < ActiveRecord::Migration[8.0]
def change
safety_assured { add_column :accounts, :avatar_meta, :json }
add_column :accounts, :avatar_blurhash, :string
end
end

View File

@ -198,6 +198,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_132728) do
t.datetime "requested_review_at", precision: nil t.datetime "requested_review_at", precision: nil
t.boolean "indexable", default: false, null: false t.boolean "indexable", default: false, null: false
t.string "attribution_domains", default: [], array: true t.string "attribution_domains", default: [], array: true
t.json "avatar_meta"
t.string "avatar_blurhash"
t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin
t.index "lower((username)::text), COALESCE(lower((domain)::text), ''::text)", name: "index_accounts_on_username_and_domain_lower", unique: true t.index "lower((username)::text), COALESCE(lower((domain)::text), ''::text)", name: "index_accounts_on_username_and_domain_lower", unique: true
t.index ["domain", "id"], name: "index_accounts_on_domain_and_id" t.index ["domain", "id"], name: "index_accounts_on_domain_and_id"

View File

@ -3,13 +3,15 @@
module Paperclip module Paperclip
class BlurhashTranscoder < Paperclip::Processor class BlurhashTranscoder < Paperclip::Processor
def make def make
return @file unless options[:style] == :small || options[:blurhash] return @file unless options[:blurhash]
width, height, data = blurhash_params width, height, data = blurhash_params
attribute_name = options[:blurhash][:attribute_name] || :blurhash
# Guard against segfaults if data has unexpected size # Guard against segfaults if data has unexpected size
raise RangeError, "Invalid image data size (expected #{width * height * 3}, got #{data.size})" if data.size != width * height * 3 # TODO: should probably be another exception type raise RangeError, "Invalid image data size (expected #{width * height * 3}, got #{data.size})" if data.size != width * height * 3 # TODO: should probably be another exception type
attachment.instance.blurhash = Blurhash.encode(width, height, data, **(options[:blurhash] || {})) attachment.instance.public_send("#{attribute_name}=", Blurhash.encode(width, height, data, **(options[:blurhash]&.without(:attribute_name) || {})))
@file @file
rescue Vips::Error => e rescue Vips::Error => e

View File

@ -10,6 +10,8 @@ module Paperclip
BINS = 10 BINS = 10
def make def make
return @file unless options.key?(:extract_colors)
background_palette, foreground_palette = Rails.configuration.x.use_vips ? palettes_from_libvips : palettes_from_imagemagick background_palette, foreground_palette = Rails.configuration.x.use_vips ? palettes_from_libvips : palettes_from_imagemagick
background_color = background_palette.first || foreground_palette.first background_color = background_palette.first || foreground_palette.first
@ -66,7 +68,8 @@ module Paperclip
}, },
} }
attachment.instance.file.instance_write(:meta, (attachment.instance.file.instance_read(:meta) || {}).merge(meta)) meta_attribute_name = options[:extract_colors][:meta_attribute_name] || "#{attachment.name}_meta"
attachment.instance.public_send("#{meta_attribute_name}=", (attachment.instance.public_send(meta_attribute_name) || {}).merge(meta))
@file @file
rescue Vips::Error => e rescue Vips::Error => e