Merge commit 'c36db57acedf0033c090748125c665a0360cd11f' into glitch-soc/merge-upstream
@ -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
|
||||
|
||||
2
.github/workflows/chromatic.yml
vendored
@ -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
|
||||
|
||||
87
.github/workflows/test-ruby.yml
vendored
@ -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
@ -0,0 +1,8 @@
|
||||
export const modes = {
|
||||
darkTheme: {
|
||||
theme: 'dark',
|
||||
},
|
||||
lightTheme: {
|
||||
theme: 'light',
|
||||
},
|
||||
} as const;
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 |
|
||||
| ------------------------------------------------------------- | ---------- | ---------------------------------- |
|
||||
|
||||
14
Gemfile.lock
@ -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
@ -29,7 +29,6 @@ sudo apt-get install \
|
||||
libpq-dev \
|
||||
libxml2-dev \
|
||||
libxslt1-dev \
|
||||
imagemagick \
|
||||
nodejs \
|
||||
redis-server \
|
||||
redis-tools \
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
39
app/javascript/mastodon/api/collections.ts
Normal 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`,
|
||||
);
|
||||
82
app/javascript/mastodon/api_types/collections.ts
Normal 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[];
|
||||
}
|
||||
@ -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',
|
||||
},
|
||||
};
|
||||
27
app/javascript/mastodon/components/callout/dismissible.tsx
Normal 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} />;
|
||||
};
|
||||
150
app/javascript/mastodon/components/callout/index.tsx
Normal 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} />;
|
||||
};
|
||||
125
app/javascript/mastodon/components/callout/styles.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
3
app/javascript/mastodon/components/form_fields/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { TextInputField } from './text_input_field';
|
||||
export { TextAreaField } from './text_area_field';
|
||||
export { SelectField } from './select_field';
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
@ -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';
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
@ -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';
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
@ -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';
|
||||
@ -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))));
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
@ -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';
|
||||
100
app/javascript/mastodon/components/form_fields/wrapper.tsx
Normal 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)' />
|
||||
</>
|
||||
);
|
||||
179
app/javascript/mastodon/features/collections/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
11
app/javascript/mastodon/features/collections/utils.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import {
|
||||
isClientFeatureEnabled,
|
||||
isServerFeatureEnabled,
|
||||
} from '@/mastodon/utils/environment';
|
||||
|
||||
export function areCollectionsEnabled() {
|
||||
return (
|
||||
isClientFeatureEnabled('collections') &&
|
||||
isServerFeatureEnabled('collections')
|
||||
);
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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'>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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ä",
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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 t’ia fillohet",
|
||||
"hashtag.admin_moderation": "Hap ndërfaqe moderimi për #{name}",
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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} 的管理界面",
|
||||
|
||||
@ -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} 的管理介面",
|
||||
|
||||
158
app/javascript/mastodon/reducers/slices/collections.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
@ -1,5 +1,7 @@
|
||||
import { annualReport } from './annual_report';
|
||||
import { collections } from './collections';
|
||||
|
||||
export const sliceReducers = {
|
||||
annualReport,
|
||||
collections,
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 |
@ -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 |
@ -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 |
1
app/javascript/material-icons/400-24px/error-fill.svg
Normal 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 |
1
app/javascript/material-icons/400-24px/error.svg
Normal 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 |
@ -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 |
1
app/javascript/material-icons/400-24px/graphic_eq.svg
Normal 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 |
@ -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)
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
15
app/serializers/rest/collection_with_accounts_serializer.rb
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
%w(
|
||||
/blocks
|
||||
/bookmarks
|
||||
/collections/(*any)
|
||||
/conversations
|
||||
/deck/(*any)
|
||||
/directory
|
||||
|
||||
@ -423,6 +423,7 @@ export default tseslint.config([
|
||||
allowNumber: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/non-nullable-type-assertion-style': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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) }
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) }
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) }
|
||||
|
||||
@ -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 }
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||