From 6af4696c70dd9ed2ff423edf6a1ff43fbdc04754 Mon Sep 17 00:00:00 2001 From: Claire Date: Wed, 28 Jan 2026 10:32:59 +0100 Subject: [PATCH 1/9] Add backend support for storing remote actors profile pic and header descriptions (#37634) --- FEDERATION.md | 1 + app/models/account.rb | 2 ++ app/models/concerns/account/avatar.rb | 2 ++ app/models/concerns/account/header.rb | 2 ++ app/serializers/rest/account_serializer.rb | 2 +- .../activitypub/process_account_service.rb | 28 +++++++++++++++---- ...1459_add_avatar_description_to_accounts.rb | 7 +++++ ...1820_add_header_description_to_accounts.rb | 7 +++++ db/schema.rb | 4 ++- 9 files changed, 47 insertions(+), 8 deletions(-) create mode 100644 db/migrate/20260127141459_add_avatar_description_to_accounts.rb create mode 100644 db/migrate/20260127141820_add_header_description_to_accounts.rb diff --git a/FEDERATION.md b/FEDERATION.md index d5a176807b..0ac44afc3c 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -67,3 +67,4 @@ The following table summarizes those limits. | Account `attributionDomains` | 256 | List will be truncated | | Account aliases (actor `alsoKnownAs`) | 256 | List will be truncated | | Custom emoji shortcode (`Emoji` `name`) | 2048 | Emoji will be rejected | +| Media and avatar/header descriptions (`name`/`summary`) | 1500 | Description will be truncated | diff --git a/app/models/account.rb b/app/models/account.rb index 32f6e39840..7623e2398c 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -9,6 +9,7 @@ # also_known_as :string is an Array # attribution_domains :string default([]), is an Array # avatar_content_type :string +# avatar_description :string default(""), not null # avatar_file_name :string # avatar_file_size :integer # avatar_remote_url :string @@ -23,6 +24,7 @@ # followers_url :string default(""), not null # following_url :string default(""), not null # header_content_type :string +# header_description :string default(""), not null # header_file_name :string # header_file_size :integer # header_remote_url :string default(""), not null diff --git a/app/models/concerns/account/avatar.rb b/app/models/concerns/account/avatar.rb index 0be4678881..945ba3592c 100644 --- a/app/models/concerns/account/avatar.rb +++ b/app/models/concerns/account/avatar.rb @@ -24,6 +24,8 @@ module Account::Avatar validates_attachment_content_type :avatar, content_type: AVATAR_IMAGE_MIME_TYPES validates_attachment_size :avatar, less_than: AVATAR_LIMIT remotable_attachment :avatar, AVATAR_LIMIT, suppress_errors: false + + validates :avatar_description, length: { maximum: MediaAttachment::MAX_DESCRIPTION_LENGTH } end def avatar_original_url diff --git a/app/models/concerns/account/header.rb b/app/models/concerns/account/header.rb index 066c42cb6c..1b70715a9e 100644 --- a/app/models/concerns/account/header.rb +++ b/app/models/concerns/account/header.rb @@ -25,6 +25,8 @@ module Account::Header validates_attachment_content_type :header, content_type: HEADER_IMAGE_MIME_TYPES validates_attachment_size :header, less_than: HEADER_LIMIT remotable_attachment :header, HEADER_LIMIT, suppress_errors: false + + validates :header_description, length: { maximum: MediaAttachment::MAX_DESCRIPTION_LENGTH } end def header_original_url diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb index 26715dd103..38ddd6ac4d 100644 --- a/app/serializers/rest/account_serializer.rb +++ b/app/serializers/rest/account_serializer.rb @@ -7,7 +7,7 @@ class REST::AccountSerializer < ActiveModel::Serializer # Please update `app/javascript/mastodon/api_types/accounts.ts` when making changes to the attributes 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, :avatar_description, :header, :header_static, :header_description, :followers_count, :following_count, :statuses_count, :last_status_at, :hide_collections has_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved_and_not_nested? diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index 6f4aa2fdb6..efdaa26974 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -142,14 +142,18 @@ class ActivityPub::ProcessAccountService < BaseService def set_fetchable_attributes! begin - @account.avatar_remote_url = image_url('icon') || '' unless skip_download? + avatar_url, avatar_description = image_url_and_description('icon') + @account.avatar_remote_url = avatar_url || '' unless skip_download? @account.avatar = nil if @account.avatar_remote_url.blank? + @account.avatar_description = avatar_description || '' rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS RedownloadAvatarWorker.perform_in(rand(30..600).seconds, @account.id) end begin - @account.header_remote_url = image_url('image') || '' unless skip_download? + header_url, header_description = image_url_and_description('image') + @account.header_remote_url = header_url || '' unless skip_download? @account.header = nil if @account.header_remote_url.blank? + @account.header_description = header_description || '' rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS RedownloadHeaderWorker.perform_in(rand(30..600).seconds, @account.id) end @@ -214,7 +218,7 @@ class ActivityPub::ProcessAccountService < BaseService end end - def image_url(key) + def image_url_and_description(key) value = first_of_value(@json[key]) return if value.nil? @@ -224,9 +228,21 @@ class ActivityPub::ProcessAccountService < BaseService return if value.nil? end - value = first_of_value(value['url']) if value.is_a?(Hash) && value['type'] == 'Image' - value = value['href'] if value.is_a?(Hash) - value if value.is_a?(String) + if value.is_a?(Hash) && value['type'] == 'Image' + url = first_of_value(value['url']) + url = url['href'] if url.is_a?(Hash) + description = value['summary'].presence || value['name'].presence + description = description.strip[0...MediaAttachment::MAX_DESCRIPTION_LENGTH] if description.present? + else + url = value + end + + url = url['href'] if url.is_a?(Hash) + + url = nil unless url.is_a?(String) + description = nil unless description.is_a?(String) + + [url, description] end def public_key diff --git a/db/migrate/20260127141459_add_avatar_description_to_accounts.rb b/db/migrate/20260127141459_add_avatar_description_to_accounts.rb new file mode 100644 index 0000000000..831342c50c --- /dev/null +++ b/db/migrate/20260127141459_add_avatar_description_to_accounts.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddAvatarDescriptionToAccounts < ActiveRecord::Migration[8.0] + def change + add_column :accounts, :avatar_description, :string, null: false, default: '' + end +end diff --git a/db/migrate/20260127141820_add_header_description_to_accounts.rb b/db/migrate/20260127141820_add_header_description_to_accounts.rb new file mode 100644 index 0000000000..74d03d5c85 --- /dev/null +++ b/db/migrate/20260127141820_add_header_description_to_accounts.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddHeaderDescriptionToAccounts < ActiveRecord::Migration[8.0] + def change + add_column :accounts, :header_description, :string, null: false, default: '' + end +end diff --git a/db/schema.rb b/db/schema.rb index 8801882808..9080de8fb8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_01_19_153538) do +ActiveRecord::Schema[8.0].define(version: 2026_01_27_141820) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -201,6 +201,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_01_19_153538) do t.string "following_url", default: "", null: false t.integer "id_scheme", default: 1 t.integer "feature_approval_policy", default: 0, null: false + t.string "avatar_description", default: "", null: false + t.string "header_description", default: "", null: false 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 ["domain", "id"], name: "index_accounts_on_domain_and_id" From d438161b9b04eda6513adb9b74e8bb5f6380ecf5 Mon Sep 17 00:00:00 2001 From: Shlee Date: Wed, 28 Jan 2026 20:44:03 +1030 Subject: [PATCH 2/9] Unclosed connection leak when replacing pooled connection in SharedTimedStack.try_create (#37335) --- app/lib/connection_pool/shared_timed_stack.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/lib/connection_pool/shared_timed_stack.rb b/app/lib/connection_pool/shared_timed_stack.rb index 14a5285c45..f1ace33017 100644 --- a/app/lib/connection_pool/shared_timed_stack.rb +++ b/app/lib/connection_pool/shared_timed_stack.rb @@ -71,6 +71,7 @@ class ConnectionPool::SharedTimedStack throw_away_connection = @queue.pop @tagged_queue[throw_away_connection.site].delete(throw_away_connection) @create_block.call(preferred_tag) + throw_away_connection.close elsif @created != @max connection = @create_block.call(preferred_tag) @created += 1 From 3f460340391f19d1cabd5320f0c227fc6ed693f4 Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 28 Jan 2026 11:17:11 +0100 Subject: [PATCH 3/9] Tags component (#37638) --- .../mastodon/components/tags/style.module.css | 50 ++++++++++ .../mastodon/components/tags/tag.stories.tsx | 46 +++++++++ .../mastodon/components/tags/tag.tsx | 94 +++++++++++++++++++ .../mastodon/components/tags/tags.stories.tsx | 29 ++++++ .../mastodon/components/tags/tags.tsx | 54 +++++++++++ app/javascript/mastodon/locales/en.json | 1 + 6 files changed, 274 insertions(+) create mode 100644 app/javascript/mastodon/components/tags/style.module.css create mode 100644 app/javascript/mastodon/components/tags/tag.stories.tsx create mode 100644 app/javascript/mastodon/components/tags/tag.tsx create mode 100644 app/javascript/mastodon/components/tags/tags.stories.tsx create mode 100644 app/javascript/mastodon/components/tags/tags.tsx diff --git a/app/javascript/mastodon/components/tags/style.module.css b/app/javascript/mastodon/components/tags/style.module.css new file mode 100644 index 0000000000..1492b67c88 --- /dev/null +++ b/app/javascript/mastodon/components/tags/style.module.css @@ -0,0 +1,50 @@ +.tag { + border-radius: 9999px; + border: 1px solid var(--color-border-primary); + appearance: none; + background: none; + padding: 8px; + transition: all 0.2s ease-in-out; + color: var(--color-text-primary); + display: inline-flex; + align-items: center; + gap: 2px; +} + +button.tag:hover, +button.tag:focus { + border-color: var(--color-bg-brand-base-hover); +} + +button.tag:focus-visible { + outline: var(--outline-focus-default); + outline-offset: 2px; +} + +.active { + border-color: var(--color-text-brand); + background: var(--color-bg-brand-softer); + color: var(--color-text-brand); +} + +.icon { + height: 1.2em; + width: 1.2em; +} + +.closeButton { + svg { + height: 1.2em; + width: 1.2em; + } +} + +.active .closeButton { + color: inherit; +} + +.tagsWrapper { + display: flex; + flex-wrap: wrap; + gap: 8px; +} diff --git a/app/javascript/mastodon/components/tags/tag.stories.tsx b/app/javascript/mastodon/components/tags/tag.stories.tsx new file mode 100644 index 0000000000..3db9f5fe99 --- /dev/null +++ b/app/javascript/mastodon/components/tags/tag.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { action } from 'storybook/actions'; + +import MusicNoteIcon from '@/material-icons/400-24px/music_note.svg?react'; + +import { EditableTag, Tag } from './tag'; + +const meta = { + component: Tag, + title: 'Components/Tags/Single Tag', + args: { + name: 'example-tag', + active: false, + onClick: action('Click'), + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithIcon: Story = { + args: { + icon: MusicNoteIcon, + }, +}; + +export const Editable: Story = { + render(args) { + return ; + }, +}; + +export const EditableWithIcon: Story = { + render(args) { + return ( + + ); + }, +}; diff --git a/app/javascript/mastodon/components/tags/tag.tsx b/app/javascript/mastodon/components/tags/tag.tsx new file mode 100644 index 0000000000..4dd4b89b55 --- /dev/null +++ b/app/javascript/mastodon/components/tags/tag.tsx @@ -0,0 +1,94 @@ +import type { ComponentPropsWithoutRef, ReactNode } from 'react'; +import { forwardRef } from 'react'; + +import { useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import CloseIcon from '@/material-icons/400-24px/close.svg?react'; + +import type { IconProp } from '../icon'; +import { Icon } from '../icon'; +import { IconButton } from '../icon_button'; + +import classes from './style.module.css'; + +export interface TagProps { + name: ReactNode; + active?: boolean; + icon?: IconProp; + className?: string; + children?: ReactNode; +} + +export const Tag = forwardRef< + HTMLButtonElement, + TagProps & ComponentPropsWithoutRef<'button'> +>(({ name, active, icon, className, children, ...props }, ref) => { + if (!name) { + return null; + } + return ( + + ); +}); +Tag.displayName = 'Tag'; + +export const EditableTag = forwardRef< + HTMLSpanElement, + TagProps & { + onRemove: () => void; + removeIcon?: IconProp; + } & ComponentPropsWithoutRef<'span'> +>( + ( + { + name, + active, + icon, + className, + children, + removeIcon = CloseIcon, + onRemove, + ...props + }, + ref, + ) => { + const intl = useIntl(); + + if (!name) { + return null; + } + return ( + + {icon && } + {typeof name === 'string' ? `#${name}` : name} + {children} + + + ); + }, +); +EditableTag.displayName = 'EditableTag'; diff --git a/app/javascript/mastodon/components/tags/tags.stories.tsx b/app/javascript/mastodon/components/tags/tags.stories.tsx new file mode 100644 index 0000000000..cf4e361b20 --- /dev/null +++ b/app/javascript/mastodon/components/tags/tags.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { action } from 'storybook/actions'; + +import { Tags } from './tags'; + +const meta = { + component: Tags, + title: 'Components/Tags/List', + args: { + tags: [{ name: 'tag-one' }, { name: 'tag-two' }], + active: 'tag-one', + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render(args) { + return ; + }, +}; + +export const Editable: Story = { + args: { + onRemove: action('Remove'), + }, +}; diff --git a/app/javascript/mastodon/components/tags/tags.tsx b/app/javascript/mastodon/components/tags/tags.tsx new file mode 100644 index 0000000000..c1c120def7 --- /dev/null +++ b/app/javascript/mastodon/components/tags/tags.tsx @@ -0,0 +1,54 @@ +import { useCallback } from 'react'; +import type { ComponentPropsWithoutRef, FC } from 'react'; + +import classes from './style.module.css'; +import { EditableTag, Tag } from './tag'; +import type { TagProps } from './tag'; + +type Tag = TagProps & { name: string }; + +export type TagsProps = { + tags: Tag[]; + active?: string; +} & ( + | ({ + onRemove?: never; + } & ComponentPropsWithoutRef<'button'>) + | ({ onRemove?: (tag: string) => void } & ComponentPropsWithoutRef<'span'>) +); + +export const Tags: FC = ({ tags, active, onRemove, ...props }) => { + if (onRemove) { + return ( +
+ {tags.map((tag) => ( + + ))} +
+ ); + } + + return ( +
+ {tags.map((tag) => ( + + ))} +
+ ); +}; + +const MappedTag: FC void }> = ({ + onRemove, + ...props +}) => { + const handleRemove = useCallback(() => { + onRemove?.(props.name); + }, [onRemove, props.name]); + return ; +}; diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 9fc147d3a8..64329bcc2c 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -1030,6 +1030,7 @@ "tabs_bar.notifications": "Notifications", "tabs_bar.publish": "New Post", "tabs_bar.search": "Search", + "tag.remove": "Remove", "terms_of_service.effective_as_of": "Effective as of {date}", "terms_of_service.title": "Terms of Service", "terms_of_service.upcoming_changes_on": "Upcoming changes on {date}", From ec76288dff7330ef243a2e4230f95d37b45823d6 Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 28 Jan 2026 11:17:32 +0100 Subject: [PATCH 4/9] Profile redesign: Timeline filters (#37626) --- .../mastodon/actions/timelines.test.ts | 60 ++++++ .../mastodon/actions/timelines_typed.ts | 148 ++++++++++++++- .../components/form_fields/toggle.module.css | 5 + .../components/form_fields/toggle_field.tsx | 4 +- .../components/redesign.module.scss | 27 ++- .../account_timeline/components/tabs.tsx | 19 ++ .../account_timeline/hooks/useFilters.ts | 28 +++ .../features/account_timeline/v2/filters.tsx | 146 +++++++++++++++ .../features/account_timeline/v2/index.tsx | 172 ++++++++++++++++++ .../account_timeline/v2/styles.module.scss | 37 ++++ .../features/ui/util/async-components.js | 5 + app/javascript/mastodon/locales/en.json | 7 + .../mastodon/selectors/timelines.ts | 51 ++++++ 13 files changed, 704 insertions(+), 5 deletions(-) create mode 100644 app/javascript/mastodon/actions/timelines.test.ts create mode 100644 app/javascript/mastodon/features/account_timeline/hooks/useFilters.ts create mode 100644 app/javascript/mastodon/features/account_timeline/v2/filters.tsx create mode 100644 app/javascript/mastodon/features/account_timeline/v2/index.tsx create mode 100644 app/javascript/mastodon/features/account_timeline/v2/styles.module.scss create mode 100644 app/javascript/mastodon/selectors/timelines.ts diff --git a/app/javascript/mastodon/actions/timelines.test.ts b/app/javascript/mastodon/actions/timelines.test.ts new file mode 100644 index 0000000000..e7f4198cde --- /dev/null +++ b/app/javascript/mastodon/actions/timelines.test.ts @@ -0,0 +1,60 @@ +import { parseTimelineKey, timelineKey } from './timelines_typed'; + +describe('timelineKey', () => { + test('returns expected key for account timeline with filters', () => { + const key = timelineKey({ + type: 'account', + userId: '123', + replies: true, + boosts: false, + media: true, + }); + expect(key).toBe('account:123:0110'); + }); + + test('returns expected key for account timeline with tag', () => { + const key = timelineKey({ + type: 'account', + userId: '456', + tagged: 'nature', + replies: true, + }); + expect(key).toBe('account:456:0100:nature'); + }); + + test('returns expected key for account timeline with pins', () => { + const key = timelineKey({ + type: 'account', + userId: '789', + pinned: true, + }); + expect(key).toBe('account:789:0001'); + }); +}); + +describe('parseTimelineKey', () => { + test('parses account timeline key with filters correctly', () => { + const params = parseTimelineKey('account:123:1010'); + expect(params).toEqual({ + type: 'account', + userId: '123', + boosts: true, + replies: false, + media: true, + pinned: false, + }); + }); + + test('parses account timeline key with tag correctly', () => { + const params = parseTimelineKey('account:456:0100:nature'); + expect(params).toEqual({ + type: 'account', + userId: '456', + replies: true, + boosts: false, + media: false, + pinned: false, + tagged: 'nature', + }); + }); +}); diff --git a/app/javascript/mastodon/actions/timelines_typed.ts b/app/javascript/mastodon/actions/timelines_typed.ts index e846882660..d1fcfb1c65 100644 --- a/app/javascript/mastodon/actions/timelines_typed.ts +++ b/app/javascript/mastodon/actions/timelines_typed.ts @@ -2,7 +2,153 @@ import { createAction } from '@reduxjs/toolkit'; import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; -import { TIMELINE_NON_STATUS_MARKERS } from './timelines'; +import { createAppThunk } from '../store/typed_functions'; + +import { expandTimeline, TIMELINE_NON_STATUS_MARKERS } from './timelines'; + +export const expandTimelineByKey = createAppThunk( + (args: { key: string; maxId?: number }, { dispatch }) => { + const params = parseTimelineKey(args.key); + if (!params) { + return; + } + + void dispatch(expandTimelineByParams({ ...params, maxId: args.maxId })); + }, +); + +export const expandTimelineByParams = createAppThunk( + (params: TimelineParams & { maxId?: number }, { dispatch }) => { + let url = ''; + const extra: Record = {}; + + if (params.type === 'account') { + url = `/api/v1/accounts/${params.userId}/statuses`; + + if (!params.replies) { + extra.exclude_replies = true; + } + if (!params.boosts) { + extra.exclude_reblogs = true; + } + if (params.pinned) { + extra.pinned = true; + } + if (params.media) { + extra.only_media = true; + } + if (params.tagged) { + extra.tagged = params.tagged; + } + } else if (params.type === 'public') { + url = '/api/v1/timelines/public'; + } + + if (params.maxId) { + extra.max_id = params.maxId.toString(); + } + + return dispatch(expandTimeline(timelineKey(params), url, extra)); + }, +); + +export interface AccountTimelineParams { + type: 'account'; + userId: string; + tagged?: string; + media?: boolean; + pinned?: boolean; + boosts?: boolean; + replies?: boolean; +} +export type PublicTimelineServer = 'local' | 'remote' | 'all'; +export interface PublicTimelineParams { + type: 'public'; + tagged?: string; + server?: PublicTimelineServer; // Defaults to 'all' + media?: boolean; +} +export interface HomeTimelineParams { + type: 'home'; +} +export type TimelineParams = + | AccountTimelineParams + | PublicTimelineParams + | HomeTimelineParams; + +const ACCOUNT_FILTERS = ['boosts', 'replies', 'media', 'pinned'] as const; + +export function timelineKey(params: TimelineParams): string { + const { type } = params; + const key: string[] = [type]; + + if (type === 'account') { + key.push(params.userId); + + const view = ACCOUNT_FILTERS.reduce( + (prev, curr) => prev + (params[curr] ? '1' : '0'), + '', + ); + + key.push(view); + } else if (type === 'public') { + key.push(params.server ?? 'all'); + if (params.media) { + key.push('media'); + } + } + + if (type !== 'home' && params.tagged) { + key.push(params.tagged); + } + + return key.filter(Boolean).join(':'); +} + +export function parseTimelineKey(key: string): TimelineParams | null { + const segments = key.split(':'); + const type = segments[0]; + + if (type === 'account') { + const userId = segments[1]; + if (!userId) { + return null; + } + + const parsed: TimelineParams = { + type: 'account', + userId, + tagged: segments[3], + }; + + const view = segments[2]?.split('') ?? []; + for (let i = 0; i < view.length; i++) { + const flagName = ACCOUNT_FILTERS[i]; + if (flagName) { + parsed[flagName] = view[i] === '1'; + } + } + return parsed; + } + + if (type === 'public') { + return { + type: 'public', + server: + segments[1] === 'remote' || segments[1] === 'local' + ? segments[1] + : 'all', + tagged: segments[2], + media: segments[3] === 'media', + }; + } + + if (type === 'home') { + return { type: 'home' }; + } + + return null; +} export function isNonStatusId(value: unknown) { return TIMELINE_NON_STATUS_MARKERS.includes(value as string | null); diff --git a/app/javascript/mastodon/components/form_fields/toggle.module.css b/app/javascript/mastodon/components/form_fields/toggle.module.css index c2d3f57bcc..997434f336 100644 --- a/app/javascript/mastodon/components/form_fields/toggle.module.css +++ b/app/javascript/mastodon/components/form_fields/toggle.module.css @@ -68,3 +68,8 @@ :global([dir='rtl']) .input:checked + .toggle::before { transform: translateX(calc(-1 * (var(--diameter) - (var(--padding) * 2)))); } + +.wrapper { + display: inline-block; + position: relative; +} diff --git a/app/javascript/mastodon/components/form_fields/toggle_field.tsx b/app/javascript/mastodon/components/form_fields/toggle_field.tsx index a116c001bc..e14bb54ad1 100644 --- a/app/javascript/mastodon/components/form_fields/toggle_field.tsx +++ b/app/javascript/mastodon/components/form_fields/toggle_field.tsx @@ -32,7 +32,7 @@ ToggleField.displayName = 'ToggleField'; export const PlainToggleField = forwardRef( ({ className, size, ...otherProps }, ref) => ( - <> + ( } hidden /> - + ), ); PlainToggleField.displayName = 'PlainToggleField'; diff --git a/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss b/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss index 028e2b41dc..f7a0bb8bbf 100644 --- a/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss +++ b/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss @@ -49,7 +49,7 @@ background-color: var(--color-bg-secondary); border: none; color: var(--color-text-secondary); - font-weight: 600; + font-weight: 500; > span { font-weight: unset; @@ -145,7 +145,7 @@ svg.badgeIcon { } dd { - font-weight: 600; + font-weight: 500; font-size: 15px; } @@ -154,3 +154,26 @@ svg.badgeIcon { margin-left: 4px; } } + +.tabs { + border-bottom: 1px solid var(--color-border-primary); + display: flex; + gap: 12px; + padding: 0 24px; + + a { + display: block; + font-size: 15px; + font-weight: 500; + padding: 18px 4px; + text-decoration: none; + color: var(--color-text-primary); + border-radius: 0; + } + + :global(.active) { + color: var(--color-text-brand); + border-bottom: 4px solid var(--color-text-brand); + padding-bottom: 14px; + } +} diff --git a/app/javascript/mastodon/features/account_timeline/components/tabs.tsx b/app/javascript/mastodon/features/account_timeline/components/tabs.tsx index c08de1390e..f525264ed4 100644 --- a/app/javascript/mastodon/features/account_timeline/components/tabs.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/tabs.tsx @@ -4,7 +4,26 @@ import { FormattedMessage } from 'react-intl'; import { NavLink } from 'react-router-dom'; +import { isRedesignEnabled } from '../common'; + +import classes from './redesign.module.scss'; + export const AccountTabs: FC<{ acct: string }> = ({ acct }) => { + if (isRedesignEnabled()) { + return ( +
+ + + + + + + + + +
+ ); + } return (
diff --git a/app/javascript/mastodon/features/account_timeline/hooks/useFilters.ts b/app/javascript/mastodon/features/account_timeline/hooks/useFilters.ts new file mode 100644 index 0000000000..d979d895ac --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/hooks/useFilters.ts @@ -0,0 +1,28 @@ +import { useCallback } from 'react'; + +import { useSearchParam } from '@/mastodon/hooks/useSearchParam'; + +export function useFilters() { + const [boosts, setBoosts] = useSearchParam('boosts'); + const [replies, setReplies] = useSearchParam('replies'); + + const handleSetBoosts = useCallback( + (value: boolean) => { + setBoosts(value ? '1' : null); + }, + [setBoosts], + ); + const handleSetReplies = useCallback( + (value: boolean) => { + setReplies(value ? '1' : null); + }, + [setReplies], + ); + + return { + boosts: boosts === '1', + replies: replies === '1', + setBoosts: handleSetBoosts, + setReplies: handleSetReplies, + }; +} diff --git a/app/javascript/mastodon/features/account_timeline/v2/filters.tsx b/app/javascript/mastodon/features/account_timeline/v2/filters.tsx new file mode 100644 index 0000000000..874d653c20 --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/v2/filters.tsx @@ -0,0 +1,146 @@ +import { useCallback, useId, useRef, useState } from 'react'; +import type { ChangeEventHandler, FC } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { useParams } from 'react-router'; + +import Overlay from 'react-overlays/esm/Overlay'; + +import { PlainToggleField } from '@/mastodon/components/form_fields/toggle_field'; +import { Icon } from '@/mastodon/components/icon'; +import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react'; + +import { AccountTabs } from '../components/tabs'; +import { useFilters } from '../hooks/useFilters'; + +import classes from './styles.module.scss'; + +export const AccountFilters: FC = () => { + const { acct } = useParams<{ acct: string }>(); + if (!acct) { + return null; + } + return ( + <> + +
+ +
+ + ); +}; + +const FilterDropdown: FC = () => { + const [open, setOpen] = useState(false); + const buttonRef = useRef(null); + + const handleClick = useCallback(() => { + setOpen(true); + }, []); + const handleHide = useCallback(() => { + setOpen(false); + }, []); + + const { boosts, replies, setBoosts, setReplies } = useFilters(); + const handleChange: ChangeEventHandler = useCallback( + (event) => { + const { name, checked } = event.target; + if (name === 'boosts') { + setBoosts(checked); + } else if (name === 'replies') { + setReplies(checked); + } + }, + [setBoosts, setReplies], + ); + + const accessibleId = useId(); + const containerRef = useRef(null); + + return ( +
+ + + {({ props }) => ( +
+ + + + + +
+ )} +
+
+ ); +}; diff --git a/app/javascript/mastodon/features/account_timeline/v2/index.tsx b/app/javascript/mastodon/features/account_timeline/v2/index.tsx new file mode 100644 index 0000000000..4254e3d7eb --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/v2/index.tsx @@ -0,0 +1,172 @@ +import { useCallback, useEffect } from 'react'; +import type { FC } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { useParams } from 'react-router'; + +import { List as ImmutableList } from 'immutable'; + +import { + expandTimelineByKey, + timelineKey, +} from '@/mastodon/actions/timelines_typed'; +import { Column } from '@/mastodon/components/column'; +import { ColumnBackButton } from '@/mastodon/components/column_back_button'; +import { FeaturedCarousel } from '@/mastodon/components/featured_carousel'; +import { LoadingIndicator } from '@/mastodon/components/loading_indicator'; +import { RemoteHint } from '@/mastodon/components/remote_hint'; +import StatusList from '@/mastodon/components/status_list'; +import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error'; +import { useAccountId } from '@/mastodon/hooks/useAccountId'; +import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility'; +import { selectTimelineByKey } from '@/mastodon/selectors/timelines'; +import { useAppDispatch, useAppSelector } from '@/mastodon/store'; + +import { AccountHeader } from '../components/account_header'; +import { LimitedAccountHint } from '../components/limited_account_hint'; +import { useFilters } from '../hooks/useFilters'; + +import { AccountFilters } from './filters'; + +const emptyList = ImmutableList(); + +const AccountTimelineV2: FC<{ multiColumn: boolean }> = ({ multiColumn }) => { + const accountId = useAccountId(); + + // Null means accountId does not exist (e.g. invalid acct). Undefined means loading. + if (accountId === null) { + return ; + } + + if (!accountId) { + return ( + + + + ); + } + + // Add this key to remount the timeline when accountId changes. + return ( + + ); +}; + +const InnerTimeline: FC<{ accountId: string; multiColumn: boolean }> = ({ + accountId, + multiColumn, +}) => { + const { tagged } = useParams<{ tagged?: string }>(); + const { boosts, replies } = useFilters(); + const key = timelineKey({ + type: 'account', + userId: accountId, + tagged, + boosts, + replies, + }); + + const timeline = useAppSelector((state) => selectTimelineByKey(state, key)); + const { blockedBy, hidden, suspended } = useAccountVisibility(accountId); + + const dispatch = useAppDispatch(); + useEffect(() => { + if (!timeline && !!accountId) { + dispatch(expandTimelineByKey({ key })); + } + }, [accountId, dispatch, key, timeline]); + + const handleLoadMore = useCallback( + (maxId: number) => { + if (accountId) { + dispatch(expandTimelineByKey({ key, maxId })); + } + }, + [accountId, dispatch, key], + ); + + const forceEmptyState = blockedBy || hidden || suspended; + + return ( + + + + + } + append={} + scrollKey='account_timeline' + // We want to have this component when timeline is undefined (loading), + // because if we don't the prepended component will re-render with every filter change. + statusIds={forceEmptyState ? emptyList : (timeline?.items ?? emptyList)} + isLoading={!!timeline?.isLoading} + hasMore={!forceEmptyState && !!timeline?.hasMore} + onLoadMore={handleLoadMore} + emptyMessage={} + bindToDocument={!multiColumn} + timelineId='account' + withCounters + /> + + ); +}; + +const Prepend: FC<{ + accountId: string; + tagged?: string; + forceEmpty: boolean; +}> = ({ forceEmpty, accountId, tagged }) => { + if (forceEmpty) { + return ; + } + + return ( + <> + + + + + ); +}; + +const EmptyMessage: FC<{ accountId: string }> = ({ accountId }) => { + const { blockedBy, hidden, suspended } = useAccountVisibility(accountId); + if (suspended) { + return ( + + ); + } else if (hidden) { + return ; + } else if (blockedBy) { + return ( + + ); + } + + return ( + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default AccountTimelineV2; diff --git a/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss b/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss new file mode 100644 index 0000000000..a8ba29afa5 --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss @@ -0,0 +1,37 @@ +.filtersWrapper { + padding: 16px 24px 8px; +} + +.filterSelectButton { + appearance: none; + border: none; + background: none; + padding: 8px 0; + font-weight: 500; + display: flex; + align-items: center; +} + +.filterSelectIcon { + width: 16px; + height: 16px; +} + +.filterOverlay { + background: var(--color-bg-elevated); + border-radius: 12px; + box-shadow: var(--dropdown-shadow); + min-width: 230px; + display: grid; + grid-template-columns: 1fr auto; + align-items: stretch; + row-gap: 16px; + padding: 8px 12px; + z-index: 1; + + > label { + cursor: pointer; + display: flex; + align-items: center; + } +} diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 976b1fb13f..4d31f6a4c4 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -1,3 +1,5 @@ +import { isClientFeatureEnabled } from '@/mastodon/utils/environment'; + export function EmojiPicker () { return import('../../emoji/emoji_picker'); } @@ -65,6 +67,9 @@ export function PinnedStatuses () { } export function AccountTimeline () { + if (isClientFeatureEnabled('profile_redesign')) { + return import('../../account_timeline/v2'); + } return import('../../account_timeline'); } diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 64329bcc2c..1468848715 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -14,6 +14,7 @@ "about.powered_by": "Decentralized social media powered by {mastodon}", "about.rules": "Server rules", "account.account_note_header": "Personal note", + "account.activity": "Activity", "account.add_note": "Add a personal note", "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Automated", @@ -41,6 +42,12 @@ "account.featured.hashtags": "Hashtags", "account.featured_tags.last_status_at": "Last post on {date}", "account.featured_tags.last_status_never": "No posts", + "account.filters.all": "All activity", + "account.filters.boosts_toggle": "Show boosts", + "account.filters.posts_boosts": "Posts and boosts", + "account.filters.posts_only": "Posts", + "account.filters.posts_replies": "Posts and replies", + "account.filters.replies_toggle": "Show replies", "account.follow": "Follow", "account.follow_back": "Follow back", "account.follow_back_short": "Follow back", diff --git a/app/javascript/mastodon/selectors/timelines.ts b/app/javascript/mastodon/selectors/timelines.ts new file mode 100644 index 0000000000..5db50ea894 --- /dev/null +++ b/app/javascript/mastodon/selectors/timelines.ts @@ -0,0 +1,51 @@ +import type { Map as ImmutableMap } from 'immutable'; +import { List as ImmutableList } from 'immutable'; + +import type { TimelineParams } from '../actions/timelines_typed'; +import { timelineKey } from '../actions/timelines_typed'; +import { createAppSelector } from '../store'; + +interface TimelineShape { + unread: number; + online: boolean; + top: boolean; + isLoading: boolean; + hasMore: boolean; + pendingItems: ImmutableList; + items: ImmutableList; +} + +type TimelinesState = ImmutableMap>; + +const emptyList = ImmutableList(); + +export const selectTimelineByKey = createAppSelector( + [(_, key: string) => key, (state) => state.timelines as TimelinesState], + (key, timelines) => toTypedTimeline(timelines.get(key)), +); + +export const selectTimelineByParams = createAppSelector( + [ + (_, params: TimelineParams) => timelineKey(params), + (state) => state.timelines as TimelinesState, + ], + (key, timelines) => toTypedTimeline(timelines.get(key)), +); + +export function toTypedTimeline(timeline?: ImmutableMap) { + if (!timeline) { + return null; + } + return { + unread: timeline.get('unread', 0) as number, + online: !!timeline.get('online', false), + top: !!timeline.get('top', false), + isLoading: !!timeline.get('isLoading', true), + hasMore: !!timeline.get('hasMore', false), + pendingItems: timeline.get( + 'pendingItems', + emptyList, + ) as ImmutableList, + items: timeline.get('items', emptyList) as ImmutableList, + } as TimelineShape; +} From f1c00feb5c98389d18243494514249c536b5ce7c Mon Sep 17 00:00:00 2001 From: PGray <77597544+PGrayCS@users.noreply.github.com> Date: Wed, 28 Jan 2026 10:33:04 +0000 Subject: [PATCH 5/9] Fix quote cancel button not appearing after edit then delete-and-redraft (#37066) --- app/javascript/mastodon/components/status.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 892270b394..8d7d689a6a 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -146,6 +146,7 @@ class Status extends ImmutablePureComponent { 'hidden', 'unread', 'pictureInPicture', + 'onQuoteCancel', ]; state = { From 6a995decb812d6e7f99d5da7790eaa5621142a15 Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 28 Jan 2026 11:33:31 +0100 Subject: [PATCH 6/9] Experiment with adding a bundle comparison tool (#37630) --- .github/workflows/bundlesize-compare.yml | 75 ++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 .github/workflows/bundlesize-compare.yml diff --git a/.github/workflows/bundlesize-compare.yml b/.github/workflows/bundlesize-compare.yml new file mode 100644 index 0000000000..eb0b4aa7da --- /dev/null +++ b/.github/workflows/bundlesize-compare.yml @@ -0,0 +1,75 @@ +name: Compare JS bundle size +on: + pull_request: + paths: + - 'app/javascript/**' + - 'vite.config.mts' + - 'package.json' + - 'yarn.lock' + - .github/workflows/bundlesize-compare.yml + +jobs: + build-head: + name: 'Build head' + runs-on: ubuntu-latest + permissions: + contents: read + env: + ANALYZE_BUNDLE_SIZE: '1' + steps: + - uses: actions/checkout@v5 + with: + ref: ${{github.event.pull_request.head.ref}} + + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript + + - name: Build + run: yarn run build:production + + - name: Upload stats.json + uses: actions/upload-artifact@v5 + with: + name: head-stats + path: ./stats.json + if-no-files-found: error + + build-base: + name: 'Build base' + runs-on: ubuntu-latest + permissions: + contents: read + env: + ANALYZE_BUNDLE_SIZE: '1' + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.base_ref }} + + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript + + - name: Build + run: yarn run build:production + + - name: Upload stats.json + uses: actions/upload-artifact@v5 + with: + name: base-stats + path: ./stats.json + if-no-files-found: error + + compare: + name: 'Compare base & head bundle sizes' + runs-on: ubuntu-latest + needs: [build-base, build-head] + permissions: + pull-requests: write + steps: + - uses: actions/download-artifact@v5 + + - uses: twk3/rollup-size-compare-action@v1.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + current-stats-json-path: ./head-stats/stats.json + base-stats-json-path: ./base-stats/stats.json From c1626486bcf09fef56b2c447f26732477db1d70f Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Wed, 28 Jan 2026 05:52:31 -0500 Subject: [PATCH 7/9] Group classes in media proxy `rescue_from` declaration (#37304) --- app/controllers/media_proxy_controller.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/controllers/media_proxy_controller.rb b/app/controllers/media_proxy_controller.rb index d55b90ad88..267107b627 100644 --- a/app/controllers/media_proxy_controller.rb +++ b/app/controllers/media_proxy_controller.rb @@ -11,9 +11,7 @@ class MediaProxyController < ApplicationController before_action :authenticate_user!, if: :limited_federation_mode? before_action :set_media_attachment - rescue_from ActiveRecord::RecordInvalid, with: :not_found - rescue_from Mastodon::UnexpectedResponseError, with: :not_found - rescue_from Mastodon::NotPermittedError, with: :not_found + rescue_from ActiveRecord::RecordInvalid, Mastodon::NotPermittedError, Mastodon::UnexpectedResponseError, with: :not_found rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error) def show From f861a5cee0455e89a0d9c131826bfcc5c56331bc Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Wed, 28 Jan 2026 06:04:00 -0500 Subject: [PATCH 8/9] Add `action_logs` association for account (#36022) --- app/controllers/concerns/accountable_concern.rb | 8 +++----- app/models/concerns/account/associations.rb | 1 + spec/models/account_spec.rb | 1 + 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/controllers/concerns/accountable_concern.rb b/app/controllers/concerns/accountable_concern.rb index c1349915f8..9c16d573c5 100644 --- a/app/controllers/concerns/accountable_concern.rb +++ b/app/controllers/concerns/accountable_concern.rb @@ -4,10 +4,8 @@ module AccountableConcern extend ActiveSupport::Concern def log_action(action, target) - Admin::ActionLog.create( - account: current_account, - action: action, - target: target - ) + current_account + .action_logs + .create(action:, target:) end end diff --git a/app/models/concerns/account/associations.rb b/app/models/concerns/account/associations.rb index c66a26c00f..03ec713941 100644 --- a/app/models/concerns/account/associations.rb +++ b/app/models/concerns/account/associations.rb @@ -12,6 +12,7 @@ module Account::Associations has_many :account_notes has_many :account_pins has_many :account_warnings + has_many :action_logs, class_name: 'Admin::ActionLog' has_many :aliases, class_name: 'AccountAlias' has_many :bookmarks has_many :collections diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index 11e7f5e02b..ca85b0fbfc 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -8,6 +8,7 @@ RSpec.describe Account do describe 'Associations' do it { is_expected.to have_many(:account_notes).inverse_of(:account) } + it { is_expected.to have_many(:action_logs).class_name('Admin::ActionLog') } it { is_expected.to have_many(:targeted_account_notes).inverse_of(:target_account) } end From a495a0cbfcf9f6111c0e598af0864de2b76e4111 Mon Sep 17 00:00:00 2001 From: Claire Date: Wed, 28 Jan 2026 12:06:30 +0100 Subject: [PATCH 9/9] Fix avatar and header descriptions being returned for suspended accounts (#37641) --- app/serializers/rest/account_serializer.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb index 38ddd6ac4d..3fa541f445 100644 --- a/app/serializers/rest/account_serializer.rb +++ b/app/serializers/rest/account_serializer.rb @@ -82,6 +82,10 @@ class REST::AccountSerializer < ActiveModel::Serializer full_asset_url(object.unavailable? ? object.avatar.default_url : object.avatar_static_url) end + def avatar_description + object.unavailable? ? '' : object.avatar_description + end + def header full_asset_url(object.unavailable? ? object.header.default_url : object.header_original_url) end @@ -90,6 +94,10 @@ class REST::AccountSerializer < ActiveModel::Serializer full_asset_url(object.unavailable? ? object.header.default_url : object.header_static_url) end + def header_description + object.unavailable? ? '' : object.header_description + end + def created_at object.created_at.midnight.as_json end