Merge commit 'c36db57acedf0033c090748125c665a0360cd11f' into glitch-soc/merge-upstream

This commit is contained in:
Claire 2026-01-23 17:57:20 +01:00
commit 8b95254883
106 changed files with 1945 additions and 561 deletions

View File

@ -9,7 +9,7 @@ RUN /bin/bash --login -i -c "nvm install"
# Install additional OS packages # Install additional OS packages
RUN apt-get update && \ RUN apt-get update && \
export DEBIAN_FRONTEND=noninteractive && \ export DEBIAN_FRONTEND=noninteractive && \
apt-get -y install --no-install-recommends libicu-dev libidn11-dev ffmpeg imagemagick libvips42 libpam-dev apt-get -y install --no-install-recommends libicu-dev libidn11-dev ffmpeg libvips42 libpam-dev
# Disable download prompt for Corepack # Disable download prompt for Corepack
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0

View File

@ -58,5 +58,5 @@ jobs:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
zip: true zip: true
storybookBuildDir: 'storybook-static' storybookBuildDir: 'storybook-static'
exitZeroOnChanges: false # Fail workflow if changes are found exitOnceUploaded: true # Exit immediately after upload
autoAcceptChanges: 'main' # Auto-accept changes on main branch only autoAcceptChanges: 'main' # Auto-accept changes on main branch only

View File

@ -173,93 +173,6 @@ jobs:
env: env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
test-imagemagick:
name: ImageMagick tests
runs-on: ubuntu-latest
needs:
- build
services:
postgres:
image: postgres:14-alpine
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
options: >-
--health-cmd pg_isready
--health-interval 10ms
--health-timeout 3s
--health-retries 50
ports:
- 5432:5432
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10ms
--health-timeout 3s
--health-retries 50
ports:
- 6379:6379
env:
DB_HOST: localhost
DB_USER: postgres
DB_PASS: postgres
COVERAGE: ${{ matrix.ruby-version == '.ruby-version' }}
RAILS_ENV: test
ALLOW_NOPAM: true
PAM_ENABLED: true
PAM_DEFAULT_SERVICE: pam_test
PAM_CONTROLLED_SERVICE: pam_test_controlled
OIDC_ENABLED: true
OIDC_SCOPE: read
SAML_ENABLED: true
CAS_ENABLED: true
BUNDLE_WITH: 'pam_authentication test'
GITHUB_RSPEC: ${{ matrix.ruby-version == '.ruby-version' && github.event.pull_request && 'true' }}
MASTODON_USE_LIBVIPS: false
strategy:
fail-fast: false
matrix:
ruby-version:
- '3.2'
- '3.3'
- '.ruby-version'
steps:
- uses: actions/checkout@v5
- uses: actions/download-artifact@v6
with:
path: './'
name: ${{ github.sha }}
- name: Expand archived asset artifacts
run: |
tar xvzf artifacts.tar.gz
- name: Set up Ruby environment
uses: ./.github/actions/setup-ruby
with:
ruby-version: ${{ matrix.ruby-version}}
additional-system-dependencies: ffmpeg imagemagick libpam-dev
- name: Load database schema
run: './bin/rails db:create db:schema:load db:seed'
- run: bin/rspec --tag attachment_processing
- name: Upload coverage reports to Codecov
if: matrix.ruby-version == '.ruby-version'
uses: codecov/codecov-action@v5
with:
files: coverage/lcov/mastodon.lcov
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
test-e2e: test-e2e:
name: End to End testing name: End to End testing
runs-on: ubuntu-latest runs-on: ubuntu-latest

8
.storybook/modes.ts Normal file
View File

@ -0,0 +1,8 @@
export const modes = {
darkTheme: {
theme: 'dark',
},
lightTheme: {
theme: 'light',
},
} as const;

View File

@ -25,6 +25,7 @@ import { mockHandlers, unhandledRequestHandler } from '@/testing/api';
// you can change the below to `/application.scss` // you can change the below to `/application.scss`
import '../app/javascript/styles/mastodon-light.scss'; import '../app/javascript/styles/mastodon-light.scss';
import './styles.css'; import './styles.css';
import { modes } from './modes';
const localeFiles = import.meta.glob('@/mastodon/locales/*.json', { const localeFiles = import.meta.glob('@/mastodon/locales/*.json', {
query: { as: 'json' }, query: { as: 'json' },
@ -198,6 +199,13 @@ const preview: Preview = {
msw: { msw: {
handlers: mockHandlers, handlers: mockHandlers,
}, },
chromatic: {
modes: {
dark: modes.darkTheme,
light: modes.lightTheme,
},
},
}, },
}; };

View File

@ -70,8 +70,6 @@ ENV \
PATH="${PATH}:/opt/ruby/bin:/opt/mastodon/bin" \ PATH="${PATH}:/opt/ruby/bin:/opt/mastodon/bin" \
# Optimize jemalloc 5.x performance # Optimize jemalloc 5.x performance
MALLOC_CONF="narenas:2,background_thread:true,thp:never,dirty_decay_ms:1000,muzzy_decay_ms:0" \ MALLOC_CONF="narenas:2,background_thread:true,thp:never,dirty_decay_ms:1000,muzzy_decay_ms:0" \
# Enable libvips, should not be changed
MASTODON_USE_LIBVIPS=true \
# Sidekiq will touch tmp/sidekiq_process_has_started_and_will_begin_processing_jobs to indicate it is ready. This can be used for a readiness check in Kubernetes # Sidekiq will touch tmp/sidekiq_process_has_started_and_will_begin_processing_jobs to indicate it is ready. This can be used for a readiness check in Kubernetes
MASTODON_SIDEKIQ_READY_FILENAME=sidekiq_process_has_started_and_will_begin_processing_jobs MASTODON_SIDEKIQ_READY_FILENAME=sidekiq_process_has_started_and_will_begin_processing_jobs

View File

@ -52,8 +52,8 @@ Mastodon requires all `POST` requests to be signed, and MAY require `GET` reques
## Size limits ## Size limits
Mastodon imposes a few hard limits on federated content. Mastodon imposes a few hard limits on federated content.
These limits are intended to be very generous and way above what the Mastodon user experience is optimized for, so as to accomodate future changes and unusual or unforeseen usage patterns, while still providing some limits for performance reasons. These limits are intended to be very generous and way above what the Mastodon user experience is optimized for, so as to accommodate future changes and unusual or unforeseen usage patterns, while still providing some limits for performance reasons.
The following table attempts to summary those limits. The following table summarizes those limits.
| Limited property | Size limit | Consequence of exceeding the limit | | Limited property | Size limit | Consequence of exceeding the limit |
| ------------------------------------------------------------- | ---------- | ---------------------------------- | | ------------------------------------------------------------- | ---------- | ---------------------------------- |

View File

@ -96,8 +96,8 @@ GEM
ast (2.4.3) ast (2.4.3)
attr_required (1.0.2) attr_required (1.0.2)
aws-eventstream (1.4.0) aws-eventstream (1.4.0)
aws-partitions (1.1201.0) aws-partitions (1.1206.0)
aws-sdk-core (3.241.3) aws-sdk-core (3.241.4)
aws-eventstream (~> 1, >= 1.3.0) aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0) aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9) aws-sigv4 (~> 1.9)
@ -105,11 +105,11 @@ GEM
bigdecimal bigdecimal
jmespath (~> 1, >= 1.6.1) jmespath (~> 1, >= 1.6.1)
logger logger
aws-sdk-kms (1.120.0) aws-sdk-kms (1.121.0)
aws-sdk-core (~> 3, >= 3.241.3) aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.211.0) aws-sdk-s3 (1.212.0)
aws-sdk-core (~> 3, >= 3.241.3) aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1) aws-sigv4 (1.12.1)
@ -234,7 +234,7 @@ GEM
excon (1.3.2) excon (1.3.2)
logger logger
fabrication (3.0.0) fabrication (3.0.0)
faker (3.5.3) faker (3.6.0)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (2.14.0) faraday (2.14.0)
faraday-net_http (>= 2.0, < 3.5) faraday-net_http (>= 2.0, < 3.5)

1
Vagrantfile vendored
View File

@ -29,7 +29,6 @@ sudo apt-get install \
libpq-dev \ libpq-dev \
libxml2-dev \ libxml2-dev \
libxslt1-dev \ libxslt1-dev \
imagemagick \
nodejs \ nodejs \
redis-server \ redis-server \
redis-tools \ redis-tools \

View File

@ -21,7 +21,7 @@ class Api::V1Alpha::CollectionItemsController < Api::BaseController
@item = AddAccountToCollectionService.new.call(@collection, @account) @item = AddAccountToCollectionService.new.call(@collection, @account)
render json: @item, serializer: REST::CollectionItemSerializer render json: @item, serializer: REST::CollectionItemSerializer, adapter: :json
end end
def destroy def destroy

View File

@ -26,16 +26,18 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
def index def index
cache_if_unauthenticated! cache_if_unauthenticated!
authorize Collection, :index? authorize @account, :index_collections?
render json: @collections, each_serializer: REST::BaseCollectionSerializer render json: @collections, each_serializer: REST::CollectionSerializer, adapter: :json
rescue Mastodon::NotPermittedError
render json: { collections: [] }
end end
def show def show
cache_if_unauthenticated! cache_if_unauthenticated!
authorize @collection, :show? authorize @collection, :show?
render json: @collection, serializer: REST::CollectionSerializer render json: @collection, serializer: REST::CollectionWithAccountsSerializer
end end
def create def create
@ -43,7 +45,7 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
@collection = CreateCollectionService.new.call(collection_creation_params, current_user.account) @collection = CreateCollectionService.new.call(collection_creation_params, current_user.account)
render json: @collection, serializer: REST::CollectionSerializer render json: @collection, serializer: REST::CollectionSerializer, adapter: :json
end end
def update def update
@ -51,7 +53,7 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
@collection.update!(collection_update_params) # TODO: Create a service for this to federate changes @collection.update!(collection_update_params) # TODO: Create a service for this to federate changes
render json: @collection, serializer: REST::CollectionSerializer render json: @collection, serializer: REST::CollectionSerializer, adapter: :json
end end
def destroy def destroy

View File

@ -29,7 +29,7 @@ class StatusesController < ApplicationController
end end
format.json do format.json do
expires_in 3.minutes, public: true if @status.distributable? && public_fetch_mode? expires_in @status.quote&.pending? ? 5.seconds : 3.minutes, public: true if @status.distributable? && public_fetch_mode?
render_with_cache json: @status, content_type: 'application/activity+json', serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter render_with_cache json: @status, content_type: 'application/activity+json', serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter
end end
end end

View File

@ -0,0 +1,39 @@
import {
apiRequestPost,
apiRequestPut,
apiRequestGet,
apiRequestDelete,
} from 'mastodon/api';
import type {
ApiWrappedCollectionJSON,
ApiCollectionWithAccountsJSON,
ApiCreateCollectionPayload,
ApiPatchCollectionPayload,
ApiCollectionsJSON,
} from '../api_types/collections';
export const apiCreateCollection = (collection: ApiCreateCollectionPayload) =>
apiRequestPost<ApiWrappedCollectionJSON>('v1_alpha/collections', collection);
export const apiUpdateCollection = ({
id,
...collection
}: ApiPatchCollectionPayload) =>
apiRequestPut<ApiWrappedCollectionJSON>(
`v1_alpha/collections/${id}`,
collection,
);
export const apiDeleteCollection = (collectionId: string) =>
apiRequestDelete(`v1_alpha/collections/${collectionId}`);
export const apiGetCollection = (collectionId: string) =>
apiRequestGet<ApiCollectionWithAccountsJSON[]>(
`v1_alpha/collections/${collectionId}`,
);
export const apiGetAccountCollections = (accountId: string) =>
apiRequestGet<ApiCollectionsJSON>(
`v1_alpha/accounts/${accountId}/collections`,
);

View File

