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
RUN apt-get update && \
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
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0

View File

@ -58,5 +58,5 @@ jobs:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
zip: true
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

View File

@ -173,93 +173,6 @@ jobs:
env:
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:
name: End to End testing
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`
import '../app/javascript/styles/mastodon-light.scss';
import './styles.css';
import { modes } from './modes';
const localeFiles = import.meta.glob('@/mastodon/locales/*.json', {
query: { as: 'json' },
@ -198,6 +199,13 @@ const preview: Preview = {
msw: {
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" \
# Optimize jemalloc 5.x performance
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
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
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.
The following table attempts to summary those limits.
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 summarizes those limits.
| Limited property | Size limit | Consequence of exceeding the limit |
| ------------------------------------------------------------- | ---------- | ---------------------------------- |

View File

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

1
Vagrantfile vendored
View File

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

View File

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

View File

@ -26,16 +26,18 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
def index
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
def show
cache_if_unauthenticated!
authorize @collection, :show?
render json: @collection, serializer: REST::CollectionSerializer
render json: @collection, serializer: REST::CollectionWithAccountsSerializer
end
def create
@ -43,7 +45,7 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
@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
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
render json: @collection, serializer: REST::CollectionSerializer
render json: @collection, serializer: REST::CollectionSerializer, adapter: :json
end
def destroy

View File

@ -29,7 +29,7 @@ class StatusesController < ApplicationController
end
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
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 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 SoundIcon from '@/material-icons/400-24px/graphic_eq.svg?react';
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
import { undoUploadCompose } from 'mastodon/actions/compose';
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 { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { SelectField, TextInputField } from 'mastodon/components/form_fields';
import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import type { List } from 'mastodon/models/list';
@ -149,68 +150,49 @@ const NewList: React.FC<{ list?: List | null }> = ({ list }) => {
return (
<form className='simple_form app-form' onSubmit={handleSubmit}>
<div className='fields-group'>
<div className='input with_label'>
<div className='label_input'>
<label htmlFor='list_title'>
<FormattedMessage
id='lists.list_name'
defaultMessage='List name'
/>
</label>
<div className='label_input__wrapper'>
<input
id='list_title'
type='text'
value={title}
onChange={handleTitleChange}
maxLength={30}
required
placeholder=' '
/>
</div>
</div>
</div>
<TextInputField
required
maxLength={30}
label={
<FormattedMessage id='lists.list_name' defaultMessage='List name' />
}
value={title}
onChange={handleTitleChange}
id='list_title'
/>
</div>
<div className='fields-group'>
<div className='input with_label'>
<div className='label_input'>
<label htmlFor='list_replies_policy'>
<FormattedMessage
id='lists.show_replies_to'
defaultMessage='Include replies from list members to'
/>
</label>
<div className='label_input__wrapper'>
<select
id='list_replies_policy'
value={repliesPolicy}
onChange={handleRepliesPolicyChange}
>
<FormattedMessage
id='lists.replies_policy.none'
defaultMessage='No one'
>
{(msg) => <option value='none'>{msg}</option>}
</FormattedMessage>
<FormattedMessage
id='lists.replies_policy.list'
defaultMessage='Members of the list'
>
{(msg) => <option value='list'>{msg}</option>}
</FormattedMessage>
<FormattedMessage
id='lists.replies_policy.followed'
defaultMessage='Any followed user'
>
{(msg) => <option value='followed'>{msg}</option>}
</FormattedMessage>
</select>
</div>
</div>
</div>
<SelectField
label={
<FormattedMessage
id='lists.show_replies_to'
defaultMessage='Include replies from list members to'
/>
}
value={repliesPolicy}
onChange={handleRepliesPolicyChange}
id='list_replies_policy'
>
<FormattedMessage
id='lists.replies_policy.none'
defaultMessage='No one'
>
{(msg) => <option value='none'>{msg}</option>}
</FormattedMessage>
<FormattedMessage
id='lists.replies_policy.list'
defaultMessage='Members of the list'
>
{(msg) => <option value='list'>{msg}</option>}
</FormattedMessage>
<FormattedMessage
id='lists.replies_policy.followed'
defaultMessage='Any followed user'
>
{(msg) => <option value='followed'>{msg}</option>}
</FormattedMessage>
</SelectField>
</div>
{id && (

View File

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

View File

@ -63,6 +63,7 @@ import {
Lists,
ListEdit,
ListMembers,
Collections,
Blocks,
DomainBlocks,
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.
// Without this it ends up in ~8 very commonly used bundles.
import '../../components/status';
import { areCollectionsEnabled } from '../collections/utils';
const messages = defineMessages({
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='/mutes' component={Mutes} content={children} />
<WrappedRoute path='/lists' component={Lists} content={children} />
{areCollectionsEnabled() &&
<WrappedRoute path='/collections' component={Collections} content={children} />
}
<Route component={BundleColumnError} />
</WrappedSwitch>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -187,6 +187,7 @@
"bundle_modal_error.close": "Close",
"bundle_modal_error.message": "Something went wrong while loading this screen.",
"bundle_modal_error.retry": "Try again",
"callout.dismiss": "Dismiss",
"carousel.current": "<sr>Slide</sr> {current, number} / {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.",
@ -194,9 +195,16 @@
"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.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.blocks": "Blocked users",
"column.bookmarks": "Bookmarks",
"column.collections": "My collections",
"column.community": "Local timeline",
"column.create_list": "Create list",
"column.direct": "Private mentions",
@ -454,6 +462,7 @@
"footer.source_code": "View source code",
"footer.status": "Status",
"footer.terms_of_service": "Terms of service",
"form_field.optional": "(optional)",
"generic.saved": "Saved",
"getting_started.heading": "Getting started",
"hashtag.admin_moderation": "Open moderation interface for #{name}",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -57,6 +57,7 @@
"account.go_to_profile": "Ga naar profiel",
"account.hide_reblogs": "Boosts van @{name} verbergen",
"account.in_memoriam": "In memoriam.",
"account.joined_long": "Lid geworden op {date}",
"account.joined_short": "Geregistreerd op",
"account.languages": "Getoonde talen wijzigen",
"account.link_verified_on": "Eigendom van deze link is gecontroleerd op {date}",
@ -90,6 +91,8 @@
"account.unmute": "@{name} niet langer negeren",
"account.unmute_notifications_short": "Meldingen 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",
"admin.dashboard.daily_retention": "Retentiegraad van gebruikers per dag, 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}}",
"loading_indicator.label": "Laden…",
"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.",
"mute_modal.hide_from_notifications": "Onder meldingen verbergen",
"mute_modal.hide_options": "Opties verbergen",

View File

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

View File

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

View File

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

View File

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

View File

@ -454,6 +454,7 @@
"footer.source_code": "檢視原始碼",
"footer.status": "狀態",
"footer.terms_of_service": "服務條款",
"form_field.optional": "(可選的)",
"generic.saved": "已儲存",
"getting_started.heading": "開始使用",
"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 { collections } from './collections';
export const sliceReducers = {
annualReport,
collections,
};

View File

@ -12,13 +12,13 @@ export function isProduction() {
else return import.meta.env.PROD;
}
export type ServerFeatures = 'fasp';
export type ServerFeatures = 'fasp' | 'collections';
export function isServerFeatureEnabled(feature: ServerFeatures) {
return initialState?.features.includes(feature) ?? false;
}
type ClientFeatures = 'profile_redesign';
type ClientFeatures = 'profile_redesign' | 'collections';
export function isClientFeatureEnabled(feature: ClientFeatures) {
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
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
def mastodon_version
@ -70,8 +70,6 @@ class Admin::Metrics::Dimension::SoftwareVersionsDimension < Admin::Metrics::Dim
end
def libvips_version
return unless Rails.configuration.x.use_vips
{
key: 'libvips',
human_key: 'libvips',
@ -80,28 +78,6 @@ class Admin::Metrics::Dimension::SoftwareVersionsDimension < Admin::Metrics::Dim
}
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
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)

View File

@ -4,7 +4,7 @@ module Account::Avatar
extend ActiveSupport::Concern
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_GEOMETRY = [AVATAR_DIMENSIONS.first, AVATAR_DIMENSIONS.last].join('x')

View File

@ -4,7 +4,7 @@ module Account::Header
extend ActiveSupport::Concern
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_GEOMETRY = [HEADER_DIMENSIONS.first, HEADER_DIMENSIONS.last].join('x')
HEADER_MAX_PIXELS = HEADER_DIMENSIONS.first * HEADER_DIMENSIONS.last

View File

@ -39,7 +39,7 @@ class PreviewCard < ApplicationRecord
include Attachmentable
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 = {
x_comp: 4,
@ -63,7 +63,7 @@ class PreviewCard < ApplicationRecord
belongs_to :author_account, class_name: 'Account', optional: true
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) },
convert_options: { all: '-quality 90 +profile "!icc,*" +set date:modify +set date:create +set date:timestamp' },
validate_media_type: false

View File

@ -47,6 +47,8 @@ class Quote < ApplicationRecord
def accept!
update!(state: :accepted)
reset_parent_cache! if attribute_previously_changed?(:state)
end
def reject!
@ -75,6 +77,15 @@ class Quote < ApplicationRecord
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
self.account = 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
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
object.id.to_s
end
def account_id
object.account_id.to_s
end
end

View File

@ -1,11 +1,23 @@
# frozen_string_literal: true
class REST::CollectionSerializer < REST::BaseCollectionSerializer
belongs_to :account, serializer: REST::AccountSerializer
class REST::CollectionSerializer < ActiveModel::Serializer
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
def id
object.id.to_s
end
def items
object.items_for(current_user&.account)
end
def account_id
object.account_id.to_s
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
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
true
end

View File

@ -94,13 +94,7 @@ module Mastodon
require 'mastodon/redis_configuration'
::REDIS_CONFIGURATION = Mastodon::RedisConfiguration.new
config.x.use_vips = ENV['MASTODON_USE_LIBVIPS'] != 'false'
if config.x.use_vips
require_relative '../lib/paperclip/vips_lazy_thumbnail'
else
require_relative '../lib/paperclip/lazy_thumbnail'
end
require_relative '../lib/paperclip/vips_lazy_thumbnail'
end
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
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')
warn(<<~MESSAGE.squish)
WARNING: The environment variable WHITELIST_MODE has been replaced with

View File

@ -182,10 +182,3 @@ unless defined?(Seahorse)
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
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)
abort <<~ERROR.squish # rubocop:disable Rails/Exit
Incompatible libvips version (#{Vips.version_string}), please install libvips >= 8.13
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)
unless Vips.at_least_libvips?(8, 13)
abort <<~ERROR.squish # rubocop:disable Rails/Exit
Incompatible libvips version (#{Vips.version_string}), please install libvips >= 8.13
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)
# In some places of the code, we rescue this exception, but we don't always
# load libvips, so it may be an undefined constant:
unless defined?(Vips)

View File

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

View File

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

View File

@ -19,14 +19,8 @@ module Paperclip
private
def blurhash_params
if Rails.configuration.x.use_vips
image = Vips::Image.thumbnail(@file.path, 100)
[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
image = Vips::Image.thumbnail(@file.path, 100)
[image.width, image.height, image.colourspace(:srgb).extract_band(0, n: 3).to_a.flatten]
end
end
end

View File

@ -10,7 +10,7 @@ module Paperclip
BINS = 10
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
foreground_colors = []
@ -93,17 +93,6 @@ module Paperclip
[background_palette, foreground_palette]
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
image = Vips::Image.new_from_file(@file.path, access: :random).thumbnail_image(100)

View File

@ -186,7 +186,7 @@
"prettier": "^3.3.3",
"react-test-renderer": "^18.2.0",
"storybook": "^10.0.5",
"stylelint": "^16.19.1",
"stylelint": "^17.0.0",
"stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-standard-scss": "^17.0.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) }
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_not allow_values('the doctor').for(:username) }

View File

@ -90,8 +90,14 @@ RSpec.describe CustomEmoji, :attachment_processing do
subject { Fabricate.build :custom_emoji }
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_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

View File

@ -3,6 +3,11 @@
require 'rails_helper'
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
context 'when whole_word is true' 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'
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(:title) }
it { is_expected.to_not allow_values([], %w(invalid)).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
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) }
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
let(:media) { Fabricate(:media_attachment, file: attachment_fixture('boop.ogg')) }
let(:expected_media_duration) { 0.235102 }
# The libvips and ImageMagick implementations produce different results
let(:expected_background_color) { Rails.configuration.x.use_vips ? '#268cd9' : '#3088d4' }
let(:expected_background_color) { '#268cd9' }
it 'sets correct file metadata' do
expect(media)

View File

@ -3,12 +3,6 @@
require 'rails_helper'
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 'url' do
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
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
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)
end
context 'when imagemagick cannot identify the file type' do
context 'when file type cannot be identified' do
it 'returns http unprocessable entity' do
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)
expect(response).to have_http_status(200)
expect(response.parsed_body).to have_key('collection_item')
end
end

View File

@ -20,7 +20,7 @@ RSpec.describe 'Api::V1Alpha::Collections', feature: :collections do
subject
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
context 'with limit param' do
@ -30,7 +30,7 @@ RSpec.describe 'Api::V1Alpha::Collections', feature: :collections do
subject
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)
.to include_pagination_headers(
@ -46,7 +46,7 @@ RSpec.describe 'Api::V1Alpha::Collections', feature: :collections do
subject
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)
.to include_pagination_headers(
@ -66,7 +66,7 @@ RSpec.describe 'Api::V1Alpha::Collections', feature: :collections do
subject
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
@ -77,10 +77,23 @@ RSpec.describe 'Api::V1Alpha::Collections', feature: :collections do
subject
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
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
describe 'GET /api/v1_alpha/collections/:id' do
@ -96,7 +109,7 @@ RSpec.describe 'Api::V1Alpha::Collections', feature: :collections do
subject
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
@ -120,8 +133,8 @@ RSpec.describe 'Api::V1Alpha::Collections', feature: :collections do
subject
expect(response).to have_http_status(200)
expect(response.parsed_body[:items].size).to eq 1
expect(response.parsed_body[:items][0]['position']).to eq items.last.position
expect(response.parsed_body[:collection][:items].size).to eq 1
expect(response.parsed_body[:collection][:items][0]['id']).to eq items.last.id.to_s
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)
end
context 'when imagemagick cannot identify the file type' do
context 'when file type cannot be identified' do
before do
allow(media_attachments).to receive(:create!).and_raise(Paperclip::Errors::NotIdentifiedByImageMagickError)
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)
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
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
Fabricate(:collection_item,
id: 2342,
state:,
position: 4)
state:)
end
context 'when state is `accepted`' do
@ -19,9 +18,8 @@ RSpec.describe REST::CollectionItemSerializer do
expect(subject)
.to include(
'id' => '2342',
'account' => an_instance_of(Hash),
'state' => 'accepted',
'position' => 4
'account_id' => collection_item.account_id.to_s,
'state' => 'accepted'
)
end
end
@ -31,7 +29,7 @@ RSpec.describe REST::CollectionItemSerializer do
let(:state) { unaccepted_state }
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

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