Merge pull request #3362 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes up to c36db57acedf0033c090748125c665a0360cd11f
This commit is contained in:
Claire 2026-01-24 12:26:15 +01:00 committed by GitHub
commit 56de578a89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
133 changed files with 3447 additions and 660 deletions

View File

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

View File

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

View File

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

8
.storybook/modes.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

1
Vagrantfile vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 254 B

View File

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

Before

Width:  |  Height:  |  Size: 457 B

After

Width:  |  Height:  |  Size: 358 B

View File

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

Before

Width:  |  Height:  |  Size: 457 B

After

Width:  |  Height:  |  Size: 466 B

View File

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

After

Width:  |  Height:  |  Size: 422 B

View File

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

After

Width:  |  Height:  |  Size: 518 B

View File

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

After

Width:  |  Height:  |  Size: 230 B

View File

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

After

Width:  |  Height:  |  Size: 230 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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