@ -0,0 +1,82 @@
// See app/serializers/rest/base_collection_serializer.rb
import type { ApiAccountJSON } from './accounts';
import type { ApiTagJSON } from './statuses';
/**
* Returned when fetching all collections for an account,
* doesn't contain account and item data
*/
export interface ApiCollectionJSON {
account_id: string;
id: string;
uri: string;
local: boolean;
item_count: number;
name: string;
description: string;
tag?: ApiTagJSON;
language: string;
sensitive: boolean;
discoverable: boolean;
created_at: string;
updated_at: string;
items: CollectionAccountItem[];
}
/**
* Returned when fetching all collections for an account
*/
export interface ApiCollectionsJSON {
collections: ApiCollectionJSON[];
}
/**
* Returned when creating, updating, and adding to a collection
*/
export interface ApiWrappedCollectionJSON {
collection: ApiCollectionJSON;
}
/**
* Returned when fetching a single collection
*/
export interface ApiCollectionWithAccountsJSON extends ApiWrappedCollectionJSON {
accounts: ApiAccountJSON[];
}
/**
* Nested account item
*/
interface CollectionAccountItem {
account_id?: string; // Only present when state is 'accepted' (or the collection is your own)
state: 'pending' | 'accepted' | 'rejected' | 'revoked';
position: number;
}
export interface WrappedCollectionAccountItem {
collection_item: CollectionAccountItem;
}
/**
* Payload types
*/
type CommonPayloadFields = Pick<
ApiCollectionJSON,
'name' | 'description' | 'sensitive' | 'discoverable'
> & {
tag?: string;
};
export interface ApiPatchCollectionPayload extends Partial<CommonPayloadFields> {
id: string;
}
export interface ApiCreateCollectionPayload extends CommonPayloadFields {
account_ids?: string[];
}

View File

@ -0,0 +1,93 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { action } from 'storybook/actions';
import { Callout } from '.';
const meta = {
title: 'Components/Callout',
args: {
children: 'Contents here',
title: 'Title',
onPrimary: action('Primary action clicked'),
primaryLabel: 'Primary',
onSecondary: action('Secondary action clicked'),
secondaryLabel: 'Secondary',
onClose: action('Close clicked'),
},
component: Callout,
render(args) {
return (
<div style={{ minWidth: 'min(400px, calc(100vw - 2rem))' }}>
<Callout {...args} />
</div>
);
},
} satisfies Meta<typeof Callout>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
variant: 'default',
},
};
export const NoIcon: Story = {
args: {
icon: false,
},
};
export const NoActions: Story = {
args: {
onPrimary: undefined,
onSecondary: undefined,
},
};
export const OnlyText: Story = {
args: {
onClose: undefined,
onPrimary: undefined,
onSecondary: undefined,
icon: false,
},
};
// export const Subtle: Story = {
// args: {
// variant: 'subtle',
// },
// };
export const Feature: Story = {
args: {
variant: 'feature',
},
};
export const Inverted: Story = {
args: {
variant: 'inverted',
},
};
export const Success: Story = {
args: {
variant: 'success',
},
};
export const Warning: Story = {
args: {
variant: 'warning',
},
};
export const Error: Story = {
args: {
variant: 'error',
},
};

View File

@ -0,0 +1,27 @@
import { useCallback } from 'react';
import type { FC } from 'react';
import { useDismissible } from '@/mastodon/hooks/useDismissible';
import { Callout } from '.';
import type { CalloutProps } from '.';
type DismissibleCalloutProps = CalloutProps & {
id: string;
};
export const DismissibleCallout: FC<DismissibleCalloutProps> = (props) => {
const { dismiss, wasDismissed } = useDismissible(props.id);
const { onClose } = props;
const handleClose = useCallback(() => {
dismiss();
onClose?.();
}, [dismiss, onClose]);
if (wasDismissed) {
return null;
}
return <Callout {...props} onClose={handleClose} />;
};

View File

@ -0,0 +1,150 @@
import type { FC, ReactNode } from 'react';
import { useIntl } from 'react-intl';
import classNames from 'classnames';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import ErrorIcon from '@/material-icons/400-24px/error.svg?react';
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
import type { IconProp } from '../icon';
import { Icon } from '../icon';
import { IconButton } from '../icon_button';
import classes from './styles.module.css';
export interface CalloutProps {
variant?:
| 'default'
// | 'subtle'
| 'feature'
| 'inverted'
| 'success'
| 'warning'
| 'error';
title?: ReactNode;
children: ReactNode;
className?: string;
/** Set to false to hide the icon. */
icon?: IconProp | boolean;
onPrimary?: () => void;
primaryLabel?: string;
onSecondary?: () => void;
secondaryLabel?: string;
onClose?: () => void;
id?: string;
}
const variantClasses = {
default: classes.variantDefault as string,
// subtle: classes.variantSubtle as string,
feature: classes.variantFeature as string,
inverted: classes.variantInverted as string,
success: classes.variantSuccess as string,
warning: classes.variantWarning as string,
error: classes.variantError as string,
} as const;
export const Callout: FC<CalloutProps> = ({
className,
variant = 'default',
title,
children,
icon,
onPrimary: primaryAction,
primaryLabel,
onSecondary: secondaryAction,
secondaryLabel,
onClose,
id,
}) => {
const intl = useIntl();
return (
<aside
className={classNames(
className,
classes.wrapper,
variantClasses[variant],
)}
data-variant={variant}
id={id}
>
<CalloutIcon variant={variant} icon={icon} />
<div className={classes.content}>
<div className={classes.body}>
{title && <h3>{title}</h3>}
{children}
</div>
{(primaryAction ?? secondaryAction) && (
<div className={classes.actionWrapper}>
{secondaryAction && (
<button
type='button'
onClick={secondaryAction}
className={classes.action}
>
{secondaryLabel ?? 'Click'}
</button>
)}
{primaryAction && (
<button
type='button'
onClick={primaryAction}
className={classes.action}
>
{primaryLabel ?? 'Click'}
</button>
)}
</div>
)}
</div>
{onClose && (
<IconButton
icon='close'
title={intl.formatMessage({
id: 'callout.dismiss',
defaultMessage: 'Dismiss',
})}
iconComponent={CloseIcon}
className={classes.close}
onClick={onClose}
/>
)}
</aside>
);
};
const CalloutIcon: FC<Pick<CalloutProps, 'variant' | 'icon'>> = ({
variant = 'default',
icon,
}) => {
if (icon === false) {
return null;
}
if (!icon || icon === true) {
switch (variant) {
case 'inverted':
case 'success':
icon = CheckIcon;
break;
case 'warning':
icon = WarningIcon;
break;
case 'error':
icon = ErrorIcon;
break;
default:
icon = InfoIcon;
}
}
return <Icon id={variant} icon={icon} className={classes.icon} />;
};

View File

@ -0,0 +1,125 @@
.wrapper {
display: flex;
align-items: start;
padding: 12px;
gap: 8px;
background-color: var(--color-bg-brand-softer);
color: var(--color-text-primary);
border-radius: 12px;
}
.icon {
padding: 4px;
border-radius: 9999px;
width: 1rem;
height: 1rem;
}
.content {
display: flex;
gap: 8px;
flex-direction: column;
flex-grow: 1;
}
@media screen and (width >= 630px) {
.content {
flex-direction: row;
}
}
.icon + .content,
.wrapper:has(.close) .content {
margin-top: 2px;
}
.body {
flex-grow: 1;
h3 {
font-weight: 500;
}
}
.actionWrapper {
display: flex;
gap: 8px;
align-items: start;
}
.action {
appearance: none;
background: none;
border: none;
color: inherit;
font-weight: 500;
padding: 0;
text-decoration: underline;
transition: color 0.1s ease-in-out;
&:hover {
color: var(--color-text-brand-soft);
}
}
.close {
color: inherit;
svg {
width: 20px;
height: 20px;
}
}
.variantDefault {
.icon {
background-color: var(--color-bg-brand-soft);
}
}
/* .variantSubtle {
border: 1px solid var(--color-bg-brand-softer);
background-color: var(--color-bg-primary);
.icon {
background-color: var(--color-bg-brand-softer);
}
} */
.variantFeature {
background-color: var(--color-bg-brand-base);
color: var(--color-text-on-brand-base);
button:hover {
color: color-mix(var(--color-text-on-brand-base), transparent 20%);
}
}
.variantInverted {
background-color: var(--color-bg-inverted);
color: var(--color-text-on-inverted);
}
.variantSuccess {
background-color: var(--color-bg-success-softer);
.icon {
background-color: var(--color-bg-success-soft);
}
}
.variantWarning {
background-color: var(--color-bg-warning-softer);
.icon {
background-color: var(--color-bg-warning-soft);
}
}
.variantError {
background-color: var(--color-bg-error-softer);
.icon {
background-color: var(--color-bg-error-soft);
}
}

View File

@ -0,0 +1,3 @@
export { TextInputField } from './text_input_field';
export { TextAreaField } from './text_area_field';
export { SelectField } from './select_field';

View File

@ -0,0 +1,55 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { SelectField } from './select_field';
const meta = {
title: 'Components/Form Fields/SelectField',
component: SelectField,
args: {
label: 'Fruit preference',
hint: 'Select your favourite fruit or not. Up to you.',
},
render(args) {
// Component styles require a wrapper class at the moment
return (
<div className='simple_form'>
<SelectField {...args}>
<option>Apple</option>
<option>Banana</option>
<option>Kiwi</option>
<option>Lemon</option>
<option>Mango</option>
<option>Orange</option>
<option>Pomelo</option>
<option>Strawberries</option>
<option>Something else</option>
</SelectField>
</div>
);
},
} satisfies Meta<typeof SelectField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Simple: Story = {};
export const Required: Story = {
args: {
required: true,
},
};
export const Optional: Story = {
args: {
required: false,
},
};
export const WithError: Story = {
args: {
required: false,
hasError: true,
},
};

View File

@ -0,0 +1,38 @@
import type { ComponentPropsWithoutRef } from 'react';
import { forwardRef } from 'react';
import { FormFieldWrapper } from './wrapper';
import type { CommonFieldWrapperProps } from './wrapper';
interface Props
extends ComponentPropsWithoutRef<'select'>, CommonFieldWrapperProps {}
/**
* A simple form field for single-item selections.
* Provide selectable items via nested `<option>` elements.
*
* Accepts an optional `hint` and can be marked as required
* or optional (by explicitly setting `required={false}`)
*/
export const SelectField = forwardRef<HTMLSelectElement, Props>(
({ id, label, hint, required, hasError, children, ...otherProps }, ref) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
inputId={id}
>
{(inputProps) => (
<div className='select-wrapper'>
<select {...otherProps} {...inputProps} ref={ref}>
{children}
</select>
</div>
)}
</FormFieldWrapper>
),
);
SelectField.displayName = 'SelectField';

View File

@ -0,0 +1,45 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { TextAreaField } from './text_area_field';
const meta = {
title: 'Components/Form Fields/TextAreaField',
component: TextAreaField,
args: {
label: 'Label',
hint: 'This is a description of this form field',
},
render(args) {
// Component styles require a wrapper class at the moment
return (
<div className='simple_form'>
<TextAreaField {...args} />
</div>
);
},
} satisfies Meta<typeof TextAreaField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Simple: Story = {};
export const Required: Story = {
args: {
required: true,
},
};
export const Optional: Story = {
args: {
required: false,
},
};
export const WithError: Story = {
args: {
required: false,
hasError: true,
},
};

View File

@ -0,0 +1,31 @@
import type { ComponentPropsWithoutRef } from 'react';
import { forwardRef } from 'react';
import { FormFieldWrapper } from './wrapper';
import type { CommonFieldWrapperProps } from './wrapper';
interface Props
extends ComponentPropsWithoutRef<'textarea'>, CommonFieldWrapperProps {}
/**
* A simple form field for multi-line text.
*
* Accepts an optional `hint` and can be marked as required
* or optional (by explicitly setting `required={false}`)
*/
export const TextAreaField = forwardRef<HTMLTextAreaElement, Props>(
({ id, label, hint, required, hasError, ...otherProps }, ref) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
inputId={id}
>
{(inputProps) => <textarea {...otherProps} {...inputProps} ref={ref} />}
</FormFieldWrapper>
),
);
TextAreaField.displayName = 'TextAreaField';

View File

@ -0,0 +1,45 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { TextInputField } from './text_input_field';
const meta = {
title: 'Components/Form Fields/TextInputField',
component: TextInputField,
args: {
label: 'Label',
hint: 'This is a description of this form field',
},
render(args) {
// Component styles require a wrapper class at the moment
return (
<div className='simple_form'>
<TextInputField {...args} />
</div>
);
},
} satisfies Meta<typeof TextInputField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Simple: Story = {};
export const Required: Story = {
args: {
required: true,
},
};
export const Optional: Story = {
args: {
required: false,
},
};
export const WithError: Story = {
args: {
required: false,
hasError: true,
},
};

View File

@ -0,0 +1,36 @@
import type { ComponentPropsWithoutRef } from 'react';
import { forwardRef } from 'react';
import { FormFieldWrapper } from './wrapper';
import type { CommonFieldWrapperProps } from './wrapper';
interface Props
extends ComponentPropsWithoutRef<'input'>, CommonFieldWrapperProps {}
/**
* A simple form field for single-line text.
*
* Accepts an optional `hint` and can be marked as required
* or optional (by explicitly setting `required={false}`)
*/
export const TextInputField = forwardRef<HTMLInputElement, Props>(
(
{ id, label, hint, hasError, required, type = 'text', ...otherProps },
ref,
) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
inputId={id}
>
{(inputProps) => (
<input type={type} {...otherProps} {...inputProps} ref={ref} />
)}
</FormFieldWrapper>
),
);
TextInputField.displayName = 'TextInputField';

View File

@ -0,0 +1,70 @@
.input {
position: absolute;
opacity: 0;
width: 100%;
height: 100%;
margin: 0;
cursor: pointer;
z-index: 1;
}
.toggle {
--diameter: 20px;
--padding: 2px;
--transition: 0.2s ease-in-out;
display: inline-flex;
align-items: center;
border-radius: 9999px;
width: calc(var(--diameter) * 2);
background: var(--color-bg-tertiary);
padding: var(--padding);
transition: background var(--transition);
box-sizing: border-box;
}
.toggle::before {
content: '';
height: var(--diameter);
width: var(--diameter);
border-radius: 9999px;
background: var(--color-text-on-brand-base);
box-shadow: 0 2px 4px 0 color-mix(var(--color-black), transparent 75%);
transition: transform var(--transition);
}
@media (prefers-reduced-motion: reduce) {
.toggle,
.toggle::before {
transition: none;
}
}
.input:checked + .toggle {
background: var(--color-bg-brand-base);
}
.input:checked:is(:hover, :focus) + .toggle {
background: var(--color-bg-brand-base-hover);
}
.input:focus-visible + .toggle {
outline: var(--outline-focus-default);
outline-offset: 2px;
}
.input:checked + .toggle::before {
transform: translateX(calc(var(--diameter) - (var(--padding) * 2)));
}
.input:disabled {
cursor: not-allowed;
}
.input:disabled + .toggle {
opacity: 0.6;
}
:global([dir='rtl']) .input:checked + .toggle::before {
transform: translateX(calc(-1 * (var(--diameter) - (var(--padding) * 2))));
}

View File

@ -0,0 +1,77 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { PlainToggleField, ToggleField } from './toggle_field';
const meta = {
title: 'Components/Form Fields/ToggleField',
component: ToggleField,
args: {
label: 'Label',
hint: 'This is a description of this form field',
disabled: false,
size: 20,
},
argTypes: {
size: {
control: { type: 'range', min: 10, max: 40, step: 1 },
},
},
render(args) {
// Component styles require a wrapper class at the moment
return (
<div className='simple_form'>
<ToggleField {...args} />
</div>
);
},
} satisfies Meta<typeof ToggleField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Simple: Story = {};
export const Required: Story = {
args: {
required: true,
},
};
export const Optional: Story = {
args: {
required: false,
},
};
export const WithError: Story = {
args: {
required: false,
hasError: true,
},
};
export const Disabled: Story = {
args: {
disabled: true,
checked: true,
},
};
export const Plain: Story = {
render(props) {
return <PlainToggleField {...props} />;
},
};
export const Small: Story = {
args: {
size: 12,
},
};
export const Large: Story = {
args: {
size: 36,
},
};

View File

@ -0,0 +1,52 @@
import type { ComponentPropsWithoutRef, CSSProperties } from 'react';
import { forwardRef } from 'react';
import classNames from 'classnames';
import classes from './toggle.module.css';
import type { CommonFieldWrapperProps } from './wrapper';
import { FormFieldWrapper } from './wrapper';
type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & {
size?: number;
};
export const ToggleField = forwardRef<
HTMLInputElement,
Props & CommonFieldWrapperProps
>(({ id, label, hint, hasError, required, ...otherProps }, ref) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
inputId={id}
>
{(inputProps) => (
<PlainToggleField {...otherProps} {...inputProps} ref={ref} />
)}
</FormFieldWrapper>
));
ToggleField.displayName = 'ToggleField';
export const PlainToggleField = forwardRef<HTMLInputElement, Props>(
({ className, size, ...otherProps }, ref) => (
<>
<input
{...otherProps}
type='checkbox'
className={classes.input}
ref={ref}
/>
<span
className={classNames(classes.toggle, className)}
style={
{ '--diameter': size ? `${size}px` : undefined } as CSSProperties
}
hidden
/>
</>
),
);
PlainToggleField.displayName = 'PlainToggleField';

View File

@ -0,0 +1,100 @@
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
import type { ReactNode, FC } from 'react';
import { useId } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
interface InputProps {
id: string;
required?: boolean;
'aria-describedby'?: string;
}
interface FieldWrapperProps {
label: ReactNode;
hint?: ReactNode;
required?: boolean;
hasError?: boolean;
inputId?: string;
children: (inputProps: InputProps) => ReactNode;
}
/**
* These types can be extended when creating individual field components.
*/
export type CommonFieldWrapperProps = Pick<
FieldWrapperProps,
'label' | 'hint' | 'hasError'
>;
/**
* A simple form field wrapper for adding a label and hint to enclosed components.
* Accepts an optional `hint` and can be marked as required
* or optional (by explicitly setting `required={false}`)
*/
export const FormFieldWrapper: FC<FieldWrapperProps> = ({
inputId: inputIdProp,
label,
hint,
required,
hasError,
children,
}) => {
const uniqueId = useId();
const inputId = inputIdProp || `${uniqueId}-input`;
const hintId = `${inputIdProp || uniqueId}-hint`;
const hasHint = !!hint;
const inputProps: InputProps = {
required,
id: inputId,
};
if (hasHint) {
inputProps['aria-describedby'] = hintId;
}
return (
<div
className={classNames('input with_block_label', {
field_with_errors: hasError,
})}
>
<div className='label_input'>
<label htmlFor={inputId}>
{label}
{required !== undefined && <RequiredMark required={required} />}
</label>
{hasHint && (
<span className='hint' id={hintId}>
{hint}
</span>
)}
<div className='label_input__wrapper'>{children(inputProps)}</div>
</div>
</div>
);
};
/**
* If `required` is explicitly set to `false` rather than `undefined`,
* the field will be visually marked as "optional".
*/
const RequiredMark: FC<{ required?: boolean }> = ({ required }) =>
required ? (
<>
{' '}
<abbr aria-hidden='true'>*</abbr>
</>
) : (
<>
{' '}
<FormattedMessage id='form_field.optional' defaultMessage='(optional)' />
</>
);

View File

@ -0,0 +1,179 @@
import { useEffect, useMemo, useCallback } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { openModal } from 'mastodon/actions/modal';
import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import {
createCollection,
fetchAccountCollections,
selectMyCollections,
} from 'mastodon/reducers/slices/collections';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
const messages = defineMessages({
heading: { id: 'column.collections', defaultMessage: 'My collections' },
create: {
id: 'collections.create_collection',
defaultMessage: 'Create collection',
},
view: {
id: 'collections.view_collection',
defaultMessage: 'View collection',
},
delete: {
id: 'collections.delete_collection',
defaultMessage: 'Delete collection',
},
more: { id: 'status.more', defaultMessage: 'More' },
});
const ListItem: React.FC<{
id: string;
name: string;
}> = ({ id, name }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const handleDeleteClick = useCallback(() => {
dispatch(
openModal({
modalType: 'CONFIRM_DELETE_LIST',
modalProps: {
listId: id,
},
}),
);
}, [dispatch, id]);
const menu = useMemo(
() => [
{ text: intl.formatMessage(messages.view), to: `/collections/${id}` },
{ text: intl.formatMessage(messages.delete), action: handleDeleteClick },
],
[intl, id, handleDeleteClick],
);
return (
<div className='lists__item'>
<Link to={`/collections/${id}`} className='lists__item__title'>
<span>{name}</span>
</Link>
<Dropdown
scrollKey='collections'
items={menu}
icon='ellipsis-h'
iconComponent={MoreHorizIcon}
title={intl.formatMessage(messages.more)}
/>
</div>
);
};
export const Collections: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const me = useAppSelector((state) => state.meta.get('me') as string);
const { collections, status } = useAppSelector(selectMyCollections);
useEffect(() => {
void dispatch(fetchAccountCollections({ accountId: me }));
}, [dispatch, me]);
const addDummyCollection = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
void dispatch(
createCollection({
payload: {
name: 'Test Collection',
description: 'A useful test collection',
discoverable: true,
sensitive: false,
},
}),
);
},
[dispatch],
);
const emptyMessage =
status === 'error' ? (
<FormattedMessage
id='collections.error_loading_collections'
defaultMessage='There was an error when trying to load your collections.'
/>
) : (
<>
<span>
<FormattedMessage
id='collections.no_collections_yet'
defaultMessage='No collections yet.'
/>
<br />
<FormattedMessage
id='collections.create_a_collection_hint'
defaultMessage='Create a collection to recommend or share your favourite accounts with others.'
/>
</span>
<SquigglyArrow className='empty-column-indicator__arrow' />
</>
);
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(messages.heading)}
>
<ColumnHeader
title={intl.formatMessage(messages.heading)}
icon='list-ul'
iconComponent={ListAltIcon}
multiColumn={multiColumn}
extraButton={
<Link
to='/collections/new'
className='column-header__button'
title={intl.formatMessage(messages.create)}
aria-label={intl.formatMessage(messages.create)}
onClick={addDummyCollection}
>
<Icon id='plus' icon={AddIcon} />
</Link>
}
/>
<ScrollableList
scrollKey='collections'
emptyMessage={emptyMessage}
isLoading={status === 'loading'}
bindToDocument={!multiColumn}
>
{collections.map((item) => (
<ListItem key={item.id} id={item.id} name={item.name} />
))}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};

View File

@ -0,0 +1,11 @@
import {
isClientFeatureEnabled,
isServerFeatureEnabled,
} from '@/mastodon/utils/environment';
export function areCollectionsEnabled() {
return (
isClientFeatureEnabled('collections') &&
isServerFeatureEnabled('collections')
);
}

View File

@ -10,8 +10,8 @@ import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import CloseIcon from '@/material-icons/400-20px/close.svg?react'; import CloseIcon from '@/material-icons/400-20px/close.svg?react';
import SoundIcon from '@/material-icons/400-24px/audio.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react'; import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import SoundIcon from '@/material-icons/400-24px/graphic_eq.svg?react';
import WarningIcon from '@/material-icons/400-24px/warning.svg?react'; import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
import { undoUploadCompose } from 'mastodon/actions/compose'; import { undoUploadCompose } from 'mastodon/actions/compose';
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';

View File

@ -20,6 +20,7 @@ import { Avatar } from 'mastodon/components/avatar';
import { AvatarGroup } from 'mastodon/components/avatar_group'; import { AvatarGroup } from 'mastodon/components/avatar_group';
import { Column } from 'mastodon/components/column'; import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header'; import { ColumnHeader } from 'mastodon/components/column_header';
import { SelectField, TextInputField } from 'mastodon/components/form_fields';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import type { List } from 'mastodon/models/list'; import type { List } from 'mastodon/models/list';
@ -149,68 +150,49 @@ const NewList: React.FC<{ list?: List | null }> = ({ list }) => {
return ( return (
<form className='simple_form app-form' onSubmit={handleSubmit}> <form className='simple_form app-form' onSubmit={handleSubmit}>
<div className='fields-group'> <div className='fields-group'>
<div className='input with_label'> <TextInputField
<div className='label_input'> required
<label htmlFor='list_title'> maxLength={30}
<FormattedMessage label={
id='lists.list_name' <FormattedMessage id='lists.list_name' defaultMessage='List name' />
defaultMessage='List name' }
/> value={title}
</label> onChange={handleTitleChange}
id='list_title'
<div className='label_input__wrapper'> />
<input
id='list_title'
type='text'
value={title}
onChange={handleTitleChange}
maxLength={30}
required
placeholder=' '
/>
</div>
</div>
</div>
</div> </div>
<div className='fields-group'> <div className='fields-group'>
<div className='input with_label'> <SelectField
<div className='label_input'> label={
<label htmlFor='list_replies_policy'> <FormattedMessage
<FormattedMessage id='lists.show_replies_to'
id='lists.show_replies_to' defaultMessage='Include replies from list members to'
defaultMessage='Include replies from list members to' />
/> }
</label> value={repliesPolicy}
onChange={handleRepliesPolicyChange}
<div className='label_input__wrapper'> id='list_replies_policy'
<select >
id='list_replies_policy' <FormattedMessage
value={repliesPolicy} id='lists.replies_policy.none'
onChange={handleRepliesPolicyChange} defaultMessage='No one'
> >
<FormattedMessage {(msg) => <option value='none'>{msg}</option>}
id='lists.replies_policy.none' </FormattedMessage>
defaultMessage='No one' <FormattedMessage
> id='lists.replies_policy.list'
{(msg) => <option value='none'>{msg}</option>} defaultMessage='Members of the list'
</FormattedMessage> >
<FormattedMessage {(msg) => <option value='list'>{msg}</option>}
id='lists.replies_policy.list' </FormattedMessage>
defaultMessage='Members of the list' <FormattedMessage
> id='lists.replies_policy.followed'
{(msg) => <option value='list'>{msg}</option>} defaultMessage='Any followed user'
</FormattedMessage> >
<FormattedMessage {(msg) => <option value='followed'>{msg}</option>}
id='lists.replies_policy.followed' </FormattedMessage>
defaultMessage='Any followed user' </SelectField>
>
{(msg) => <option value='followed'>{msg}</option>}
</FormattedMessage>
</select>
</div>
</div>
</div>
</div> </div>
{id && ( {id && (

View File

@ -16,6 +16,7 @@ import { closeOnboarding } from 'mastodon/actions/onboarding';
import { Button } from 'mastodon/components/button'; import { Button } from 'mastodon/components/button';
import { Column } from 'mastodon/components/column'; import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header'; import { ColumnHeader } from 'mastodon/components/column_header';
import { TextAreaField, TextInputField } from 'mastodon/components/form_fields';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { me } from 'mastodon/initial_state'; import { me } from 'mastodon/initial_state';
@ -212,62 +213,47 @@ export const Profile: React.FC<{
</div> </div>
<div className='fields-group'> <div className='fields-group'>
<div <TextInputField
className={classNames('input with_block_label', { maxLength={30}
field_with_errors: !!errors?.display_name, label={
})}
>
<label htmlFor='display_name'>
<FormattedMessage <FormattedMessage
id='onboarding.profile.display_name' id='onboarding.profile.display_name'
defaultMessage='Display name' defaultMessage='Display name'
/> />
</label> }
<span className='hint'> hint={
<FormattedMessage <FormattedMessage
id='onboarding.profile.display_name_hint' id='onboarding.profile.display_name_hint'
defaultMessage='Your full name or your fun name…' defaultMessage='Your full name or your fun name…'
/> />
</span> }
<div className='label_input'> value={displayName}
<input onChange={handleDisplayNameChange}
id='display_name' hasError={!!errors?.display_name}
type='text' id='display_name'
value={displayName} />
onChange={handleDisplayNameChange}
maxLength={30}
/>
</div>
</div>
</div> </div>
<div className='fields-group'> <div className='fields-group'>
<div <TextAreaField
className={classNames('input with_block_label', { maxLength={500}
field_with_errors: !!errors?.note, label={
})}
>
<label htmlFor='note'>
<FormattedMessage <FormattedMessage
id='onboarding.profile.note' id='onboarding.profile.note'
defaultMessage='Bio' defaultMessage='Bio'
/> />
</label> }
<span className='hint'> hint={
<FormattedMessage <FormattedMessage
id='onboarding.profile.note_hint' id='onboarding.profile.note_hint'
defaultMessage='You can @mention other people or #hashtags…' defaultMessage='You can @mention other people or #hashtags…'
/> />
</span> }
<div className='label_input'> value={note}
<textarea onChange={handleNoteChange}
id='note' hasError={!!errors?.note}
value={note} id='note'
onChange={handleNoteChange} />
maxLength={500}
/>
</div>
</div>
</div> </div>
<label className='app-form__toggle'> <label className='app-form__toggle'>

View File

@ -63,6 +63,7 @@ import {
Lists, Lists,
ListEdit, ListEdit,
ListMembers, ListMembers,
Collections,
Blocks, Blocks,
DomainBlocks, DomainBlocks,
Mutes, Mutes,
@ -85,6 +86,7 @@ import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
// Dummy import, to make sure that <Status /> ends up in the application bundle. // Dummy import, to make sure that <Status /> ends up in the application bundle.
// Without this it ends up in ~8 very commonly used bundles. // Without this it ends up in ~8 very commonly used bundles.
import '../../components/status'; import '../../components/status';
import { areCollectionsEnabled } from '../collections/utils';
const messages = defineMessages({ const messages = defineMessages({
beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' }, beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' },
@ -227,6 +229,9 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/followed_tags' component={FollowedTags} content={children} /> <WrappedRoute path='/followed_tags' component={FollowedTags} content={children} />
<WrappedRoute path='/mutes' component={Mutes} content={children} /> <WrappedRoute path='/mutes' component={Mutes} content={children} />
<WrappedRoute path='/lists' component={Lists} content={children} /> <WrappedRoute path='/lists' component={Lists} content={children} />
{areCollectionsEnabled() &&
<WrappedRoute path='/collections' component={Collections} content={children} />
}
<Route component={BundleColumnError} /> <Route component={BundleColumnError} />
</WrappedSwitch> </WrappedSwitch>

View File

@ -42,6 +42,12 @@ export function Lists () {
return import('../../lists'); return import('../../lists');
} }
export function Collections () {
return import('../../collections').then(
module => ({default: module.Collections})
);
}
export function Status () { export function Status () {
return import('../../status'); return import('../../status');
} }

View File

@ -454,6 +454,7 @@
"footer.source_code": "Прагледзець зыходны код", "footer.source_code": "Прагледзець зыходны код",
"footer.status": "Статус", "footer.status": "Статус",
"footer.terms_of_service": "Умовы выкарыстання", "footer.terms_of_service": "Умовы выкарыстання",
"form_field.optional": "(неабавязкова)",
"generic.saved": "Захавана", "generic.saved": "Захавана",
"getting_started.heading": "Пачатак працы", "getting_started.heading": "Пачатак працы",
"hashtag.admin_moderation": "Адкрыць інтэрфейс мадэратара для #{name}", "hashtag.admin_moderation": "Адкрыць інтэрфейс мадэратара для #{name}",

View File

@ -454,6 +454,7 @@
"footer.source_code": "Vis kildekode", "footer.source_code": "Vis kildekode",
"footer.status": "Status", "footer.status": "Status",
"footer.terms_of_service": "Tjenestevilkår", "footer.terms_of_service": "Tjenestevilkår",
"form_field.optional": "(valgfri)",
"generic.saved": "Gemt", "generic.saved": "Gemt",
"getting_started.heading": "Startmenu", "getting_started.heading": "Startmenu",
"hashtag.admin_moderation": "Åbn modereringsbrugerflade for #{name}", "hashtag.admin_moderation": "Åbn modereringsbrugerflade for #{name}",

View File

@ -454,6 +454,7 @@
"footer.source_code": "Quellcode anzeigen", "footer.source_code": "Quellcode anzeigen",
"footer.status": "Status", "footer.status": "Status",
"footer.terms_of_service": "Nutzungsbedingungen", "footer.terms_of_service": "Nutzungsbedingungen",
"form_field.optional": "(optional)",
"generic.saved": "Gespeichert", "generic.saved": "Gespeichert",
"getting_started.heading": "Auf gehts!", "getting_started.heading": "Auf gehts!",
"hashtag.admin_moderation": "#{name} moderieren", "hashtag.admin_moderation": "#{name} moderieren",

View File

@ -454,6 +454,7 @@
"footer.source_code": "Προβολή πηγαίου κώδικα", "footer.source_code": "Προβολή πηγαίου κώδικα",
"footer.status": "Κατάσταση", "footer.status": "Κατάσταση",
"footer.terms_of_service": "Όροι υπηρεσίας", "footer.terms_of_service": "Όροι υπηρεσίας",
"form_field.optional": "(προαιρετικό)",
"generic.saved": "Αποθηκεύτηκε", "generic.saved": "Αποθηκεύτηκε",
"getting_started.heading": "Ας ξεκινήσουμε", "getting_started.heading": "Ας ξεκινήσουμε",
"hashtag.admin_moderation": "Άνοιγμα διεπαφής συντονισμού για το #{name}", "hashtag.admin_moderation": "Άνοιγμα διεπαφής συντονισμού για το #{name}",

View File

@ -454,6 +454,7 @@
"footer.source_code": "View source code", "footer.source_code": "View source code",
"footer.status": "Status", "footer.status": "Status",
"footer.terms_of_service": "Terms of service", "footer.terms_of_service": "Terms of service",
"form_field.optional": "(optional)",
"generic.saved": "Saved", "generic.saved": "Saved",
"getting_started.heading": "Getting started", "getting_started.heading": "Getting started",
"hashtag.admin_moderation": "Open moderation interface for #{name}", "hashtag.admin_moderation": "Open moderation interface for #{name}",

View File

@ -187,6 +187,7 @@
"bundle_modal_error.close": "Close", "bundle_modal_error.close": "Close",
"bundle_modal_error.message": "Something went wrong while loading this screen.", "bundle_modal_error.message": "Something went wrong while loading this screen.",
"bundle_modal_error.retry": "Try again", "bundle_modal_error.retry": "Try again",
"callout.dismiss": "Dismiss",
"carousel.current": "<sr>Slide</sr> {current, number} / {max, number}", "carousel.current": "<sr>Slide</sr> {current, number} / {max, number}",
"carousel.slide": "Slide {current, number} of {max, number}", "carousel.slide": "Slide {current, number} of {max, number}",
"closed_registrations.other_server_instructions": "Since Mastodon is decentralized, you can create an account on another server and still interact with this one.", "closed_registrations.other_server_instructions": "Since Mastodon is decentralized, you can create an account on another server and still interact with this one.",
@ -194,9 +195,16 @@
"closed_registrations_modal.find_another_server": "Find another server", "closed_registrations_modal.find_another_server": "Find another server",
"closed_registrations_modal.preamble": "Mastodon is decentralized, so no matter where you create your account, you will be able to follow and interact with anyone on this server. You can even self-host it!", "closed_registrations_modal.preamble": "Mastodon is decentralized, so no matter where you create your account, you will be able to follow and interact with anyone on this server. You can even self-host it!",
"closed_registrations_modal.title": "Signing up on Mastodon", "closed_registrations_modal.title": "Signing up on Mastodon",
"collections.create_a_collection_hint": "Create a collection to recommend or share your favourite accounts with others.",
"collections.create_collection": "Create collection",
"collections.delete_collection": "Delete collection",
"collections.error_loading_collections": "There was an error when trying to load your collections.",
"collections.no_collections_yet": "No collections yet.",
"collections.view_collection": "View collection",
"column.about": "About", "column.about": "About",
"column.blocks": "Blocked users", "column.blocks": "Blocked users",
"column.bookmarks": "Bookmarks", "column.bookmarks": "Bookmarks",
"column.collections": "My collections",
"column.community": "Local timeline", "column.community": "Local timeline",
"column.create_list": "Create list", "column.create_list": "Create list",
"column.direct": "Private mentions", "column.direct": "Private mentions",
@ -454,6 +462,7 @@
"footer.source_code": "View source code", "footer.source_code": "View source code",
"footer.status": "Status", "footer.status": "Status",
"footer.terms_of_service": "Terms of service", "footer.terms_of_service": "Terms of service",
"form_field.optional": "(optional)",
"generic.saved": "Saved", "generic.saved": "Saved",
"getting_started.heading": "Getting started", "getting_started.heading": "Getting started",
"hashtag.admin_moderation": "Open moderation interface for #{name}", "hashtag.admin_moderation": "Open moderation interface for #{name}",

View File

@ -454,6 +454,7 @@
"footer.source_code": "Ver código fuente", "footer.source_code": "Ver código fuente",
"footer.status": "Estado", "footer.status": "Estado",
"footer.terms_of_service": "Términos del servicio", "footer.terms_of_service": "Términos del servicio",
"form_field.optional": "(opcional)",
"generic.saved": "Guardado", "generic.saved": "Guardado",
"getting_started.heading": "Inicio de Mastodon", "getting_started.heading": "Inicio de Mastodon",
"hashtag.admin_moderation": "Abrir interface de moderación para #{name}", "hashtag.admin_moderation": "Abrir interface de moderación para #{name}",

View File

@ -454,6 +454,7 @@
"footer.source_code": "Ver código fuente", "footer.source_code": "Ver código fuente",
"footer.status": "Estado", "footer.status": "Estado",
"footer.terms_of_service": "Condiciones del servicio", "footer.terms_of_service": "Condiciones del servicio",
"form_field.optional": "(opcional)",
"generic.saved": "Guardado", "generic.saved": "Guardado",
"getting_started.heading": "Primeros pasos", "getting_started.heading": "Primeros pasos",
"hashtag.admin_moderation": "Abrir interfaz de moderación para #{name}", "hashtag.admin_moderation": "Abrir interfaz de moderación para #{name}",

View File

@ -454,6 +454,7 @@
"footer.source_code": "Ver código fuente", "footer.source_code": "Ver código fuente",
"footer.status": "Estado", "footer.status": "Estado",
"footer.terms_of_service": "Términos del servicio", "footer.terms_of_service": "Términos del servicio",
"form_field.optional": "(opcional)",
"generic.saved": "Guardado", "generic.saved": "Guardado",
"getting_started.heading": "Primeros pasos", "getting_started.heading": "Primeros pasos",
"hashtag.admin_moderation": "Abrir interfaz de moderación para #{name}", "hashtag.admin_moderation": "Abrir interfaz de moderación para #{name}",

View File

@ -454,6 +454,7 @@
"footer.source_code": "Näytä lähdekoodi", "footer.source_code": "Näytä lähdekoodi",
"footer.status": "Tila", "footer.status": "Tila",
"footer.terms_of_service": "Käyttöehdot", "footer.terms_of_service": "Käyttöehdot",
"form_field.optional": "(valinnainen)",
"generic.saved": "Tallennettu", "generic.saved": "Tallennettu",
"getting_started.heading": "Näin pääset alkuun", "getting_started.heading": "Näin pääset alkuun",
"hashtag.admin_moderation": "Avaa tunnisteen #{name} moderointinäkymä", "hashtag.admin_moderation": "Avaa tunnisteen #{name} moderointinäkymä",

View File

@ -454,6 +454,7 @@
"footer.source_code": "Ver código fonte", "footer.source_code": "Ver código fonte",
"footer.status": "Estado", "footer.status": "Estado",
"footer.terms_of_service": "Condicións do servizo", "footer.terms_of_service": "Condicións do servizo",
"form_field.optional": "(optativo)",
"generic.saved": "Gardado", "generic.saved": "Gardado",
"getting_started.heading": "Primeiros pasos", "getting_started.heading": "Primeiros pasos",
"hashtag.admin_moderation": "Abrir interface de moderación para ##{name}", "hashtag.admin_moderation": "Abrir interface de moderación para ##{name}",

View File

@ -454,6 +454,7 @@
"footer.source_code": "Skoða frumkóða", "footer.source_code": "Skoða frumkóða",
"footer.status": "Staða", "footer.status": "Staða",
"footer.terms_of_service": "Þjónustuskilmálar", "footer.terms_of_service": "Þjónustuskilmálar",
"form_field.optional": "(valfrjálst)",
"generic.saved": "Vistað", "generic.saved": "Vistað",
"getting_started.heading": "Komast í gang", "getting_started.heading": "Komast í gang",
"hashtag.admin_moderation": "Opna umsjónarviðmót fyrir #{name}", "hashtag.admin_moderation": "Opna umsjónarviðmót fyrir #{name}",

View File

@ -454,6 +454,7 @@
"footer.source_code": "Visualizza il codice sorgente", "footer.source_code": "Visualizza il codice sorgente",
"footer.status": "Stato", "footer.status": "Stato",
"footer.terms_of_service": "Termini di servizio", "footer.terms_of_service": "Termini di servizio",
"form_field.optional": "(opzionale)",
"generic.saved": "Salvato", "generic.saved": "Salvato",
"getting_started.heading": "Per iniziare", "getting_started.heading": "Per iniziare",
"hashtag.admin_moderation": "Apri l'interfaccia di moderazione per #{name}", "hashtag.admin_moderation": "Apri l'interfaccia di moderazione per #{name}",

View File

@ -57,6 +57,7 @@
"account.go_to_profile": "Ga naar profiel", "account.go_to_profile": "Ga naar profiel",
"account.hide_reblogs": "Boosts van @{name} verbergen", "account.hide_reblogs": "Boosts van @{name} verbergen",
"account.in_memoriam": "In memoriam.", "account.in_memoriam": "In memoriam.",
"account.joined_long": "Lid geworden op {date}",
"account.joined_short": "Geregistreerd op", "account.joined_short": "Geregistreerd op",
"account.languages": "Getoonde talen wijzigen", "account.languages": "Getoonde talen wijzigen",
"account.link_verified_on": "Eigendom van deze link is gecontroleerd op {date}", "account.link_verified_on": "Eigendom van deze link is gecontroleerd op {date}",
@ -90,6 +91,8 @@
"account.unmute": "@{name} niet langer negeren", "account.unmute": "@{name} niet langer negeren",
"account.unmute_notifications_short": "Meldingen niet langer negeren", "account.unmute_notifications_short": "Meldingen niet langer negeren",
"account.unmute_short": "Niet langer negeren", "account.unmute_short": "Niet langer negeren",
"account_fields_modal.close": "Sluiten",
"account_fields_modal.title": "{name}'s info",
"account_note.placeholder": "Klik om een opmerking toe te voegen", "account_note.placeholder": "Klik om een opmerking toe te voegen",
"admin.dashboard.daily_retention": "Retentiegraad van gebruikers per dag, vanaf registratie", "admin.dashboard.daily_retention": "Retentiegraad van gebruikers per dag, vanaf registratie",
"admin.dashboard.monthly_retention": "Retentiegraad van gebruikers per maand, vanaf registratie", "admin.dashboard.monthly_retention": "Retentiegraad van gebruikers per maand, vanaf registratie",
@ -589,6 +592,7 @@
"load_pending": "{count, plural, one {# nieuw item} other {# nieuwe items}}", "load_pending": "{count, plural, one {# nieuw item} other {# nieuwe items}}",
"loading_indicator.label": "Laden…", "loading_indicator.label": "Laden…",
"media_gallery.hide": "Verberg", "media_gallery.hide": "Verberg",
"minicard.more_items": "+{count}",
"moved_to_account_banner.text": "Omdat je naar {movedToAccount} bent verhuisd is jouw account {disabledAccount} momenteel uitgeschakeld.", "moved_to_account_banner.text": "Omdat je naar {movedToAccount} bent verhuisd is jouw account {disabledAccount} momenteel uitgeschakeld.",
"mute_modal.hide_from_notifications": "Onder meldingen verbergen", "mute_modal.hide_from_notifications": "Onder meldingen verbergen",
"mute_modal.hide_options": "Opties verbergen", "mute_modal.hide_options": "Opties verbergen",

View File

@ -454,6 +454,7 @@
"footer.source_code": "Ver código-fonte", "footer.source_code": "Ver código-fonte",
"footer.status": "Estado", "footer.status": "Estado",
"footer.terms_of_service": "Termos de serviço", "footer.terms_of_service": "Termos de serviço",
"form_field.optional": "(opcional)",
"generic.saved": "Guardado", "generic.saved": "Guardado",
"getting_started.heading": "Primeiros passos", "getting_started.heading": "Primeiros passos",
"hashtag.admin_moderation": "Abrir interface de moderação para #{name}", "hashtag.admin_moderation": "Abrir interface de moderação para #{name}",

View File

@ -452,6 +452,7 @@
"footer.source_code": "Shihni kodin burim", "footer.source_code": "Shihni kodin burim",
"footer.status": "Gjendje", "footer.status": "Gjendje",
"footer.terms_of_service": "Kushte shërbimi", "footer.terms_of_service": "Kushte shërbimi",
"form_field.optional": "(opsionale)",
"generic.saved": "U ruajt", "generic.saved": "U ruajt",
"getting_started.heading": "Si tia fillohet", "getting_started.heading": "Si tia fillohet",
"hashtag.admin_moderation": "Hap ndërfaqe moderimi për #{name}", "hashtag.admin_moderation": "Hap ndërfaqe moderimi për #{name}",

View File

@ -454,6 +454,7 @@
"footer.source_code": "Mã nguồn", "footer.source_code": "Mã nguồn",
"footer.status": "Trạng thái", "footer.status": "Trạng thái",
"footer.terms_of_service": "Điều khoản dịch vụ", "footer.terms_of_service": "Điều khoản dịch vụ",
"form_field.optional": "(tùy chọn)",
"generic.saved": "Đã lưu", "generic.saved": "Đã lưu",
"getting_started.heading": "Quản lý", "getting_started.heading": "Quản lý",
"hashtag.admin_moderation": "Mở giao diện quản trị #{name}", "hashtag.admin_moderation": "Mở giao diện quản trị #{name}",

View File

@ -454,6 +454,7 @@
"footer.source_code": "查看源代码", "footer.source_code": "查看源代码",
"footer.status": "状态", "footer.status": "状态",
"footer.terms_of_service": "服务条款", "footer.terms_of_service": "服务条款",
"form_field.optional": "(选填)",
"generic.saved": "已保存", "generic.saved": "已保存",
"getting_started.heading": "开始使用", "getting_started.heading": "开始使用",
"hashtag.admin_moderation": "打开 #{name} 的管理界面", "hashtag.admin_moderation": "打开 #{name} 的管理界面",

View File

@ -454,6 +454,7 @@
"footer.source_code": "檢視原始碼", "footer.source_code": "檢視原始碼",
"footer.status": "狀態", "footer.status": "狀態",
"footer.terms_of_service": "服務條款", "footer.terms_of_service": "服務條款",
"form_field.optional": "(可選的)",
"generic.saved": "已儲存", "generic.saved": "已儲存",
"getting_started.heading": "開始使用", "getting_started.heading": "開始使用",
"hashtag.admin_moderation": "開啟 #{name} 的管理介面", "hashtag.admin_moderation": "開啟 #{name} 的管理介面",

View File

@ -0,0 +1,158 @@
import { createSlice } from '@reduxjs/toolkit';
import {
apiCreateCollection,
apiGetAccountCollections,
// apiGetCollection,
} from '@/mastodon/api/collections';
import type {
ApiCollectionJSON,
ApiCreateCollectionPayload,
} from '@/mastodon/api_types/collections';
import {
createAppSelector,
createDataLoadingThunk,
} from '@/mastodon/store/typed_functions';
type QueryStatus = 'idle' | 'loading' | 'error';
interface CollectionState {
// Collections mapped by collection id
collections: Record<string, ApiCollectionJSON>;
// Lists of collection ids mapped by account id
accountCollections: Record<
string,
{
collectionIds: string[];
status: QueryStatus;
}
>;
}
const initialState: CollectionState = {
collections: {},
accountCollections: {},
};
const collectionSlice = createSlice({
name: 'collections',
initialState,
reducers: {},
extraReducers(builder) {
/**
* Fetching account collections
*/
builder.addCase(fetchAccountCollections.pending, (state, action) => {
const { accountId } = action.meta.arg;
state.accountCollections[accountId] ??= {
status: 'loading',
collectionIds: [],
};
state.accountCollections[accountId].status = 'loading';
});
builder.addCase(fetchAccountCollections.rejected, (state, action) => {
const { accountId } = action.meta.arg;
state.accountCollections[accountId] = {
status: 'error',
collectionIds: [],
};
});
builder.addCase(fetchAccountCollections.fulfilled, (state, actions) => {
const { collections } = actions.payload;
const collectionsMap: Record<string, ApiCollectionJSON> = {};
const collectionIds: string[] = [];
collections.forEach((collection) => {
const { id } = collection;
collectionsMap[id] = collection;
collectionIds.push(id);
});
state.collections = collectionsMap;
state.accountCollections[actions.meta.arg.accountId] = {
collectionIds,
status: 'idle',
};
});
/**
* Creating a collection
*/
builder.addCase(createCollection.fulfilled, (state, actions) => {
const { collection } = actions.payload;
state.collections[collection.id] = collection;
if (state.accountCollections[collection.account_id]) {
state.accountCollections[collection.account_id]?.collectionIds.unshift(
collection.id,
);
} else {
state.accountCollections[collection.account_id] = {
collectionIds: [collection.id],
status: 'idle',
};
}
});
},
});
export const fetchAccountCollections = createDataLoadingThunk(
`${collectionSlice.name}/fetchAccountCollections`,
({ accountId }: { accountId: string }) => apiGetAccountCollections(accountId),
);
// To be added soon…
//
// export const fetchCollection = createDataLoadingThunk(
// `${collectionSlice.name}/fetchCollection`,
// ({ collectionId }: { collectionId: string }) =>
// apiGetCollection(collectionId),
// );
export const createCollection = createDataLoadingThunk(
`${collectionSlice.name}/createCollection`,
({ payload }: { payload: ApiCreateCollectionPayload }) =>
apiCreateCollection(payload),
);
export const collections = collectionSlice.reducer;
/**
* Selectors
*/
interface AccountCollectionQuery {
status: QueryStatus;
collections: ApiCollectionJSON[];
}
export const selectMyCollections = createAppSelector(
[
(state) => state.meta.get('me') as string,
(state) => state.collections.accountCollections,
(state) => state.collections.collections,
],
(me, collectionsByAccountId, collectionsById) => {
const myCollectionsQuery = collectionsByAccountId[me];
if (!myCollectionsQuery) {
return {
status: 'error',
collections: [] as ApiCollectionJSON[],
} satisfies AccountCollectionQuery;
}
const { status, collectionIds } = myCollectionsQuery;
return {
status,
collections: collectionIds
.map((id) => collectionsById[id])
.filter((c) => !!c),
} satisfies AccountCollectionQuery;
},
);

View File

@ -1,5 +1,7 @@
import { annualReport } from './annual_report'; import { annualReport } from './annual_report';
import { collections } from './collections';
export const sliceReducers = { export const sliceReducers = {
annualReport, annualReport,
collections,
}; };

View File

@ -12,13 +12,13 @@ export function isProduction() {
else return import.meta.env.PROD; else return import.meta.env.PROD;
} }
export type ServerFeatures = 'fasp'; export type ServerFeatures = 'fasp' | 'collections';
export function isServerFeatureEnabled(feature: ServerFeatures) { export function isServerFeatureEnabled(feature: ServerFeatures) {
return initialState?.features.includes(feature) ?? false; return initialState?.features.includes(feature) ?? false;
} }
type ClientFeatures = 'profile_redesign'; type ClientFeatures = 'profile_redesign' | 'collections';
export function isClientFeatureEnabled(feature: ClientFeatures) { export function isClientFeatureEnabled(feature: ClientFeatures) {
try { try {

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="currentColor"><path d="M280-240v-480h80v480h-80ZM440-80v-800h80v800h-80ZM120-400v-160h80v160h-80Zm480 160v-480h80v480h-80Zm160-160v-160h80v160h-80Z"/></svg>

Before

Width:  |  Height:  |  Size: 254 B

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q54 0 104-17.5t92-50.5L228-676q-33 42-50.5 92T160-480q0 134 93 227t227 93Zm252-124q33-42 50.5-92T800-480q0-134-93-227t-227-93q-54 0-104 17.5T284-732l448 448Z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm197-147q16-12 30-26t26-30L283-733q-16 12-30 26t-26 30l450 450Z"/></svg>

Before

Width:  |  Height:  |  Size: 457 B

After

Width:  |  Height:  |  Size: 358 B

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q54 0 104-17.5t92-50.5L228-676q-33 42-50.5 92T160-480q0 134 93 227t227 93Zm252-124q33-42 50.5-92T800-480q0-134-93-227t-227-93q-54 0-104 17.5T284-732l448 448Z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q54 0 104-17.5t92-50.5L228-676q-33 42-50.5 92T160-480q0 134 93 227t227 93Zm252-124q33-42 50.5-92T800-480q0-134-93-227t-227-93q-54 0-104 17.5T284-732l448 448ZM480-480Z"/></svg>

Before

Width:  |  Height:  |  Size: 457 B

After

Width:  |  Height:  |  Size: 466 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-280q17 0 28.5-11.5T520-320q0-17-11.5-28.5T480-360q-17 0-28.5 11.5T440-320q0 17 11.5 28.5T480-280Zm-40-160h80v-240h-80v240Zm40 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Z"/></svg>

After

Width:  |  Height:  |  Size: 422 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-280q17 0 28.5-11.5T520-320q0-17-11.5-28.5T480-360q-17 0-28.5 11.5T440-320q0 17 11.5 28.5T480-280Zm-40-160h80v-240h-80v240Zm40 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>

After

Width:  |  Height:  |  Size: 518 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M280-240v-480h80v480h-80ZM440-80v-800h80v800h-80ZM120-400v-160h80v160h-80Zm480 160v-480h80v480h-80Zm160-160v-160h80v160h-80Z"/></svg>

After

Width:  |  Height:  |  Size: 230 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M280-240v-480h80v480h-80ZM440-80v-800h80v800h-80ZM120-400v-160h80v160h-80Zm480 160v-480h80v480h-80Zm160-160v-160h80v160h-80Z"/></svg>

After

Width:  |  Height:  |  Size: 230 B

View File

@ -10,7 +10,7 @@ class Admin::Metrics::Dimension::SoftwareVersionsDimension < Admin::Metrics::Dim
protected protected
def perform_query def perform_query
[mastodon_version, ruby_version, postgresql_version, redis_version, elasticsearch_version, libvips_version, imagemagick_version, ffmpeg_version].compact [mastodon_version, ruby_version, postgresql_version, redis_version, elasticsearch_version, libvips_version, ffmpeg_version].compact
end end
def mastodon_version def mastodon_version
@ -70,8 +70,6 @@ class Admin::Metrics::Dimension::SoftwareVersionsDimension < Admin::Metrics::Dim
end end
def libvips_version def libvips_version
return unless Rails.configuration.x.use_vips
{ {
key: 'libvips', key: 'libvips',
human_key: 'libvips', human_key: 'libvips',
@ -80,28 +78,6 @@ class Admin::Metrics::Dimension::SoftwareVersionsDimension < Admin::Metrics::Dim
} }
end end
def imagemagick_version
return if Rails.configuration.x.use_vips
imagemagick_binary = Paperclip.options[:is_windows] ? 'magick convert' : 'convert'
version_output = Terrapin::CommandLine.new(imagemagick_binary, '-version').run
version_match = version_output.match(/Version: ImageMagick (\S+)/)[1].strip
return nil unless version_match
version = version_match
{
key: 'imagemagick',
human_key: 'ImageMagick',
value: version,
human_value: version,
}
rescue Terrapin::CommandNotFoundError, Terrapin::ExitStatusError, Paperclip::Errors::CommandNotFoundError, Paperclip::Errors::CommandFailedError
nil
end
def ffmpeg_version def ffmpeg_version
version_output = Terrapin::CommandLine.new(Rails.configuration.x.ffprobe_binary, '-show_program_version -v 0 -of json').run version_output = Terrapin::CommandLine.new(Rails.configuration.x.ffprobe_binary, '-show_program_version -v 0 -of json').run
version = Oj.load(version_output, mode: :strict, symbol_keys: true).dig(:program_version, :version) version = Oj.load(version_output, mode: :strict, symbol_keys: true).dig(:program_version, :version)

View File

@ -4,7 +4,7 @@ module Account::Avatar
extend ActiveSupport::Concern extend ActiveSupport::Concern
AVATAR_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze AVATAR_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
AVATAR_LIMIT = Rails.configuration.x.use_vips ? 8.megabytes : 2.megabytes AVATAR_LIMIT = 8.megabytes
AVATAR_DIMENSIONS = [400, 400].freeze AVATAR_DIMENSIONS = [400, 400].freeze
AVATAR_GEOMETRY = [AVATAR_DIMENSIONS.first, AVATAR_DIMENSIONS.last].join('x') AVATAR_GEOMETRY = [AVATAR_DIMENSIONS.first, AVATAR_DIMENSIONS.last].join('x')

View File

@ -4,7 +4,7 @@ module Account::Header
extend ActiveSupport::Concern extend ActiveSupport::Concern
HEADER_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze HEADER_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
HEADER_LIMIT = Rails.configuration.x.use_vips ? 8.megabytes : 2.megabytes HEADER_LIMIT = 8.megabytes
HEADER_DIMENSIONS = [1500, 500].freeze HEADER_DIMENSIONS = [1500, 500].freeze
HEADER_GEOMETRY = [HEADER_DIMENSIONS.first, HEADER_DIMENSIONS.last].join('x') HEADER_GEOMETRY = [HEADER_DIMENSIONS.first, HEADER_DIMENSIONS.last].join('x')
HEADER_MAX_PIXELS = HEADER_DIMENSIONS.first * HEADER_DIMENSIONS.last HEADER_MAX_PIXELS = HEADER_DIMENSIONS.first * HEADER_DIMENSIONS.last

View File

@ -39,7 +39,7 @@ class PreviewCard < ApplicationRecord
include Attachmentable include Attachmentable
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
LIMIT = Rails.configuration.x.use_vips ? 8.megabytes : 2.megabytes LIMIT = 8.megabytes
BLURHASH_OPTIONS = { BLURHASH_OPTIONS = {
x_comp: 4, x_comp: 4,
@ -63,7 +63,7 @@ class PreviewCard < ApplicationRecord
belongs_to :author_account, class_name: 'Account', optional: true belongs_to :author_account, class_name: 'Account', optional: true
has_attached_file :image, has_attached_file :image,
processors: [Rails.configuration.x.use_vips ? :lazy_thumbnail : :thumbnail, :blurhash_transcoder], processors: [:lazy_thumbnail, :blurhash_transcoder],
styles: ->(f) { image_styles(f) }, styles: ->(f) { image_styles(f) },
convert_options: { all: '-quality 90 +profile "!icc,*" +set date:modify +set date:create +set date:timestamp' }, convert_options: { all: '-quality 90 +profile "!icc,*" +set date:modify +set date:create +set date:timestamp' },
validate_media_type: false validate_media_type: false

View File

@ -47,6 +47,8 @@ class Quote < ApplicationRecord
def accept! def accept!
update!(state: :accepted) update!(state: :accepted)
reset_parent_cache! if attribute_previously_changed?(:state)
end end
def reject! def reject!
@ -75,6 +77,15 @@ class Quote < ApplicationRecord
private private
def reset_parent_cache!
return if status_id.nil?
Rails.cache.delete("v3:statuses/#{status_id}")
# This clears the web cache for the ActivityPub representation
Rails.cache.delete("statuses/show:v3:statuses/#{status_id}")
end
def set_accounts def set_accounts
self.account = status.account self.account = status.account
self.quoted_account = quoted_status&.account self.quoted_account = quoted_status&.account

View File

@ -1,12 +0,0 @@
# frozen_string_literal: true
class REST::BaseCollectionSerializer < ActiveModel::Serializer
attributes :id, :uri, :name, :description, :language, :local, :sensitive,
:discoverable, :item_count, :created_at, :updated_at
belongs_to :tag, serializer: REST::StatusSerializer::TagSerializer
def id
object.id.to_s
end
end

View File

@ -3,11 +3,15 @@
class REST::CollectionItemSerializer < ActiveModel::Serializer class REST::CollectionItemSerializer < ActiveModel::Serializer
delegate :accepted?, to: :object delegate :accepted?, to: :object
attributes :id, :position, :state attributes :id, :state
belongs_to :account, serializer: REST::AccountSerializer, if: :accepted? attribute :account_id, if: :accepted?
def id def id
object.id.to_s object.id.to_s
end end
def account_id
object.account_id.to_s
end
end end

View File

@ -1,11 +1,23 @@
# frozen_string_literal: true # frozen_string_literal: true
class REST::CollectionSerializer < REST::BaseCollectionSerializer class REST::CollectionSerializer < ActiveModel::Serializer
belongs_to :account, serializer: REST::AccountSerializer attributes :id, :uri, :name, :description, :language, :account_id,
:local, :sensitive, :discoverable, :item_count,
:created_at, :updated_at
belongs_to :tag, serializer: REST::StatusSerializer::TagSerializer
has_many :items, serializer: REST::CollectionItemSerializer has_many :items, serializer: REST::CollectionItemSerializer
def id
object.id.to_s
end
def items def items
object.items_for(current_user&.account) object.items_for(current_user&.account)
end end
def account_id
object.account_id.to_s
end
end end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
class REST::CollectionWithAccountsSerializer < ActiveModel::Serializer
belongs_to :collection, serializer: REST::CollectionSerializer
has_many :accounts, serializer: REST::AccountSerializer
def collection
object
end
def accounts
[object.account] + object.collection_items.map(&:account)
end
end

View File

@ -6,7 +6,10 @@ class Admin::AccountDeletionWorker
sidekiq_options queue: 'pull', lock: :until_executed, lock_ttl: 1.week.to_i sidekiq_options queue: 'pull', lock: :until_executed, lock_ttl: 1.week.to_i
def perform(account_id) def perform(account_id)
DeleteAccountService.new.call(Account.find(account_id), reserve_username: true, reserve_email: true) delete_account = Account.find(account_id)
return unless delete_account.unavailable?
DeleteAccountService.new.call(delete_account, reserve_username: true, reserve_email: true)
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
true true
end end

View File

@ -94,13 +94,7 @@ module Mastodon
require 'mastodon/redis_configuration' require 'mastodon/redis_configuration'
::REDIS_CONFIGURATION = Mastodon::RedisConfiguration.new ::REDIS_CONFIGURATION = Mastodon::RedisConfiguration.new
config.x.use_vips = ENV['MASTODON_USE_LIBVIPS'] != 'false' require_relative '../lib/paperclip/vips_lazy_thumbnail'
if config.x.use_vips
require_relative '../lib/paperclip/vips_lazy_thumbnail'
else
require_relative '../lib/paperclip/lazy_thumbnail'
end
end end
config.x.cache_buster = config_for(:cache_buster) config.x.cache_buster = config_for(:cache_buster)

View File

@ -1,27 +0,0 @@
<policymap>
<!-- Set some basic system resource limits -->
<policy domain="resource" name="time" value="60" />
<policy domain="module" rights="none" pattern="URL" />
<policy domain="filter" rights="none" pattern="*" />
<!--
Ideally, we would restrict ImageMagick to only accessing its own
disk-backed pixel cache as well as Mastodon-created Tempfiles.
However, those paths depend on the operating system and environment
variables, so they can only be known at runtime.
Furthermore, those paths are not necessarily shared across Mastodon
processes, so even creating a policy.xml at runtime is impractical.
For the time being, only disable indirect reads.
-->
<policy domain="path" rights="none" pattern="@*" />
<!-- Disallow any coder by default, and only enable ones required by Mastodon -->
<policy domain="coder" rights="none" pattern="*" />
<policy domain="coder" rights="read | write" pattern="{JPEG,PNG,GIF,WEBP,HEIC,AVIF}" />
<policy domain="coder" rights="write" pattern="{HISTOGRAM,RGB,INFO}" />
</policymap>

View File

@ -17,12 +17,6 @@ if ENV['REDIS_NAMESPACE']
abort message # rubocop:disable Rails/Exit abort message # rubocop:disable Rails/Exit
end end
if ENV['MASTODON_USE_LIBVIPS'] == 'false'
warn <<~MESSAGE
WARNING: Mastodon support for ImageMagick is deprecated and will be removed in future versions. Please consider using libvips instead.
MESSAGE
end
if ENV.key?('WHITELIST_MODE') if ENV.key?('WHITELIST_MODE')
warn(<<~MESSAGE.squish) warn(<<~MESSAGE.squish)
WARNING: The environment variable WHITELIST_MODE has been replaced with WARNING: The environment variable WHITELIST_MODE has been replaced with

View File

@ -182,10 +182,3 @@ unless defined?(Seahorse)
end end
end end
end end
# Set our ImageMagick security policy, but allow admins to override it
ENV['MAGICK_CONFIGURE_PATH'] = begin
imagemagick_config_paths = ENV.fetch('MAGICK_CONFIGURE_PATH', '').split(File::PATH_SEPARATOR)
imagemagick_config_paths << Rails.root.join('config', 'imagemagick').expand_path.to_s
imagemagick_config_paths.join(File::PATH_SEPARATOR)
end

View File

@ -1,35 +1,33 @@
# frozen_string_literal: true # frozen_string_literal: true
if Rails.configuration.x.use_vips ENV['VIPS_BLOCK_UNTRUSTED'] = 'true'
ENV['VIPS_BLOCK_UNTRUSTED'] = 'true'
require 'vips' require 'vips'
unless Vips.at_least_libvips?(8, 13) unless Vips.at_least_libvips?(8, 13)
abort <<~ERROR.squish # rubocop:disable Rails/Exit abort <<~ERROR.squish # rubocop:disable Rails/Exit
Incompatible libvips version (#{Vips.version_string}), please install libvips >= 8.13 Incompatible libvips version (#{Vips.version_string}), please install libvips >= 8.13
ERROR ERROR
end
Vips.block('VipsForeign', true)
%w(
VipsForeignLoadNsgif
VipsForeignLoadJpeg
VipsForeignLoadPng
VipsForeignLoadWebp
VipsForeignLoadHeif
VipsForeignSavePng
VipsForeignSaveSpng
VipsForeignSaveJpeg
VipsForeignSaveWebp
).each do |operation|
Vips.block(operation, false)
end
Vips.block_untrusted(true)
end end
Vips.block('VipsForeign', true)
%w(
VipsForeignLoadNsgif
VipsForeignLoadJpeg
VipsForeignLoadPng
VipsForeignLoadWebp
VipsForeignLoadHeif
VipsForeignSavePng
VipsForeignSaveSpng
VipsForeignSaveJpeg
VipsForeignSaveWebp
).each do |operation|
Vips.block(operation, false)
end
Vips.block_untrusted(true)
# In some places of the code, we rescue this exception, but we don't always # In some places of the code, we rescue this exception, but we don't always
# load libvips, so it may be an undefined constant: # load libvips, so it may be an undefined constant:
unless defined?(Vips) unless defined?(Vips)

View File

@ -7,6 +7,7 @@
%w( %w(
/blocks /blocks
/bookmarks /bookmarks
/collections/(*any)
/conversations /conversations
/deck/(*any) /deck/(*any)
/directory /directory

View File

@ -423,6 +423,7 @@ export default tseslint.config([
allowNumber: true, allowNumber: true,
}, },
], ],
'@typescript-eslint/non-nullable-type-assertion-style': 'off',
}, },
}, },
{ {

View File

@ -19,14 +19,8 @@ module Paperclip
private private
def blurhash_params def blurhash_params
if Rails.configuration.x.use_vips image = Vips::Image.thumbnail(@file.path, 100)
image = Vips::Image.thumbnail(@file.path, 100) [image.width, image.height, image.colourspace(:srgb).extract_band(0, n: 3).to_a.flatten]
[image.width, image.height, image.colourspace(:srgb).extract_band(0, n: 3).to_a.flatten]
else
pixels = convert(':source -depth 8 RGB:-', source: File.expand_path(@file.path)).unpack('C*')
geometry = options.fetch(:file_geometry_parser).from_file(@file)
[geometry.width, geometry.height, pixels]
end
end end
end end
end end

View File

@ -10,7 +10,7 @@ module Paperclip
BINS = 10 BINS = 10
def make def make
background_palette, foreground_palette = Rails.configuration.x.use_vips ? palettes_from_libvips : palettes_from_imagemagick background_palette, foreground_palette = palettes_from_libvips
background_color = background_palette.first || foreground_palette.first background_color = background_palette.first || foreground_palette.first
foreground_colors = [] foreground_colors = []
@ -93,17 +93,6 @@ module Paperclip
[background_palette, foreground_palette] [background_palette, foreground_palette]
end end
def palettes_from_imagemagick
depth = 8
# Determine background palette by getting colors close to the image's edge only
background_palette = palette_from_im_histogram(convert(':source -alpha set -gravity Center -region 75%x75% -fill None -colorize 100% -alpha transparent +region -format %c -colors :quantity -depth :depth histogram:info:', source: File.expand_path(@file.path), quantity: 10, depth: depth), 10)
# Determine foreground palette from the whole image
foreground_palette = palette_from_im_histogram(convert(':source -format %c -colors :quantity -depth :depth histogram:info:', source: File.expand_path(@file.path), quantity: 10, depth: depth), 10)
[background_palette, foreground_palette]
end
def downscaled_image def downscaled_image
image = Vips::Image.new_from_file(@file.path, access: :random).thumbnail_image(100) image = Vips::Image.new_from_file(@file.path, access: :random).thumbnail_image(100)

View File

@ -186,7 +186,7 @@
"prettier": "^3.3.3", "prettier": "^3.3.3",
"react-test-renderer": "^18.2.0", "react-test-renderer": "^18.2.0",
"storybook": "^10.0.5", "storybook": "^10.0.5",
"stylelint": "^16.19.1", "stylelint": "^17.0.0",
"stylelint-config-prettier-scss": "^1.0.0", "stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-standard-scss": "^17.0.0", "stylelint-config-standard-scss": "^17.0.0",
"typescript": "~5.9.0", "typescript": "~5.9.0",

View File

@ -564,6 +564,8 @@ RSpec.describe Account do
it { is_expected.to_not allow_values('username', 'Username').for(:username) } it { is_expected.to_not allow_values('username', 'Username').for(:username) }
end end
it { is_expected.to validate_length_of(:username).is_at_most(described_class::USERNAME_LENGTH_HARD_LIMIT) }
it { is_expected.to allow_values('the-doctor', username_over_limit).for(:username) } it { is_expected.to allow_values('the-doctor', username_over_limit).for(:username) }
it { is_expected.to_not allow_values('the doctor').for(:username) } it { is_expected.to_not allow_values('the doctor').for(:username) }

View File

@ -90,8 +90,14 @@ RSpec.describe CustomEmoji, :attachment_processing do
subject { Fabricate.build :custom_emoji } subject { Fabricate.build :custom_emoji }
it { is_expected.to validate_uniqueness_of(:shortcode).scoped_to(:domain) } it { is_expected.to validate_uniqueness_of(:shortcode).scoped_to(:domain) }
it { is_expected.to validate_length_of(:shortcode).is_at_least(described_class::MINIMUM_SHORTCODE_SIZE) } it { is_expected.to validate_length_of(:shortcode).is_at_least(described_class::MINIMUM_SHORTCODE_SIZE).is_at_most(described_class::MAX_SHORTCODE_SIZE) }
it { is_expected.to allow_values('cats').for(:shortcode) } it { is_expected.to allow_values('cats').for(:shortcode) }
it { is_expected.to_not allow_values('@#$@#$', 'X').for(:shortcode) } it { is_expected.to_not allow_values('@#$@#$', 'X').for(:shortcode) }
context 'when remote' do
subject { Fabricate.build :custom_emoji, domain: 'host.example' }
it { is_expected.to validate_length_of(:shortcode).is_at_most(described_class::MAX_FEDERATED_SHORTCODE_SIZE) }
end
end end
end end

View File

@ -3,6 +3,11 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe CustomFilterKeyword do RSpec.describe CustomFilterKeyword do
describe 'Validations' do
it { is_expected.to validate_length_of(:keyword).is_at_most(described_class::KEYWORD_LENGTH_LIMIT) }
it { is_expected.to validate_presence_of(:keyword) }
end
describe '#to_regex' do describe '#to_regex' do
context 'when whole_word is true' do context 'when whole_word is true' do
it 'builds a regex with boundaries and the keyword' do it 'builds a regex with boundaries and the keyword' do

View File

@ -6,8 +6,9 @@ RSpec.describe CustomFilter do
it_behaves_like 'Expireable' it_behaves_like 'Expireable'
describe 'Validations' do describe 'Validations' do
it { is_expected.to validate_presence_of(:title) } it { is_expected.to validate_length_of(:title).is_at_most(described_class::TITLE_LENGTH_LIMIT) }
it { is_expected.to validate_presence_of(:context) } it { is_expected.to validate_presence_of(:context) }
it { is_expected.to validate_presence_of(:title) }
it { is_expected.to_not allow_values([], %w(invalid)).for(:context) } it { is_expected.to_not allow_values([], %w(invalid)).for(:context) }
it { is_expected.to allow_values(%w(home)).for(:context) } it { is_expected.to allow_values(%w(home)).for(:context) }

View File

@ -6,6 +6,7 @@ RSpec.describe List do
describe 'Validations' do describe 'Validations' do
subject { Fabricate.build :list } subject { Fabricate.build :list }
it { is_expected.to validate_length_of(:title).is_at_most(described_class::TITLE_LENGTH_LIMIT) }
it { is_expected.to validate_presence_of(:title) } it { is_expected.to validate_presence_of(:title) }
context 'when account has hit max list limit' do context 'when account has hit max list limit' do

View File

@ -219,9 +219,7 @@ RSpec.describe MediaAttachment, :attachment_processing do
describe 'ogg with cover art' do describe 'ogg with cover art' do
let(:media) { Fabricate(:media_attachment, file: attachment_fixture('boop.ogg')) } let(:media) { Fabricate(:media_attachment, file: attachment_fixture('boop.ogg')) }
let(:expected_media_duration) { 0.235102 } let(:expected_media_duration) { 0.235102 }
let(:expected_background_color) { '#268cd9' }
# The libvips and ImageMagick implementations produce different results
let(:expected_background_color) { Rails.configuration.x.use_vips ? '#268cd9' : '#3088d4' }
it 'sets correct file metadata' do it 'sets correct file metadata' do
expect(media) expect(media)

View File

@ -3,12 +3,6 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe PreviewCard do RSpec.describe PreviewCard do
describe 'file size limit', :attachment_processing do
it 'is set differently whether vips is enabled or not' do
expect(described_class::LIMIT).to eq(Rails.configuration.x.use_vips ? 8.megabytes : 2.megabytes)
end
end
describe 'Validations' do describe 'Validations' do
describe 'url' do describe 'url' do
it { is_expected.to allow_values('http://example.host/path', 'https://example.host/path').for(:url) } it { is_expected.to allow_values('http://example.host/path', 'https://example.host/path').for(:url) }

View File

@ -20,6 +20,19 @@ RSpec.describe 'ActivityPub Inboxes' do
end end
end end
context 'with an excessively large payload' do
subject { post inbox_path, params: { this: :that, those: :these }.to_json, sign_with: remote_account }
before { stub_const('ActivityPub::Activity::MAX_JSON_SIZE', 1.byte) }
it 'returns http content too large' do
subject
expect(response)
.to have_http_status(413)
end
end
context 'with a specific account' do context 'with a specific account' do
subject { post account_inbox_path(account_username: account.username), params: {}.to_json, sign_with: remote_account } subject { post account_inbox_path(account_username: account.username), params: {}.to_json, sign_with: remote_account }

View File

@ -102,7 +102,7 @@ RSpec.describe 'Media' do
allow(user.account).to receive(:media_attachments).and_return(media_attachments) allow(user.account).to receive(:media_attachments).and_return(media_attachments)
end end
context 'when imagemagick cannot identify the file type' do context 'when file type cannot be identified' do
it 'returns http unprocessable entity' do it 'returns http unprocessable entity' do
allow(media_attachments).to receive(:create!).and_raise(Paperclip::Errors::NotIdentifiedByImageMagickError) allow(media_attachments).to receive(:create!).and_raise(Paperclip::Errors::NotIdentifiedByImageMagickError)

View File

@ -26,6 +26,7 @@ RSpec.describe 'Api::V1Alpha::CollectionItems', feature: :collections do
end.to change(collection.collection_items, :count).by(1) end.to change(collection.collection_items, :count).by(1)
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(response.parsed_body).to have_key('collection_item')
end end
end end

View File

@ -20,7 +20,7 @@ RSpec.describe 'Api::V1Alpha::Collections', feature: :collections do
subject subject
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(response.parsed_body.size).to eq 3 expect(response.parsed_body[:collections].size).to eq 3
end end
context 'with limit param' do context 'with limit param' do
@ -30,7 +30,7 @@ RSpec.describe 'Api::V1Alpha::Collections', feature: :collections do
subject subject
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(response.parsed_body.size).to eq 1 expect(response.parsed_body[:collections].size).to eq 1
expect(response) expect(response)
.to include_pagination_headers( .to include_pagination_headers(
@ -46,7 +46,7 @@ RSpec.describe 'Api::V1Alpha::Collections', feature: :collections do
subject subject
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(response.parsed_body.size).to eq 1 expect(response.parsed_body[:collections].size).to eq 1
expect(response) expect(response)
.to include_pagination_headers( .to include_pagination_headers(
@ -66,7 +66,7 @@ RSpec.describe 'Api::V1Alpha::Collections', feature: :collections do
subject subject
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(response.parsed_body.size).to eq 3 expect(response.parsed_body[:collections].size).to eq 3
end end
end end
@ -77,10 +77,23 @@ RSpec.describe 'Api::V1Alpha::Collections', feature: :collections do
subject subject
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(response.parsed_body.size).to eq 4 expect(response.parsed_body[:collections].size).to eq 4
end end
end end
end end
context 'when the requesting user is blocked by the given account' do
before do
account.block!(user.account)
end
it 'returns an empty array' do
subject
expect(response).to have_http_status(200)
expect(response.parsed_body[:collections]).to eq []
end
end
end end
describe 'GET /api/v1_alpha/collections/:id' do describe 'GET /api/v1_alpha/collections/:id' do
@ -96,7 +109,7 @@ RSpec.describe 'Api::V1Alpha::Collections', feature: :collections do
subject subject
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(response.parsed_body[:items].size).to eq 2 expect(response.parsed_body[:collection][:items].size).to eq 2
end end
end end
@ -120,8 +133,8 @@ RSpec.describe 'Api::V1Alpha::Collections', feature: :collections do
subject subject
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(response.parsed_body[:items].size).to eq 1 expect(response.parsed_body[:collection][:items].size).to eq 1
expect(response.parsed_body[:items][0]['position']).to eq items.last.position expect(response.parsed_body[:collection][:items][0]['id']).to eq items.last.id.to_s
end end
end end
end end

View File

@ -71,7 +71,7 @@ RSpec.describe 'Media API', :attachment_processing do
allow(user.account).to receive(:media_attachments).and_return(media_attachments) allow(user.account).to receive(:media_attachments).and_return(media_attachments)
end end
context 'when imagemagick cannot identify the file type' do context 'when file type cannot be identified' do
before do before do
allow(media_attachments).to receive(:create!).and_raise(Paperclip::Errors::NotIdentifiedByImageMagickError) allow(media_attachments).to receive(:create!).and_raise(Paperclip::Errors::NotIdentifiedByImageMagickError)
end end

View File

@ -190,6 +190,17 @@ RSpec.describe 'API Web Push Subscriptions' do
.to eq(alerts_payload[:data][:alerts][type.to_sym].to_s) .to eq(alerts_payload[:data][:alerts][type.to_sym].to_s)
end end
end end
context 'when using other user subscription' do
let(:subscription) { Fabricate(:web_push_subscription) }
it 'does not change settings' do
put api_web_push_subscription_path(subscription), params: alerts_payload
expect(response)
.to have_http_status(404)
end
end
end end
def created_push_subscription def created_push_subscription

View File

@ -1,41 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe REST::BaseCollectionSerializer do
subject do
serialized_record_json(collection, described_class, options: {
scope: current_user,
scope_name: :current_user,
})
end
let(:current_user) { nil }
let(:tag) { Fabricate(:tag, name: 'discovery') }
let(:collection) do
Fabricate(:collection,
id: 2342,
name: 'Exquisite follows',
description: 'Always worth a follow',
local: true,
sensitive: true,
discoverable: false,
tag:)
end
it 'includes the relevant attributes' do
expect(subject)
.to include(
'id' => '2342',
'name' => 'Exquisite follows',
'description' => 'Always worth a follow',
'local' => true,
'sensitive' => true,
'discoverable' => false,
'tag' => a_hash_including('name' => 'discovery'),
'created_at' => match_api_datetime_format,
'updated_at' => match_api_datetime_format
)
end
end

View File

@ -8,8 +8,7 @@ RSpec.describe REST::CollectionItemSerializer do
let(:collection_item) do let(:collection_item) do
Fabricate(:collection_item, Fabricate(:collection_item,
id: 2342, id: 2342,
state:, state:)
position: 4)
end end
context 'when state is `accepted`' do context 'when state is `accepted`' do
@ -19,9 +18,8 @@ RSpec.describe REST::CollectionItemSerializer do
expect(subject) expect(subject)
.to include( .to include(
'id' => '2342', 'id' => '2342',
'account' => an_instance_of(Hash), 'account_id' => collection_item.account_id.to_s,
'state' => 'accepted', 'state' => 'accepted'
'position' => 4
) )
end end
end end
@ -31,7 +29,7 @@ RSpec.describe REST::CollectionItemSerializer do
let(:state) { unaccepted_state } let(:state) { unaccepted_state }
it 'does not include an account' do it 'does not include an account' do
expect(subject.keys).to_not include('account') expect(subject.keys).to_not include('account_id')
end end
end end
end end

Some files were not shown because too many files have changed in this diff Show More