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

Merge upstream changes up to 8e7c3973dc
This commit is contained in:
Claire
2026-02-19 23:31:51 +01:00
committed by GitHub
180 changed files with 2413 additions and 1062 deletions

View File

@@ -187,7 +187,7 @@ GEM
irb (~> 1.10) irb (~> 1.10)
reline (>= 0.3.8) reline (>= 0.3.8)
debug_inspector (1.2.0) debug_inspector (1.2.0)
devise (5.0.1) devise (5.0.2)
bcrypt (~> 3.0) bcrypt (~> 3.0)
orm_adapter (~> 0.1) orm_adapter (~> 0.1)
railties (>= 7.0) railties (>= 7.0)

View File

@@ -0,0 +1,22 @@
# frozen_string_literal: true
module Admin
class CollectionsController < BaseController
before_action :set_account
before_action :set_collection, only: :show
def show
authorize @collection, :show?
end
private
def set_account
@account = Account.find(params[:account_id])
end
def set_collection
@collection = @account.collections.includes(accepted_collection_items: :account).find(params[:id])
end
end
end

View File

@@ -50,7 +50,7 @@ module Admin
private private
def filtered_reports def filtered_reports
ReportFilter.new(filter_params).results.order(id: :desc).includes(:account, :target_account) ReportFilter.new(filter_params).results.order(id: :desc).includes(:account, :target_account, :collections)
end end
def filter_params def filter_params
@@ -58,7 +58,7 @@ module Admin
end end
def set_report def set_report
@report = Report.find(params[:id]) @report = Report.includes(collections: :accepted_collection_items).find(params[:id])
end end
end end
end end

View File

@@ -12,6 +12,26 @@ export interface ApiAccountRoleJSON {
name: string; name: string;
} }
type ApiFeaturePolicy =
| 'public'
| 'followers'
| 'following'
| 'disabled'
| 'unsupported_policy';
type ApiUserFeaturePolicy =
| 'automatic'
| 'manual'
| 'denied'
| 'missing'
| 'unknown';
interface ApiFeaturePolicyJSON {
automatic: ApiFeaturePolicy[];
manual: ApiFeaturePolicy[];
current_user: ApiUserFeaturePolicy;
}
// See app/serializers/rest/account_serializer.rb // See app/serializers/rest/account_serializer.rb
export interface BaseApiAccountJSON { export interface BaseApiAccountJSON {
acct: string; acct: string;
@@ -23,6 +43,7 @@ export interface BaseApiAccountJSON {
indexable: boolean; indexable: boolean;
display_name: string; display_name: string;
emojis: ApiCustomEmojiJSON[]; emojis: ApiCustomEmojiJSON[];
feature_approval: ApiFeaturePolicyJSON;
fields: ApiAccountFieldJSON[]; fields: ApiAccountFieldJSON[];
followers_count: number; followers_count: number;
following_count: number; following_count: number;

View File

@@ -6,7 +6,7 @@ import { EmojiHTML } from './emoji/html';
import { useElementHandledLink } from './status/handled_link'; import { useElementHandledLink } from './status/handled_link';
interface AccountBioProps { interface AccountBioProps {
className: string; className?: string;
accountId: string; accountId: string;
showDropdown?: boolean; showDropdown?: boolean;
} }

View File

@@ -42,6 +42,13 @@ export const WithError: Story = {
}, },
}; };
export const AutoSize: Story = {
args: {
autoSize: true,
defaultValue: 'This textarea will grow as you type more lines.',
},
};
export const Plain: Story = { export const Plain: Story = {
render(args) { render(args) {
return <TextArea {...args} />; return <TextArea {...args} />;

View File

@@ -3,12 +3,16 @@ import { forwardRef, useCallback } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import type { TextareaAutosizeProps } from 'react-textarea-autosize';
import TextAreaAutosize from 'react-textarea-autosize';
import { FormFieldWrapper } from './form_field_wrapper'; import { FormFieldWrapper } from './form_field_wrapper';
import type { CommonFieldWrapperProps } from './form_field_wrapper'; import type { CommonFieldWrapperProps } from './form_field_wrapper';
import classes from './text_input.module.scss'; import classes from './text_input.module.scss';
interface Props type TextAreaProps =
extends ComponentPropsWithoutRef<'textarea'>, CommonFieldWrapperProps {} | ({ autoSize?: false } & ComponentPropsWithoutRef<'textarea'>)
| ({ autoSize: true } & TextareaAutosizeProps);
/** /**
* A simple form field for multi-line text. * A simple form field for multi-line text.
@@ -17,45 +21,56 @@ interface Props
* or optional (by explicitly setting `required={false}`) * or optional (by explicitly setting `required={false}`)
*/ */
export const TextAreaField = forwardRef<HTMLTextAreaElement, Props>( export const TextAreaField = forwardRef<
({ id, label, hint, required, hasError, ...otherProps }, ref) => ( HTMLTextAreaElement,
<FormFieldWrapper TextAreaProps & CommonFieldWrapperProps
label={label} >(({ id, label, hint, required, hasError, ...otherProps }, ref) => (
hint={hint} <FormFieldWrapper
required={required} label={label}
hasError={hasError} hint={hint}
inputId={id} required={required}
> hasError={hasError}
{(inputProps) => <TextArea {...otherProps} {...inputProps} ref={ref} />} inputId={id}
</FormFieldWrapper> >
), {(inputProps) => <TextArea {...otherProps} {...inputProps} ref={ref} />}
); </FormFieldWrapper>
));
TextAreaField.displayName = 'TextAreaField'; TextAreaField.displayName = 'TextAreaField';
export const TextArea = forwardRef< export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
HTMLTextAreaElement, ({ className, onKeyDown, autoSize, ...otherProps }, ref) => {
ComponentPropsWithoutRef<'textarea'> const handleSubmitHotkey = useCallback(
>(({ className, onKeyDown, ...otherProps }, ref) => { (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const handleSubmitHotkey = useCallback( onKeyDown?.(e);
(e: React.KeyboardEvent<HTMLTextAreaElement>) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
onKeyDown?.(e); const targetForm = e.currentTarget.form;
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { targetForm?.requestSubmit();
const targetForm = e.currentTarget.form; }
targetForm?.requestSubmit(); },
} [onKeyDown],
}, );
[onKeyDown],
);
return ( if (autoSize) {
<textarea return (
{...otherProps} <TextAreaAutosize
onKeyDown={handleSubmitHotkey} {...(otherProps as TextareaAutosizeProps)}
className={classNames(className, classes.input)} onKeyDown={handleSubmitHotkey}
ref={ref} className={classNames(className, classes.input)}
/> ref={ref}
); />
}); );
}
return (
<textarea
{...otherProps}
onKeyDown={handleSubmitHotkey}
className={classNames(className, classes.input)}
ref={ref}
/>
);
},
);
TextArea.displayName = 'TextArea'; TextArea.displayName = 'TextArea';

View File

@@ -0,0 +1,94 @@
import { useCallback, useId, useRef, useState } from 'react';
import type { ChangeEventHandler, FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { TextArea } from '@/flavours/glitch/components/form_fields';
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
import { insertEmojiAtPosition } from '@/flavours/glitch/features/emoji/utils';
import type { BaseConfirmationModalProps } from '@/flavours/glitch/features/ui/components/confirmation_modals';
import { ConfirmationModal } from '@/flavours/glitch/features/ui/components/confirmation_modals';
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
import { useCurrentAccountId } from '@/flavours/glitch/hooks/useAccountId';
import classes from '../styles.module.scss';
import { CharCounter } from './char_counter';
import { EmojiPicker } from './emoji_picker';
const messages = defineMessages({
addTitle: {
id: 'account_edit.bio_modal.add_title',
defaultMessage: 'Add bio',
},
editTitle: {
id: 'account_edit.bio_modal.edit_title',
defaultMessage: 'Edit bio',
},
save: {
id: 'account_edit.save',
defaultMessage: 'Save',
},
});
const MAX_BIO_LENGTH = 500;
export const BioModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
const intl = useIntl();
const titleId = useId();
const counterId = useId();
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const accountId = useCurrentAccountId();
const account = useAccount(accountId);
const [newBio, setNewBio] = useState(account?.note_plain ?? '');
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
(event) => {
setNewBio(event.currentTarget.value);
},
[],
);
const handlePickEmoji = useCallback((emoji: string) => {
setNewBio((prev) => {
const position = textAreaRef.current?.selectionStart ?? prev.length;
return insertEmojiAtPosition(prev, emoji, position);
});
}, []);
if (!account) {
return <LoadingIndicator />;
}
return (
<ConfirmationModal
title={intl.formatMessage(
account.note_plain ? messages.editTitle : messages.addTitle,
)}
titleId={titleId}
confirm={intl.formatMessage(messages.save)}
onConfirm={onClose} // To be implemented
onClose={onClose}
noFocusButton
>
<div className={classes.inputWrapper}>
<TextArea
value={newBio}
ref={textAreaRef}
onChange={handleChange}
className={classes.inputText}
aria-labelledby={titleId}
aria-describedby={counterId}
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
autoFocus
autoSize
/>
<EmojiPicker onPick={handlePickEmoji} />
</div>
<CharCounter
currentLength={newBio.length}
maxLength={MAX_BIO_LENGTH}
id={counterId}
/>
</ConfirmationModal>
);
};

View File

@@ -0,0 +1,27 @@
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { polymorphicForwardRef } from '@/types/polymorphic';
import classes from '../styles.module.scss';
export const CharCounter = polymorphicForwardRef<
'p',
{ currentLength: number; maxLength: number }
>(({ currentLength, maxLength, as: Component = 'p' }, ref) => (
<Component
ref={ref}
className={classNames(
classes.counter,
currentLength > maxLength && classes.counterError,
)}
>
<FormattedMessage
id='account_edit.char_counter'
defaultMessage='{currentLength}/{maxLength} characters'
values={{ currentLength, maxLength }}
/>
</Component>
));
CharCounter.displayName = 'CharCounter';

View File

@@ -0,0 +1,27 @@
import { useCallback } from 'react';
import type { FC } from 'react';
import { isPlainObject } from '@reduxjs/toolkit';
import EmojiPickerDropdown from '../../compose/containers/emoji_picker_dropdown_container';
export const EmojiPicker: FC<{ onPick: (emoji: string) => void }> = ({
onPick,
}) => {
const handlePick = useCallback(
(emoji: unknown) => {
if (isPlainObject(emoji)) {
if ('native' in emoji && typeof emoji.native === 'string') {
onPick(emoji.native);
} else if (
'shortcode' in emoji &&
typeof emoji.shortcode === 'string'
) {
onPick(`:${emoji.shortcode}:`);
}
}
},
[onPick],
);
return <EmojiPickerDropdown onPickEmoji={handlePick} />;
};

View File

@@ -0,0 +1,87 @@
import { useCallback, useId, useRef, useState } from 'react';
import type { ChangeEventHandler, FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { TextInput } from '@/flavours/glitch/components/form_fields';
import { insertEmojiAtPosition } from '@/flavours/glitch/features/emoji/utils';
import type { BaseConfirmationModalProps } from '@/flavours/glitch/features/ui/components/confirmation_modals';
import { ConfirmationModal } from '@/flavours/glitch/features/ui/components/confirmation_modals';
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
import { useCurrentAccountId } from '@/flavours/glitch/hooks/useAccountId';
import classes from '../styles.module.scss';
import { CharCounter } from './char_counter';
import { EmojiPicker } from './emoji_picker';
const messages = defineMessages({
addTitle: {
id: 'account_edit.name_modal.add_title',
defaultMessage: 'Add display name',
},
editTitle: {
id: 'account_edit.name_modal.edit_title',
defaultMessage: 'Edit display name',
},
save: {
id: 'account_edit.save',
defaultMessage: 'Save',
},
});
const MAX_NAME_LENGTH = 30;
export const NameModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
const intl = useIntl();
const titleId = useId();
const counterId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const accountId = useCurrentAccountId();
const account = useAccount(accountId);
const [newName, setNewName] = useState(account?.display_name ?? '');
const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback(
(event) => {
setNewName(event.currentTarget.value);
},
[],
);
const handlePickEmoji = useCallback((emoji: string) => {
setNewName((prev) => {
const position = inputRef.current?.selectionStart ?? prev.length;
return insertEmojiAtPosition(prev, emoji, position);
});
}, []);
return (
<ConfirmationModal
title={intl.formatMessage(messages.editTitle)}
titleId={titleId}
confirm={intl.formatMessage(messages.save)}
onConfirm={onClose} // To be implemented
onClose={onClose}
noCloseOnConfirm
noFocusButton
>
<div className={classes.inputWrapper}>
<TextInput
value={newName}
ref={inputRef}
onChange={handleChange}
className={classes.inputText}
aria-labelledby={titleId}
aria-describedby={counterId}
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
autoFocus
/>
<EmojiPicker onPick={handlePickEmoji} />
</div>
<CharCounter
currentLength={newName.length}
maxLength={MAX_NAME_LENGTH}
id={counterId}
/>
</ConfirmationModal>
);
};

View File

@@ -0,0 +1,62 @@
import type { FC, ReactNode } from 'react';
import type { MessageDescriptor } from 'react-intl';
import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import { IconButton } from '@/flavours/glitch/components/icon_button';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import classes from '../styles.module.scss';
const buttonMessage = defineMessage({
id: 'account_edit.section_edit_button',
defaultMessage: 'Edit',
});
interface AccountEditSectionProps {
title: MessageDescriptor;
description?: MessageDescriptor;
showDescription?: boolean;
onEdit?: () => void;
children?: ReactNode;
className?: string;
extraButtons?: ReactNode;
}
export const AccountEditSection: FC<AccountEditSectionProps> = ({
title,
description,
showDescription,
onEdit,
children,
className,
extraButtons,
}) => {
const intl = useIntl();
return (
<section className={classNames(className, classes.section)}>
<header className={classes.sectionHeader}>
<h3 className={classes.sectionTitle}>
<FormattedMessage {...title} />
</h3>
{onEdit && (
<IconButton
icon='pencil'
iconComponent={EditIcon}
onClick={onEdit}
title={`${intl.formatMessage(buttonMessage)} ${intl.formatMessage(title)}`}
/>
)}
{extraButtons}
</header>
{showDescription && (
<p className={classes.sectionSubtitle}>
<FormattedMessage {...description} />
</p>
)}
{children}
</section>
);
};

View File

@@ -1,23 +1,92 @@
import { useCallback } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { ModalType } from '@/flavours/glitch/actions/modal';
import { openModal } from '@/flavours/glitch/actions/modal';
import { AccountBio } from '@/flavours/glitch/components/account_bio';
import { Avatar } from '@/flavours/glitch/components/avatar';
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 { DisplayNameSimple } from '@/flavours/glitch/components/display_name/simple';
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator'; import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
import BundleColumnError from '@/flavours/glitch/features/ui/components/bundle_column_error'; import BundleColumnError from '@/flavours/glitch/features/ui/components/bundle_column_error';
import { useAccount } from '@/flavours/glitch/hooks/useAccount'; import { useAccount } from '@/flavours/glitch/hooks/useAccount';
import { useCurrentAccountId } from '@/flavours/glitch/hooks/useAccountId'; import { useCurrentAccountId } from '@/flavours/glitch/hooks/useAccountId';
import { autoPlayGif } from '@/flavours/glitch/initial_state';
import { useAppDispatch } from '@/flavours/glitch/store';
import { AccountEditSection } from './components/section';
import classes from './styles.module.scss'; import classes from './styles.module.scss';
const messages = defineMessages({
displayNameTitle: {
id: 'account_edit.display_name.title',
defaultMessage: 'Display name',
},
displayNamePlaceholder: {
id: 'account_edit.display_name.placeholder',
defaultMessage:
'Your display name is how your name appears on your profile and in timelines.',
},
bioTitle: {
id: 'account_edit.bio.title',
defaultMessage: 'Bio',
},
bioPlaceholder: {
id: 'account_edit.bio.placeholder',
defaultMessage: 'Add a short introduction to help others identify you.',
},
customFieldsTitle: {
id: 'account_edit.custom_fields.title',
defaultMessage: 'Custom fields',
},
customFieldsPlaceholder: {
id: 'account_edit.custom_fields.placeholder',
defaultMessage:
'Add your pronouns, external links, or anything else youd like to share.',
},
featuredHashtagsTitle: {
id: 'account_edit.featured_hashtags.title',
defaultMessage: 'Featured hashtags',
},
featuredHashtagsPlaceholder: {
id: 'account_edit.featured_hashtags.placeholder',
defaultMessage:
'Help others identify, and have quick access to, your favorite topics.',
},
profileTabTitle: {
id: 'account_edit.profile_tab.title',
defaultMessage: 'Profile tab settings',
},
profileTabSubtitle: {
id: 'account_edit.profile_tab.subtitle',
defaultMessage: 'Customize the tabs on your profile and what they display.',
},
});
export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => { export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
const accountId = useCurrentAccountId(); const accountId = useCurrentAccountId();
const account = useAccount(accountId); const account = useAccount(accountId);
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch();
const handleOpenModal = useCallback(
(type: ModalType, props?: Record<string, unknown>) => {
dispatch(openModal({ modalType: type, modalProps: props ?? {} }));
},
[dispatch],
);
const handleNameEdit = useCallback(() => {
handleOpenModal('ACCOUNT_EDIT_NAME');
}, [handleOpenModal]);
const handleBioEdit = useCallback(() => {
handleOpenModal('ACCOUNT_EDIT_BIO');
}, [handleOpenModal]);
if (!accountId) { if (!accountId) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />; return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
} }
@@ -30,6 +99,8 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
); );
} }
const headerSrc = autoPlayGif ? account.header : account.header_static;
return ( return (
<Column bindToDocument={!multiColumn} className={classes.column}> <Column bindToDocument={!multiColumn} className={classes.column}>
<ColumnHeader <ColumnHeader
@@ -37,7 +108,7 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
id: 'account_edit.column_title', id: 'account_edit.column_title',
defaultMessage: 'Edit Profile', defaultMessage: 'Edit Profile',
})} })}
className={classes.header} className={classes.columnHeader}
showBackButton showBackButton
extraButton={ extraButton={
<Link to={`/@${account.acct}`} className='button'> <Link to={`/@${account.acct}`} className='button'>
@@ -48,6 +119,48 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
</Link> </Link>
} }
/> />
<header>
<div className={classes.profileImage}>
{headerSrc && <img src={headerSrc} alt='' />}
</div>
<Avatar account={account} size={80} className={classes.avatar} />
</header>
<AccountEditSection
title={messages.displayNameTitle}
description={messages.displayNamePlaceholder}
showDescription={account.display_name.length === 0}
onEdit={handleNameEdit}
>
<DisplayNameSimple account={account} />
</AccountEditSection>
<AccountEditSection
title={messages.bioTitle}
description={messages.bioPlaceholder}
showDescription={!account.note_plain}
onEdit={handleBioEdit}
>
<AccountBio accountId={accountId} />
</AccountEditSection>
<AccountEditSection
title={messages.customFieldsTitle}
description={messages.customFieldsPlaceholder}
showDescription
/>
<AccountEditSection
title={messages.featuredHashtagsTitle}
description={messages.featuredHashtagsPlaceholder}
showDescription
/>
<AccountEditSection
title={messages.profileTabTitle}
description={messages.profileTabSubtitle}
showDescription
/>
</Column> </Column>
); );
}; };

View File

@@ -3,7 +3,7 @@
border-top-width: 0; border-top-width: 0;
} }
.header { .columnHeader {
:global(.column-header__buttons) { :global(.column-header__buttons) {
align-items: center; align-items: center;
padding-inline-end: 16px; padding-inline-end: 16px;
@@ -11,16 +11,100 @@
} }
} }
.nav { .profileImage {
display: flex; height: 120px;
align-items: center; background: var(--color-bg-secondary);
justify-content: space-between; border-bottom: 1px solid var(--color-border-primary);
gap: 8px; overflow: hidden;
padding: 24px 24px 12px;
> h1 { @container (width >= 500px) {
flex-grow: 1; height: 160px;
font-weight: 600; }
font-size: 15px;
> img {
object-fit: cover;
object-position: top center;
width: 100%;
height: 100%;
} }
} }
.avatar {
margin-top: -64px;
margin-left: 18px;
border: 1px solid var(--color-border-primary);
}
.section {
padding: 20px;
border-bottom: 1px solid var(--color-border-primary);
font-size: 15px;
}
.sectionHeader {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
> button {
border: 1px solid var(--color-border-primary);
border-radius: 8px;
box-sizing: border-box;
padding: 4px;
svg {
width: 20px;
height: 20px;
}
}
}
.sectionTitle {
flex-grow: 1;
font-size: 17px;
font-weight: 600;
}
.sectionSubtitle {
color: var(--color-text-secondary);
}
.inputWrapper {
position: relative;
}
// Override input styles
.inputWrapper .inputText {
font-size: 15px;
padding-right: 32px;
}
textarea.inputText {
min-height: 82px;
height: 100%;
// 160px is approx the height of the modal header and footer
max-height: calc(80vh - 160px);
}
.inputWrapper :global(.emoji-picker-dropdown) {
position: absolute;
bottom: 10px;
right: 8px;
height: 24px;
z-index: 1;
:global(.icon-button) {
color: var(--color-text-secondary);
}
}
.counter {
margin-top: 4px;
font-size: 13px;
}
.counterError {
color: var(--color-text-error);
}

View File

@@ -153,7 +153,7 @@ const InnerNodeModal: FC<{
onConfirm={handleSave} onConfirm={handleSave}
updating={state === 'saving'} updating={state === 'saving'}
disabled={!isDirty} disabled={!isDirty}
closeWhenConfirm={false} noCloseOnConfirm
noFocusButton noFocusButton
/> />
); );

View File

@@ -86,11 +86,9 @@ const InnerTimeline: FC<{ accountId: string; multiColumn: boolean }> = ({
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
useEffect(() => { useEffect(() => {
if (accountId) { if (accountId) {
if (!timeline) { dispatch(expandTimelineByKey({ key }));
dispatch(expandTimelineByKey({ key }));
}
} }
}, [accountId, dispatch, key, timeline]); }, [accountId, dispatch, key]);
const handleLoadMore = useCallback( const handleLoadMore = useCallback(
(maxId: number) => { (maxId: number) => {

View File

@@ -13,8 +13,9 @@ import { CollectionMenu } from './collection_menu';
export const CollectionMetaData: React.FC<{ export const CollectionMetaData: React.FC<{
collection: ApiCollectionJSON; collection: ApiCollectionJSON;
extended?: boolean;
className?: string; className?: string;
}> = ({ collection, className }) => { }> = ({ collection, extended, className }) => {
return ( return (
<ul className={classNames(classes.metaList, className)}> <ul className={classNames(classes.metaList, className)}>
<FormattedMessage <FormattedMessage
@@ -23,6 +24,30 @@ export const CollectionMetaData: React.FC<{
values={{ count: collection.item_count }} values={{ count: collection.item_count }}
tagName='li' tagName='li'
/> />
{extended && (
<>
{collection.discoverable ? (
<FormattedMessage
id='collections.visibility_public'
defaultMessage='Public'
tagName='li'
/>
) : (
<FormattedMessage
id='collections.visibility_unlisted'
defaultMessage='Unlisted'
tagName='li'
/>
)}
{collection.sensitive && (
<FormattedMessage
id='collections.sensitive'
defaultMessage='Sensitive'
tagName='li'
/>
)}
</>
)}
<FormattedMessage <FormattedMessage
id='collections.last_updated_at' id='collections.last_updated_at'
defaultMessage='Last updated: {date}' defaultMessage='Last updated: {date}'

View File

@@ -55,10 +55,6 @@ export const CollectionMenu: React.FC<{
text: intl.formatMessage(editorMessages.editDetails), text: intl.formatMessage(editorMessages.editDetails),
to: `/collections/${id}/edit/details`, to: `/collections/${id}/edit/details`,
}, },
{
text: intl.formatMessage(editorMessages.editSettings),
to: `/collections/${id}/edit/settings`,
},
null, null,
{ {
text: intl.formatMessage(messages.delete), text: intl.formatMessage(messages.delete),

View File

@@ -114,6 +114,7 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
{description && <p className={classes.description}>{description}</p>} {description && <p className={classes.description}>{description}</p>}
<AuthorNote id={collection.account_id} /> <AuthorNote id={collection.account_id} />
<CollectionMetaData <CollectionMetaData
extended
collection={collection} collection={collection}
className={classes.metaData} className={classes.metaData}
/> />

View File

@@ -132,7 +132,11 @@ export const CollectionAccounts: React.FC<{
accountIds: suggestedAccountIds, accountIds: suggestedAccountIds,
isLoading: isLoadingSuggestions, isLoading: isLoadingSuggestions,
searchAccounts, searchAccounts,
} = useSearchAccounts(); } = useSearchAccounts({
filterResults: (account) =>
// Only suggest accounts who allow being featured/recommended
account.feature_approval.current_user === 'automatic',
});
const suggestedItems = suggestedAccountIds.map((id) => ({ const suggestedItems = suggestedAccountIds.map((id) => ({
id, id,

View File

@@ -4,6 +4,8 @@ import { FormattedMessage } from 'react-intl';
import { useHistory, useLocation } from 'react-router-dom'; import { useHistory, useLocation } from 'react-router-dom';
import { isFulfilled } from '@reduxjs/toolkit';
import type { import type {
ApiCollectionJSON, ApiCollectionJSON,
ApiCreateCollectionPayload, ApiCreateCollectionPayload,
@@ -11,11 +13,17 @@ import type {
} from 'flavours/glitch/api_types/collections'; } from 'flavours/glitch/api_types/collections';
import { Button } from 'flavours/glitch/components/button'; import { Button } from 'flavours/glitch/components/button';
import { import {
CheckboxField,
Fieldset,
FormStack, FormStack,
RadioButtonField,
TextAreaField, TextAreaField,
} from 'flavours/glitch/components/form_fields'; } from 'flavours/glitch/components/form_fields';
import { TextInputField } from 'flavours/glitch/components/form_fields/text_input_field'; import { TextInputField } from 'flavours/glitch/components/form_fields/text_input_field';
import { updateCollection } from 'flavours/glitch/reducers/slices/collections'; import {
createCollection,
updateCollection,
} from 'flavours/glitch/reducers/slices/collections';
import { useAppDispatch } from 'flavours/glitch/store'; import { useAppDispatch } from 'flavours/glitch/store';
import type { TempCollectionState } from './state'; import type { TempCollectionState } from './state';
@@ -30,12 +38,21 @@ export const CollectionDetails: React.FC<{
const history = useHistory(); const history = useHistory();
const location = useLocation<TempCollectionState>(); const location = useLocation<TempCollectionState>();
const { id, initialName, initialDescription, initialTopic, initialItemIds } = const {
getCollectionEditorState(collection, location.state); id,
initialName,
initialDescription,
initialTopic,
initialItemIds,
initialDiscoverable,
initialSensitive,
} = getCollectionEditorState(collection, location.state);
const [name, setName] = useState(initialName); const [name, setName] = useState(initialName);
const [description, setDescription] = useState(initialDescription); const [description, setDescription] = useState(initialDescription);
const [topic, setTopic] = useState(initialTopic); const [topic, setTopic] = useState(initialTopic);
const [discoverable, setDiscoverable] = useState(initialDiscoverable);
const [sensitive, setSensitive] = useState(initialSensitive);
const handleNameChange = useCallback( const handleNameChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => { (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -58,6 +75,20 @@ export const CollectionDetails: React.FC<{
[], [],
); );
const handleDiscoverableChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setDiscoverable(event.target.value === 'public');
},
[],
);
const handleSensitiveChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setSensitive(event.target.checked);
},
[],
);
const handleSubmit = useCallback( const handleSubmit = useCallback(
(e: React.FormEvent) => { (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -68,108 +99,208 @@ export const CollectionDetails: React.FC<{
name, name,
description, description,
tag_name: topic || null, tag_name: topic || null,
discoverable,
sensitive,
}; };
void dispatch(updateCollection({ payload })).then(() => { void dispatch(updateCollection({ payload })).then(() => {
history.push(`/collections/${id}`); history.goBack();
}); });
} else { } else {
const payload: Partial<ApiCreateCollectionPayload> = { const payload: ApiCreateCollectionPayload = {
name, name,
description, description,
tag_name: topic || null, discoverable,
sensitive,
account_ids: initialItemIds, account_ids: initialItemIds,
}; };
if (topic) {
payload.tag_name = topic;
}
history.replace('/collections/new', payload); void dispatch(
history.push('/collections/new/settings', payload); createCollection({
payload,
}),
).then((result) => {
if (isFulfilled(result)) {
history.replace(
`/collections/${result.payload.collection.id}/edit/details`,
);
history.push(`/collections/${result.payload.collection.id}`);
}
});
} }
}, },
[id, name, description, topic, dispatch, history, initialItemIds], [
id,
name,
description,
topic,
discoverable,
sensitive,
dispatch,
history,
initialItemIds,
],
); );
return ( return (
<FormStack as='form' onSubmit={handleSubmit}> <form onSubmit={handleSubmit} className={classes.form}>
{!id && ( <FormStack className={classes.formFieldStack}>
<WizardStepHeader {!id && (
step={2} <WizardStepHeader
title={ step={2}
title={
<FormattedMessage
id='collections.create.basic_details_title'
defaultMessage='Basic details'
/>
}
/>
)}
<TextInputField
required
label={
<FormattedMessage <FormattedMessage
id='collections.create.basic_details_title' id='collections.collection_name'
defaultMessage='Basic details' defaultMessage='Name'
/> />
} }
/> hint={
)}
<TextInputField
required
label={
<FormattedMessage
id='collections.collection_name'
defaultMessage='Name'
/>
}
hint={
<FormattedMessage
id='collections.name_length_hint'
defaultMessage='40 characters limit'
/>
}
value={name}
onChange={handleNameChange}
maxLength={40}
/>
<TextAreaField
required
label={
<FormattedMessage
id='collections.collection_description'
defaultMessage='Description'
/>
}
hint={
<FormattedMessage
id='collections.description_length_hint'
defaultMessage='100 characters limit'
/>
}
value={description}
onChange={handleDescriptionChange}
maxLength={100}
/>
<TextInputField
required={false}
label={
<FormattedMessage
id='collections.collection_topic'
defaultMessage='Topic'
/>
}
hint={
<FormattedMessage
id='collections.topic_hint'
defaultMessage='Add a hashtag that helps others understand the main topic of this collection.'
/>
}
value={topic}
onChange={handleTopicChange}
maxLength={40}
/>
<div className={classes.actionWrapper}>
<Button type='submit'>
{id ? (
<FormattedMessage id='lists.save' defaultMessage='Save' />
) : (
<FormattedMessage <FormattedMessage
id='collections.continue' id='collections.name_length_hint'
defaultMessage='Continue' defaultMessage='40 characters limit'
/> />
)} }
</Button> value={name}
onChange={handleNameChange}
maxLength={40}
/>
<TextAreaField
required
label={
<FormattedMessage
id='collections.collection_description'
defaultMessage='Description'
/>
}
hint={
<FormattedMessage
id='collections.description_length_hint'
defaultMessage='100 characters limit'
/>
}
value={description}
onChange={handleDescriptionChange}
maxLength={100}
/>
<TextInputField
required={false}
label={
<FormattedMessage
id='collections.collection_topic'
defaultMessage='Topic'
/>
}
hint={
<FormattedMessage
id='collections.topic_hint'
defaultMessage='Add a hashtag that helps others understand the main topic of this collection.'
/>
}
value={topic}
onChange={handleTopicChange}
maxLength={40}
/>
<Fieldset
legend={
<FormattedMessage
id='collections.visibility_title'
defaultMessage='Visibility'
/>
}
>
<RadioButtonField
label={
<FormattedMessage
id='collections.visibility_public'
defaultMessage='Public'
/>
}
hint={
<FormattedMessage
id='collections.visibility_public_hint'
defaultMessage='Discoverable in search results and other areas where recommendations appear.'
/>
}
value='public'
checked={discoverable}
onChange={handleDiscoverableChange}
/>
<RadioButtonField
label={
<FormattedMessage
id='collections.visibility_unlisted'
defaultMessage='Unlisted'
/>
}
hint={
<FormattedMessage
id='collections.visibility_unlisted_hint'
defaultMessage='Visible to anyone with a link. Hidden from search results and recommendations.'
/>
}
value='unlisted'
checked={!discoverable}
onChange={handleDiscoverableChange}
/>
</Fieldset>
<Fieldset
legend={
<FormattedMessage
id='collections.content_warning'
defaultMessage='Content warning'
/>
}
>
<CheckboxField
label={
<FormattedMessage
id='collections.mark_as_sensitive'
defaultMessage='Mark as sensitive'
/>
}
hint={
<FormattedMessage
id='collections.mark_as_sensitive_hint'
defaultMessage="Hides the collection's description and accounts behind a content warning. The collection name will still be visible."
/>
}
checked={sensitive}
onChange={handleSensitiveChange}
/>
</Fieldset>
</FormStack>
<div className={classes.stickyFooter}>
<div className={classes.actionWrapper}>
<Button type='submit'>
{id ? (
<FormattedMessage id='lists.save' defaultMessage='Save' />
) : (
<FormattedMessage
id='collections.create_collection'
defaultMessage='Create collection'
/>
)}
</Button>
</div>
</div> </div>
</FormStack> </form>
); );
}; };

View File

@@ -21,7 +21,6 @@ import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
import { CollectionAccounts } from './accounts'; import { CollectionAccounts } from './accounts';
import { CollectionDetails } from './details'; import { CollectionDetails } from './details';
import { CollectionSettings } from './settings';
export const messages = defineMessages({ export const messages = defineMessages({
create: { create: {
@@ -34,20 +33,12 @@ export const messages = defineMessages({
}, },
editDetails: { editDetails: {
id: 'collections.edit_details', id: 'collections.edit_details',
defaultMessage: 'Edit basic details', defaultMessage: 'Edit details',
}, },
manageAccounts: { manageAccounts: {
id: 'collections.manage_accounts', id: 'collections.manage_accounts',
defaultMessage: 'Manage accounts', defaultMessage: 'Manage accounts',
}, },
manageAccountsLong: {
id: 'collections.manage_accounts_in_collection',
defaultMessage: 'Manage accounts in this collection',
},
editSettings: {
id: 'collections.edit_settings',
defaultMessage: 'Edit settings',
},
}); });
function usePageTitle(id: string | undefined) { function usePageTitle(id: string | undefined) {
@@ -62,8 +53,6 @@ function usePageTitle(id: string | undefined) {
return messages.manageAccounts; return messages.manageAccounts;
} else if (matchPath(location.pathname, { path: `${path}/details` })) { } else if (matchPath(location.pathname, { path: `${path}/details` })) {
return messages.editDetails; return messages.editDetails;
} else if (matchPath(location.pathname, { path: `${path}/settings` })) {
return messages.editSettings;
} else { } else {
throw new Error('No page title defined for route'); throw new Error('No page title defined for route');
} }
@@ -117,11 +106,6 @@ export const CollectionEditorPage: React.FC<{
// eslint-disable-next-line react/jsx-no-bind // eslint-disable-next-line react/jsx-no-bind
render={() => <CollectionDetails collection={collection} />} render={() => <CollectionDetails collection={collection} />}
/> />
<Route
path={`${path}/settings`}
// eslint-disable-next-line react/jsx-no-bind
render={() => <CollectionSettings collection={collection} />}
/>
</Switch> </Switch>
)} )}
</div> </div>

View File

@@ -1,199 +0,0 @@
import { useCallback, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { useHistory, useLocation } from 'react-router-dom';
import { isFulfilled } from '@reduxjs/toolkit';
import type {
ApiCollectionJSON,
ApiCreateCollectionPayload,
ApiUpdateCollectionPayload,
} from 'flavours/glitch/api_types/collections';
import { Button } from 'flavours/glitch/components/button';
import {
Fieldset,
FormStack,
CheckboxField,
RadioButtonField,
} from 'flavours/glitch/components/form_fields';
import {
createCollection,
updateCollection,
} from 'flavours/glitch/reducers/slices/collections';
import { useAppDispatch } from 'flavours/glitch/store';
import type { TempCollectionState } from './state';
import { getCollectionEditorState } from './state';
import classes from './styles.module.scss';
import { WizardStepHeader } from './wizard_step_header';
export const CollectionSettings: React.FC<{
collection?: ApiCollectionJSON | null;
}> = ({ collection }) => {
const dispatch = useAppDispatch();
const history = useHistory();
const location = useLocation<TempCollectionState>();
const { id, initialDiscoverable, initialSensitive, ...editorState } =
getCollectionEditorState(collection, location.state);
const [discoverable, setDiscoverable] = useState(initialDiscoverable);
const [sensitive, setSensitive] = useState(initialSensitive);
const handleDiscoverableChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setDiscoverable(event.target.value === 'public');
},
[],
);
const handleSensitiveChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setSensitive(event.target.checked);
},
[],
);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (id) {
const payload: ApiUpdateCollectionPayload = {
id,
discoverable,
sensitive,
};
void dispatch(updateCollection({ payload })).then(() => {
history.push(`/collections/${id}`);
});
} else {
const payload: ApiCreateCollectionPayload = {
name: editorState.initialName,
description: editorState.initialDescription,
discoverable,
sensitive,
account_ids: editorState.initialItemIds,
};
if (editorState.initialTopic) {
payload.tag_name = editorState.initialTopic;
}
void dispatch(
createCollection({
payload,
}),
).then((result) => {
if (isFulfilled(result)) {
history.replace(
`/collections/${result.payload.collection.id}/edit/settings`,
);
history.push(`/collections`);
}
});
}
},
[id, discoverable, sensitive, dispatch, history, editorState],
);
return (
<FormStack as='form' onSubmit={handleSubmit}>
{!id && (
<WizardStepHeader
step={3}
title={
<FormattedMessage
id='collections.create.settings_title'
defaultMessage='Settings'
/>
}
/>
)}
<Fieldset
legend={
<FormattedMessage
id='collections.visibility_title'
defaultMessage='Visibility'
/>
}
>
<RadioButtonField
label={
<FormattedMessage
id='collections.visibility_public'
defaultMessage='Public'
/>
}
hint={
<FormattedMessage
id='collections.visibility_public_hint'
defaultMessage='Discoverable in search results and other areas where recommendations appear.'
/>
}
value='public'
checked={discoverable}
onChange={handleDiscoverableChange}
/>
<RadioButtonField
label={
<FormattedMessage
id='collections.visibility_unlisted'
defaultMessage='Unlisted'
/>
}
hint={
<FormattedMessage
id='collections.visibility_unlisted_hint'
defaultMessage='Visible to anyone with a link. Hidden from search results and recommendations.'
/>
}
value='unlisted'
checked={!discoverable}
onChange={handleDiscoverableChange}
/>
</Fieldset>
<Fieldset
legend={
<FormattedMessage
id='collections.content_warning'
defaultMessage='Content warning'
/>
}
>
<CheckboxField
label={
<FormattedMessage
id='collections.mark_as_sensitive'
defaultMessage='Mark as sensitive'
/>
}
hint={
<FormattedMessage
id='collections.mark_as_sensitive_hint'
defaultMessage="Hides the collection's description and accounts behind a content warning. The collection name will still be visible."
/>
}
checked={sensitive}
onChange={handleSensitiveChange}
/>
</Fieldset>
<div className={classes.actionWrapper}>
<Button type='submit'>
{id ? (
<FormattedMessage id='lists.save' defaultMessage='Save' />
) : (
<FormattedMessage
id='collections.create_collection'
defaultMessage='Create collection'
/>
)}
</Button>
</div>
</FormStack>
);
};

View File

@@ -12,7 +12,7 @@ export const WizardStepHeader: React.FC<{
<FormattedMessage <FormattedMessage
id='collections.create.steps' id='collections.create.steps'
defaultMessage='Step {step}/{total}' defaultMessage='Step {step}/{total}'
values={{ step, total: 3 }} values={{ step, total: 2 }}
> >
{(content) => <p className={classes.step}>{content}</p>} {(content) => <p className={classes.step}>{content}</p>}
</FormattedMessage> </FormattedMessage>

View File

@@ -67,6 +67,24 @@ export function emojiToUnicodeHex(emoji: string): string {
return codes.join('-'); return codes.join('-');
} }
const CHARS_ALLOWED_AROUND_EMOJI =
// eslint-disable-next-line no-control-regex
/[>< …\u0009-\u000d\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/;
// TODO: Move to picker file when that's being built out.
export function insertEmojiAtPosition(
text: string,
emoji: string,
position = text.length,
): string {
const isShortcode = isCustomEmoji(emoji);
const needsSpace =
isShortcode &&
position > 0 &&
!CHARS_ALLOWED_AROUND_EMOJI.test(text[position - 1] ?? '');
return `${text.slice(0, position)}${needsSpace ? ' ' : ''}${emoji} ${text.slice(position)}`;
}
function supportsRegExpSets() { function supportsRegExpSets() {
return 'unicodeSets' in RegExp.prototype; return 'unicodeSets' in RegExp.prototype;
} }

View File

@@ -10,8 +10,10 @@ import { useAppDispatch } from 'flavours/glitch/store';
export function useSearchAccounts({ export function useSearchAccounts({
resetOnInputClear = true, resetOnInputClear = true,
onSettled, onSettled,
filterResults,
}: { }: {
onSettled?: (value: string) => void; onSettled?: (value: string) => void;
filterResults?: (account: ApiAccountJSON) => boolean;
resetOnInputClear?: boolean; resetOnInputClear?: boolean;
} = {}) { } = {}) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -49,8 +51,9 @@ export function useSearchAccounts({
}, },
}) })
.then((data) => { .then((data) => {
dispatch(importFetchedAccounts(data)); const accounts = filterResults ? data.filter(filterResults) : data;
setAccountIds(data.map((a) => a.id)); dispatch(importFetchedAccounts(accounts));
setAccountIds(accounts.map((a) => a.id));
setLoadingState('idle'); setLoadingState('idle');
onSettled?.(value); onSettled?.(value);
}) })

View File

@@ -19,20 +19,23 @@ const messages = defineMessages({
export const ConfirmationModal: React.FC< export const ConfirmationModal: React.FC<
{ {
title: React.ReactNode; title: React.ReactNode;
titleId?: string;
message?: React.ReactNode; message?: React.ReactNode;
confirm: React.ReactNode; confirm: React.ReactNode;
cancel?: React.ReactNode; cancel?: React.ReactNode;
secondary?: React.ReactNode; secondary?: React.ReactNode;
onSecondary?: () => void; onSecondary?: () => void;
onConfirm: () => void; onConfirm: () => void;
closeWhenConfirm?: boolean; noCloseOnConfirm?: boolean;
extraContent?: React.ReactNode; extraContent?: React.ReactNode;
children?: React.ReactNode;
updating?: boolean; updating?: boolean;
disabled?: boolean; disabled?: boolean;
noFocusButton?: boolean; noFocusButton?: boolean;
} & BaseConfirmationModalProps } & BaseConfirmationModalProps
> = ({ > = ({
title, title,
titleId,
message, message,
confirm, confirm,
cancel, cancel,
@@ -40,19 +43,20 @@ export const ConfirmationModal: React.FC<
onConfirm, onConfirm,
secondary, secondary,
onSecondary, onSecondary,
closeWhenConfirm = true,
extraContent, extraContent,
children,
updating, updating,
disabled, disabled,
noCloseOnConfirm = false,
noFocusButton = false, noFocusButton = false,
}) => { }) => {
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
if (closeWhenConfirm) { if (!noCloseOnConfirm) {
onClose(); onClose();
} }
onConfirm(); onConfirm();
}, [onClose, onConfirm, closeWhenConfirm]); }, [onClose, onConfirm, noCloseOnConfirm]);
const handleSecondary = useCallback(() => { const handleSecondary = useCallback(() => {
onClose(); onClose();
@@ -63,10 +67,10 @@ export const ConfirmationModal: React.FC<
<div className='modal-root__modal safety-action-modal'> <div className='modal-root__modal safety-action-modal'>
<div className='safety-action-modal__top'> <div className='safety-action-modal__top'>
<div className='safety-action-modal__confirmation'> <div className='safety-action-modal__confirmation'>
<h1>{title}</h1> <h1 id={titleId}>{title}</h1>
{message && <p>{message}</p>} {message && <p>{message}</p>}
{extraContent} {extraContent ?? children}
</div> </div>
</div> </div>

View File

@@ -1,3 +1,4 @@
export type { BaseConfirmationModalProps } from './confirmation_modal';
export { ConfirmationModal } from './confirmation_modal'; export { ConfirmationModal } from './confirmation_modal';
export { ConfirmDeleteStatusModal } from './delete_status'; export { ConfirmDeleteStatusModal } from './delete_status';
export { ConfirmDeleteListModal } from './delete_list'; export { ConfirmDeleteListModal } from './delete_list';

View File

@@ -33,7 +33,7 @@ const messages = defineMessages({
/** /**
* [1] Since we only want this modal to have two buttons "Don't ask again" and * [1] Since we only want this modal to have two buttons "Don't ask again" and
* "Got it" , we have to use the `onClose` handler to handle the "Don't ask again" * "Got it" , we have to use the `onClose` handler to handle the "Don't ask again"
* functionality. Because of this, we need to set `closeWhenConfirm` to false and * functionality. Because of this, we need to set `noCloseOnConfirm` to true and
* close the modal manually. * close the modal manually.
* This prevents the modal from being dismissed permanently when just confirming. * This prevents the modal from being dismissed permanently when just confirming.
*/ */
@@ -65,13 +65,13 @@ export const QuietPostQuoteInfoModal: React.FC<{ status: Status }> = ({
return ( return (
<ConfirmationModal <ConfirmationModal
closeWhenConfirm={false} // [1]
title={intl.formatMessage(messages.title)} title={intl.formatMessage(messages.title)}
message={intl.formatMessage(messages.message)} message={intl.formatMessage(messages.message)}
confirm={intl.formatMessage(messages.got_it)} confirm={intl.formatMessage(messages.got_it)}
cancel={intl.formatMessage(messages.dismiss)} cancel={intl.formatMessage(messages.dismiss)}
onConfirm={confirm} onConfirm={confirm}
onClose={dismiss} onClose={dismiss}
noCloseOnConfirm
/> />
); );
}; };

View File

@@ -96,6 +96,8 @@ export const MODAL_COMPONENTS = {
'ANNUAL_REPORT': AnnualReportModal, 'ANNUAL_REPORT': AnnualReportModal,
'COMPOSE_PRIVACY': () => Promise.resolve({ default: VisibilityModal }), 'COMPOSE_PRIVACY': () => Promise.resolve({ default: VisibilityModal }),
'ACCOUNT_NOTE': () => import('@/flavours/glitch/features/account_timeline/modals/note_modal').then(module => ({ default: module.AccountNoteModal })), 'ACCOUNT_NOTE': () => import('@/flavours/glitch/features/account_timeline/modals/note_modal').then(module => ({ default: module.AccountNoteModal })),
'ACCOUNT_EDIT_NAME': () => import('@/flavours/glitch/features/account_edit/components/name_modal').then(module => ({ default: module.NameModal })),
'ACCOUNT_EDIT_BIO': () => import('@/flavours/glitch/features/account_edit/components/bio_modal').then(module => ({ default: module.BioModal })),
}; };
export default class ModalRoot extends PureComponent { export default class ModalRoot extends PureComponent {

View File

@@ -69,6 +69,11 @@ export const accountDefaultValues: AccountShape = {
display_name: '', display_name: '',
display_name_html: '', display_name_html: '',
emojis: ImmutableList<CustomEmoji>(), emojis: ImmutableList<CustomEmoji>(),
feature_approval: {
automatic: [],
manual: [],
current_user: 'missing',
},
fields: ImmutableList<AccountField>(), fields: ImmutableList<AccountField>(),
group: false, group: false,
header: '', header: '',

View File

@@ -15,6 +15,7 @@ import type {
ApiCreateCollectionPayload, ApiCreateCollectionPayload,
ApiUpdateCollectionPayload, ApiUpdateCollectionPayload,
} from '@/flavours/glitch/api_types/collections'; } from '@/flavours/glitch/api_types/collections';
import { me } from '@/flavours/glitch/initial_state';
import { import {
createAppSelector, createAppSelector,
createDataLoadingThunk, createDataLoadingThunk,
@@ -111,6 +112,14 @@ const collectionSlice = createSlice({
const { collectionId } = action.meta.arg; const { collectionId } = action.meta.arg;
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete state.collections[collectionId]; delete state.collections[collectionId];
if (me) {
let accountCollectionIds = state.accountCollections[me]?.collectionIds;
if (accountCollectionIds) {
accountCollectionIds = accountCollectionIds.filter(
(id) => id !== collectionId,
);
}
}
}); });
/** /**

View File

@@ -12,6 +12,26 @@ export interface ApiAccountRoleJSON {
name: string; name: string;
} }
type ApiFeaturePolicy =
| 'public'
| 'followers'
| 'following'
| 'disabled'
| 'unsupported_policy';
type ApiUserFeaturePolicy =
| 'automatic'
| 'manual'
| 'denied'
| 'missing'
| 'unknown';
interface ApiFeaturePolicyJSON {
automatic: ApiFeaturePolicy[];
manual: ApiFeaturePolicy[];
current_user: ApiUserFeaturePolicy;
}
// See app/serializers/rest/account_serializer.rb // See app/serializers/rest/account_serializer.rb
export interface BaseApiAccountJSON { export interface BaseApiAccountJSON {
acct: string; acct: string;
@@ -23,6 +43,7 @@ export interface BaseApiAccountJSON {
indexable: boolean; indexable: boolean;
display_name: string; display_name: string;
emojis: ApiCustomEmojiJSON[]; emojis: ApiCustomEmojiJSON[];
feature_approval: ApiFeaturePolicyJSON;
fields: ApiAccountFieldJSON[]; fields: ApiAccountFieldJSON[];
followers_count: number; followers_count: number;
following_count: number; following_count: number;

View File

@@ -6,7 +6,7 @@ import { EmojiHTML } from './emoji/html';
import { useElementHandledLink } from './status/handled_link'; import { useElementHandledLink } from './status/handled_link';
interface AccountBioProps { interface AccountBioProps {
className: string; className?: string;
accountId: string; accountId: string;
showDropdown?: boolean; showDropdown?: boolean;
} }

View File

@@ -42,6 +42,13 @@ export const WithError: Story = {
}, },
}; };
export const AutoSize: Story = {
args: {
autoSize: true,
defaultValue: 'This textarea will grow as you type more lines.',
},
};
export const Plain: Story = { export const Plain: Story = {
render(args) { render(args) {
return <TextArea {...args} />; return <TextArea {...args} />;

View File

@@ -3,12 +3,16 @@ import { forwardRef, useCallback } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import type { TextareaAutosizeProps } from 'react-textarea-autosize';
import TextAreaAutosize from 'react-textarea-autosize';
import { FormFieldWrapper } from './form_field_wrapper'; import { FormFieldWrapper } from './form_field_wrapper';
import type { CommonFieldWrapperProps } from './form_field_wrapper'; import type { CommonFieldWrapperProps } from './form_field_wrapper';
import classes from './text_input.module.scss'; import classes from './text_input.module.scss';
interface Props type TextAreaProps =
extends ComponentPropsWithoutRef<'textarea'>, CommonFieldWrapperProps {} | ({ autoSize?: false } & ComponentPropsWithoutRef<'textarea'>)
| ({ autoSize: true } & TextareaAutosizeProps);
/** /**
* A simple form field for multi-line text. * A simple form field for multi-line text.
@@ -17,45 +21,56 @@ interface Props
* or optional (by explicitly setting `required={false}`) * or optional (by explicitly setting `required={false}`)
*/ */
export const TextAreaField = forwardRef<HTMLTextAreaElement, Props>( export const TextAreaField = forwardRef<
({ id, label, hint, required, hasError, ...otherProps }, ref) => ( HTMLTextAreaElement,
<FormFieldWrapper TextAreaProps & CommonFieldWrapperProps
label={label} >(({ id, label, hint, required, hasError, ...otherProps }, ref) => (
hint={hint} <FormFieldWrapper
required={required} label={label}
hasError={hasError} hint={hint}
inputId={id} required={required}
> hasError={hasError}
{(inputProps) => <TextArea {...otherProps} {...inputProps} ref={ref} />} inputId={id}
</FormFieldWrapper> >
), {(inputProps) => <TextArea {...otherProps} {...inputProps} ref={ref} />}
); </FormFieldWrapper>
));
TextAreaField.displayName = 'TextAreaField'; TextAreaField.displayName = 'TextAreaField';
export const TextArea = forwardRef< export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
HTMLTextAreaElement, ({ className, onKeyDown, autoSize, ...otherProps }, ref) => {
ComponentPropsWithoutRef<'textarea'> const handleSubmitHotkey = useCallback(
>(({ className, onKeyDown, ...otherProps }, ref) => { (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const handleSubmitHotkey = useCallback( onKeyDown?.(e);
(e: React.KeyboardEvent<HTMLTextAreaElement>) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
onKeyDown?.(e); const targetForm = e.currentTarget.form;
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { targetForm?.requestSubmit();
const targetForm = e.currentTarget.form; }
targetForm?.requestSubmit(); },
} [onKeyDown],
}, );
[onKeyDown],
);
return ( if (autoSize) {
<textarea return (
{...otherProps} <TextAreaAutosize
onKeyDown={handleSubmitHotkey} {...(otherProps as TextareaAutosizeProps)}
className={classNames(className, classes.input)} onKeyDown={handleSubmitHotkey}
ref={ref} className={classNames(className, classes.input)}
/> ref={ref}
); />
}); );
}
return (
<textarea
{...otherProps}
onKeyDown={handleSubmitHotkey}
className={classNames(className, classes.input)}
ref={ref}
/>
);
},
);
TextArea.displayName = 'TextArea'; TextArea.displayName = 'TextArea';

View File

@@ -0,0 +1,94 @@
import { useCallback, useId, useRef, useState } from 'react';
import type { ChangeEventHandler, FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { TextArea } from '@/mastodon/components/form_fields';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import { insertEmojiAtPosition } from '@/mastodon/features/emoji/utils';
import type { BaseConfirmationModalProps } from '@/mastodon/features/ui/components/confirmation_modals';
import { ConfirmationModal } from '@/mastodon/features/ui/components/confirmation_modals';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
import classes from '../styles.module.scss';
import { CharCounter } from './char_counter';
import { EmojiPicker } from './emoji_picker';
const messages = defineMessages({
addTitle: {
id: 'account_edit.bio_modal.add_title',
defaultMessage: 'Add bio',
},
editTitle: {
id: 'account_edit.bio_modal.edit_title',
defaultMessage: 'Edit bio',
},
save: {
id: 'account_edit.save',
defaultMessage: 'Save',
},
});
const MAX_BIO_LENGTH = 500;
export const BioModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
const intl = useIntl();
const titleId = useId();
const counterId = useId();
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const accountId = useCurrentAccountId();
const account = useAccount(accountId);
const [newBio, setNewBio] = useState(account?.note_plain ?? '');
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
(event) => {
setNewBio(event.currentTarget.value);
},
[],
);
const handlePickEmoji = useCallback((emoji: string) => {
setNewBio((prev) => {
const position = textAreaRef.current?.selectionStart ?? prev.length;
return insertEmojiAtPosition(prev, emoji, position);
});
}, []);
if (!account) {
return <LoadingIndicator />;
}
return (
<ConfirmationModal
title={intl.formatMessage(
account.note_plain ? messages.editTitle : messages.addTitle,
)}
titleId={titleId}
confirm={intl.formatMessage(messages.save)}
onConfirm={onClose} // To be implemented
onClose={onClose}
noFocusButton
>
<div className={classes.inputWrapper}>
<TextArea
value={newBio}
ref={textAreaRef}
onChange={handleChange}
className={classes.inputText}
aria-labelledby={titleId}
aria-describedby={counterId}
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
autoFocus
autoSize
/>
<EmojiPicker onPick={handlePickEmoji} />
</div>
<CharCounter
currentLength={newBio.length}
maxLength={MAX_BIO_LENGTH}
id={counterId}
/>
</ConfirmationModal>
);
};

View File

@@ -0,0 +1,27 @@
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { polymorphicForwardRef } from '@/types/polymorphic';
import classes from '../styles.module.scss';
export const CharCounter = polymorphicForwardRef<
'p',
{ currentLength: number; maxLength: number }
>(({ currentLength, maxLength, as: Component = 'p' }, ref) => (
<Component
ref={ref}
className={classNames(
classes.counter,
currentLength > maxLength && classes.counterError,
)}
>
<FormattedMessage
id='account_edit.char_counter'
defaultMessage='{currentLength}/{maxLength} characters'
values={{ currentLength, maxLength }}
/>
</Component>
));
CharCounter.displayName = 'CharCounter';

View File

@@ -0,0 +1,27 @@
import { useCallback } from 'react';
import type { FC } from 'react';
import { isPlainObject } from '@reduxjs/toolkit';
import EmojiPickerDropdown from '../../compose/containers/emoji_picker_dropdown_container';
export const EmojiPicker: FC<{ onPick: (emoji: string) => void }> = ({
onPick,
}) => {
const handlePick = useCallback(
(emoji: unknown) => {
if (isPlainObject(emoji)) {
if ('native' in emoji && typeof emoji.native === 'string') {
onPick(emoji.native);
} else if (
'shortcode' in emoji &&
typeof emoji.shortcode === 'string'
) {
onPick(`:${emoji.shortcode}:`);
}
}
},
[onPick],
);
return <EmojiPickerDropdown onPickEmoji={handlePick} />;
};

View File

@@ -0,0 +1,87 @@
import { useCallback, useId, useRef, useState } from 'react';
import type { ChangeEventHandler, FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { TextInput } from '@/mastodon/components/form_fields';
import { insertEmojiAtPosition } from '@/mastodon/features/emoji/utils';
import type { BaseConfirmationModalProps } from '@/mastodon/features/ui/components/confirmation_modals';
import { ConfirmationModal } from '@/mastodon/features/ui/components/confirmation_modals';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
import classes from '../styles.module.scss';
import { CharCounter } from './char_counter';
import { EmojiPicker } from './emoji_picker';
const messages = defineMessages({
addTitle: {
id: 'account_edit.name_modal.add_title',
defaultMessage: 'Add display name',
},
editTitle: {
id: 'account_edit.name_modal.edit_title',
defaultMessage: 'Edit display name',
},
save: {
id: 'account_edit.save',
defaultMessage: 'Save',
},
});
const MAX_NAME_LENGTH = 30;
export const NameModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
const intl = useIntl();
const titleId = useId();
const counterId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const accountId = useCurrentAccountId();
const account = useAccount(accountId);
const [newName, setNewName] = useState(account?.display_name ?? '');
const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback(
(event) => {
setNewName(event.currentTarget.value);
},
[],
);
const handlePickEmoji = useCallback((emoji: string) => {
setNewName((prev) => {
const position = inputRef.current?.selectionStart ?? prev.length;
return insertEmojiAtPosition(prev, emoji, position);
});
}, []);
return (
<ConfirmationModal
title={intl.formatMessage(messages.editTitle)}
titleId={titleId}
confirm={intl.formatMessage(messages.save)}
onConfirm={onClose} // To be implemented
onClose={onClose}
noCloseOnConfirm
noFocusButton
>
<div className={classes.inputWrapper}>
<TextInput
value={newName}
ref={inputRef}
onChange={handleChange}
className={classes.inputText}
aria-labelledby={titleId}
aria-describedby={counterId}
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
autoFocus
/>
<EmojiPicker onPick={handlePickEmoji} />
</div>
<CharCounter
currentLength={newName.length}
maxLength={MAX_NAME_LENGTH}
id={counterId}
/>
</ConfirmationModal>
);
};

View File

@@ -0,0 +1,62 @@
import type { FC, ReactNode } from 'react';
import type { MessageDescriptor } from 'react-intl';
import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import { IconButton } from '@/mastodon/components/icon_button';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import classes from '../styles.module.scss';
const buttonMessage = defineMessage({
id: 'account_edit.section_edit_button',
defaultMessage: 'Edit',
});
interface AccountEditSectionProps {
title: MessageDescriptor;
description?: MessageDescriptor;
showDescription?: boolean;
onEdit?: () => void;
children?: ReactNode;
className?: string;
extraButtons?: ReactNode;
}
export const AccountEditSection: FC<AccountEditSectionProps> = ({
title,
description,
showDescription,
onEdit,
children,
className,
extraButtons,
}) => {
const intl = useIntl();
return (
<section className={classNames(className, classes.section)}>
<header className={classes.sectionHeader}>
<h3 className={classes.sectionTitle}>
<FormattedMessage {...title} />
</h3>
{onEdit && (
<IconButton
icon='pencil'
iconComponent={EditIcon}
onClick={onEdit}
title={`${intl.formatMessage(buttonMessage)} ${intl.formatMessage(title)}`}
/>
)}
{extraButtons}
</header>
{showDescription && (
<p className={classes.sectionSubtitle}>
<FormattedMessage {...description} />
</p>
)}
{children}
</section>
);
};

View File

@@ -1,23 +1,92 @@
import { useCallback } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { ModalType } from '@/mastodon/actions/modal';
import { openModal } from '@/mastodon/actions/modal';
import { AccountBio } from '@/mastodon/components/account_bio';
import { Avatar } from '@/mastodon/components/avatar';
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 { DisplayNameSimple } from '@/mastodon/components/display_name/simple';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator'; import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error'; import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error';
import { useAccount } from '@/mastodon/hooks/useAccount'; import { useAccount } from '@/mastodon/hooks/useAccount';
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId'; import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
import { autoPlayGif } from '@/mastodon/initial_state';
import { useAppDispatch } from '@/mastodon/store';
import { AccountEditSection } from './components/section';
import classes from './styles.module.scss'; import classes from './styles.module.scss';
const messages = defineMessages({
displayNameTitle: {
id: 'account_edit.display_name.title',
defaultMessage: 'Display name',
},
displayNamePlaceholder: {
id: 'account_edit.display_name.placeholder',
defaultMessage:
'Your display name is how your name appears on your profile and in timelines.',
},
bioTitle: {
id: 'account_edit.bio.title',
defaultMessage: 'Bio',
},
bioPlaceholder: {
id: 'account_edit.bio.placeholder',
defaultMessage: 'Add a short introduction to help others identify you.',
},
customFieldsTitle: {
id: 'account_edit.custom_fields.title',
defaultMessage: 'Custom fields',
},
customFieldsPlaceholder: {
id: 'account_edit.custom_fields.placeholder',
defaultMessage:
'Add your pronouns, external links, or anything else youd like to share.',
},
featuredHashtagsTitle: {
id: 'account_edit.featured_hashtags.title',
defaultMessage: 'Featured hashtags',
},
featuredHashtagsPlaceholder: {
id: 'account_edit.featured_hashtags.placeholder',
defaultMessage:
'Help others identify, and have quick access to, your favorite topics.',
},
profileTabTitle: {
id: 'account_edit.profile_tab.title',
defaultMessage: 'Profile tab settings',
},
profileTabSubtitle: {
id: 'account_edit.profile_tab.subtitle',
defaultMessage: 'Customize the tabs on your profile and what they display.',
},
});
export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => { export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
const accountId = useCurrentAccountId(); const accountId = useCurrentAccountId();
const account = useAccount(accountId); const account = useAccount(accountId);
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch();
const handleOpenModal = useCallback(
(type: ModalType, props?: Record<string, unknown>) => {
dispatch(openModal({ modalType: type, modalProps: props ?? {} }));
},
[dispatch],
);
const handleNameEdit = useCallback(() => {
handleOpenModal('ACCOUNT_EDIT_NAME');
}, [handleOpenModal]);
const handleBioEdit = useCallback(() => {
handleOpenModal('ACCOUNT_EDIT_BIO');
}, [handleOpenModal]);
if (!accountId) { if (!accountId) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />; return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
} }
@@ -30,6 +99,8 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
); );
} }
const headerSrc = autoPlayGif ? account.header : account.header_static;
return ( return (
<Column bindToDocument={!multiColumn} className={classes.column}> <Column bindToDocument={!multiColumn} className={classes.column}>
<ColumnHeader <ColumnHeader
@@ -37,7 +108,7 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
id: 'account_edit.column_title', id: 'account_edit.column_title',
defaultMessage: 'Edit Profile', defaultMessage: 'Edit Profile',
})} })}
className={classes.header} className={classes.columnHeader}
showBackButton showBackButton
extraButton={ extraButton={
<Link to={`/@${account.acct}`} className='button'> <Link to={`/@${account.acct}`} className='button'>
@@ -48,6 +119,48 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
</Link> </Link>
} }
/> />
<header>
<div className={classes.profileImage}>
{headerSrc && <img src={headerSrc} alt='' />}
</div>
<Avatar account={account} size={80} className={classes.avatar} />
</header>
<AccountEditSection
title={messages.displayNameTitle}
description={messages.displayNamePlaceholder}
showDescription={account.display_name.length === 0}
onEdit={handleNameEdit}
>
<DisplayNameSimple account={account} />
</AccountEditSection>
<AccountEditSection
title={messages.bioTitle}
description={messages.bioPlaceholder}
showDescription={!account.note_plain}
onEdit={handleBioEdit}
>
<AccountBio accountId={accountId} />
</AccountEditSection>
<AccountEditSection
title={messages.customFieldsTitle}
description={messages.customFieldsPlaceholder}
showDescription
/>
<AccountEditSection
title={messages.featuredHashtagsTitle}
description={messages.featuredHashtagsPlaceholder}
showDescription
/>
<AccountEditSection
title={messages.profileTabTitle}
description={messages.profileTabSubtitle}
showDescription
/>
</Column> </Column>
); );
}; };

View File

@@ -3,7 +3,7 @@
border-top-width: 0; border-top-width: 0;
} }
.header { .columnHeader {
:global(.column-header__buttons) { :global(.column-header__buttons) {
align-items: center; align-items: center;
padding-inline-end: 16px; padding-inline-end: 16px;
@@ -11,16 +11,100 @@
} }
} }
.nav { .profileImage {
display: flex; height: 120px;
align-items: center; background: var(--color-bg-secondary);
justify-content: space-between; border-bottom: 1px solid var(--color-border-primary);
gap: 8px; overflow: hidden;
padding: 24px 24px 12px;
> h1 { @container (width >= 500px) {
flex-grow: 1; height: 160px;
font-weight: 600; }
font-size: 15px;
> img {
object-fit: cover;
object-position: top center;
width: 100%;
height: 100%;
} }
} }
.avatar {
margin-top: -64px;
margin-left: 18px;
border: 1px solid var(--color-border-primary);
}
.section {
padding: 20px;
border-bottom: 1px solid var(--color-border-primary);
font-size: 15px;
}
.sectionHeader {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
> button {
border: 1px solid var(--color-border-primary);
border-radius: 8px;
box-sizing: border-box;
padding: 4px;
svg {
width: 20px;
height: 20px;
}
}
}
.sectionTitle {
flex-grow: 1;
font-size: 17px;
font-weight: 600;
}
.sectionSubtitle {
color: var(--color-text-secondary);
}
.inputWrapper {
position: relative;
}
// Override input styles
.inputWrapper .inputText {
font-size: 15px;
padding-right: 32px;
}
textarea.inputText {
min-height: 82px;
height: 100%;
// 160px is approx the height of the modal header and footer
max-height: calc(80vh - 160px);
}
.inputWrapper :global(.emoji-picker-dropdown) {
position: absolute;
bottom: 10px;
right: 8px;
height: 24px;
z-index: 1;
:global(.icon-button) {
color: var(--color-text-secondary);
}
}
.counter {
margin-top: 4px;
font-size: 13px;
}
.counterError {
color: var(--color-text-error);
}

View File

@@ -153,7 +153,7 @@ const InnerNodeModal: FC<{
onConfirm={handleSave} onConfirm={handleSave}
updating={state === 'saving'} updating={state === 'saving'}
disabled={!isDirty} disabled={!isDirty}
closeWhenConfirm={false} noCloseOnConfirm
noFocusButton noFocusButton
/> />
); );

View File

@@ -86,11 +86,9 @@ const InnerTimeline: FC<{ accountId: string; multiColumn: boolean }> = ({
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
useEffect(() => { useEffect(() => {
if (accountId) { if (accountId) {
if (!timeline) { dispatch(expandTimelineByKey({ key }));
dispatch(expandTimelineByKey({ key }));
}
} }
}, [accountId, dispatch, key, timeline]); }, [accountId, dispatch, key]);
const handleLoadMore = useCallback( const handleLoadMore = useCallback(
(maxId: number) => { (maxId: number) => {

View File

@@ -13,8 +13,9 @@ import { CollectionMenu } from './collection_menu';
export const CollectionMetaData: React.FC<{ export const CollectionMetaData: React.FC<{
collection: ApiCollectionJSON; collection: ApiCollectionJSON;
extended?: boolean;
className?: string; className?: string;
}> = ({ collection, className }) => { }> = ({ collection, extended, className }) => {
return ( return (
<ul className={classNames(classes.metaList, className)}> <ul className={classNames(classes.metaList, className)}>
<FormattedMessage <FormattedMessage
@@ -23,6 +24,30 @@ export const CollectionMetaData: React.FC<{
values={{ count: collection.item_count }} values={{ count: collection.item_count }}
tagName='li' tagName='li'
/> />
{extended && (
<>
{collection.discoverable ? (
<FormattedMessage
id='collections.visibility_public'
defaultMessage='Public'
tagName='li'
/>
) : (
<FormattedMessage
id='collections.visibility_unlisted'
defaultMessage='Unlisted'
tagName='li'
/>
)}
{collection.sensitive && (
<FormattedMessage
id='collections.sensitive'
defaultMessage='Sensitive'
tagName='li'
/>
)}
</>
)}
<FormattedMessage <FormattedMessage
id='collections.last_updated_at' id='collections.last_updated_at'
defaultMessage='Last updated: {date}' defaultMessage='Last updated: {date}'

View File

@@ -55,10 +55,6 @@ export const CollectionMenu: React.FC<{
text: intl.formatMessage(editorMessages.editDetails), text: intl.formatMessage(editorMessages.editDetails),
to: `/collections/${id}/edit/details`, to: `/collections/${id}/edit/details`,
}, },
{
text: intl.formatMessage(editorMessages.editSettings),
to: `/collections/${id}/edit/settings`,
},
null, null,
{ {
text: intl.formatMessage(messages.delete), text: intl.formatMessage(messages.delete),

View File

@@ -114,6 +114,7 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
{description && <p className={classes.description}>{description}</p>} {description && <p className={classes.description}>{description}</p>}
<AuthorNote id={collection.account_id} /> <AuthorNote id={collection.account_id} />
<CollectionMetaData <CollectionMetaData
extended
collection={collection} collection={collection}
className={classes.metaData} className={classes.metaData}
/> />

View File

@@ -129,7 +129,11 @@ export const CollectionAccounts: React.FC<{
accountIds: suggestedAccountIds, accountIds: suggestedAccountIds,
isLoading: isLoadingSuggestions, isLoading: isLoadingSuggestions,
searchAccounts, searchAccounts,
} = useSearchAccounts(); } = useSearchAccounts({
filterResults: (account) =>
// Only suggest accounts who allow being featured/recommended
account.feature_approval.current_user === 'automatic',
});
const suggestedItems = suggestedAccountIds.map((id) => ({ const suggestedItems = suggestedAccountIds.map((id) => ({
id, id,

View File

@@ -4,15 +4,26 @@ import { FormattedMessage } from 'react-intl';
import { useHistory, useLocation } from 'react-router-dom'; import { useHistory, useLocation } from 'react-router-dom';
import { isFulfilled } from '@reduxjs/toolkit';
import type { import type {
ApiCollectionJSON, ApiCollectionJSON,
ApiCreateCollectionPayload, ApiCreateCollectionPayload,
ApiUpdateCollectionPayload, ApiUpdateCollectionPayload,
} from 'mastodon/api_types/collections'; } from 'mastodon/api_types/collections';
import { Button } from 'mastodon/components/button'; import { Button } from 'mastodon/components/button';
import { FormStack, TextAreaField } from 'mastodon/components/form_fields'; import {
CheckboxField,
Fieldset,
FormStack,
RadioButtonField,
TextAreaField,
} from 'mastodon/components/form_fields';
import { TextInputField } from 'mastodon/components/form_fields/text_input_field'; import { TextInputField } from 'mastodon/components/form_fields/text_input_field';
import { updateCollection } from 'mastodon/reducers/slices/collections'; import {
createCollection,
updateCollection,
} from 'mastodon/reducers/slices/collections';
import { useAppDispatch } from 'mastodon/store'; import { useAppDispatch } from 'mastodon/store';
import type { TempCollectionState } from './state'; import type { TempCollectionState } from './state';
@@ -27,12 +38,21 @@ export const CollectionDetails: React.FC<{
const history = useHistory(); const history = useHistory();
const location = useLocation<TempCollectionState>(); const location = useLocation<TempCollectionState>();
const { id, initialName, initialDescription, initialTopic, initialItemIds } = const {
getCollectionEditorState(collection, location.state); id,
initialName,
initialDescription,
initialTopic,
initialItemIds,
initialDiscoverable,
initialSensitive,
} = getCollectionEditorState(collection, location.state);
const [name, setName] = useState(initialName); const [name, setName] = useState(initialName);
const [description, setDescription] = useState(initialDescription); const [description, setDescription] = useState(initialDescription);
const [topic, setTopic] = useState(initialTopic); const [topic, setTopic] = useState(initialTopic);
const [discoverable, setDiscoverable] = useState(initialDiscoverable);
const [sensitive, setSensitive] = useState(initialSensitive);
const handleNameChange = useCallback( const handleNameChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => { (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -55,6 +75,20 @@ export const CollectionDetails: React.FC<{
[], [],
); );
const handleDiscoverableChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setDiscoverable(event.target.value === 'public');
},
[],
);
const handleSensitiveChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setSensitive(event.target.checked);
},
[],
);
const handleSubmit = useCallback( const handleSubmit = useCallback(
(e: React.FormEvent) => { (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -65,108 +99,208 @@ export const CollectionDetails: React.FC<{
name, name,
description, description,
tag_name: topic || null, tag_name: topic || null,
discoverable,
sensitive,
}; };
void dispatch(updateCollection({ payload })).then(() => { void dispatch(updateCollection({ payload })).then(() => {
history.push(`/collections/${id}`); history.goBack();
}); });
} else { } else {
const payload: Partial<ApiCreateCollectionPayload> = { const payload: ApiCreateCollectionPayload = {
name, name,
description, description,
tag_name: topic || null, discoverable,
sensitive,
account_ids: initialItemIds, account_ids: initialItemIds,
}; };
if (topic) {
payload.tag_name = topic;
}
history.replace('/collections/new', payload); void dispatch(
history.push('/collections/new/settings', payload); createCollection({
payload,
}),
).then((result) => {
if (isFulfilled(result)) {
history.replace(
`/collections/${result.payload.collection.id}/edit/details`,
);
history.push(`/collections/${result.payload.collection.id}`);
}
});
} }
}, },
[id, name, description, topic, dispatch, history, initialItemIds], [
id,
name,
description,
topic,
discoverable,
sensitive,
dispatch,
history,
initialItemIds,
],
); );
return ( return (
<FormStack as='form' onSubmit={handleSubmit}> <form onSubmit={handleSubmit} className={classes.form}>
{!id && ( <FormStack className={classes.formFieldStack}>
<WizardStepHeader {!id && (
step={2} <WizardStepHeader
title={ step={2}
title={
<FormattedMessage
id='collections.create.basic_details_title'
defaultMessage='Basic details'
/>
}
/>
)}
<TextInputField
required
label={
<FormattedMessage <FormattedMessage
id='collections.create.basic_details_title' id='collections.collection_name'
defaultMessage='Basic details' defaultMessage='Name'
/> />
} }
/> hint={
)}
<TextInputField
required
label={
<FormattedMessage
id='collections.collection_name'
defaultMessage='Name'
/>
}
hint={
<FormattedMessage
id='collections.name_length_hint'
defaultMessage='40 characters limit'
/>
}
value={name}
onChange={handleNameChange}
maxLength={40}
/>
<TextAreaField
required
label={
<FormattedMessage
id='collections.collection_description'
defaultMessage='Description'
/>
}
hint={
<FormattedMessage
id='collections.description_length_hint'
defaultMessage='100 characters limit'
/>
}
value={description}
onChange={handleDescriptionChange}
maxLength={100}
/>
<TextInputField
required={false}
label={
<FormattedMessage
id='collections.collection_topic'
defaultMessage='Topic'
/>
}
hint={
<FormattedMessage
id='collections.topic_hint'
defaultMessage='Add a hashtag that helps others understand the main topic of this collection.'
/>
}
value={topic}
onChange={handleTopicChange}
maxLength={40}
/>
<div className={classes.actionWrapper}>
<Button type='submit'>
{id ? (
<FormattedMessage id='lists.save' defaultMessage='Save' />
) : (
<FormattedMessage <FormattedMessage
id='collections.continue' id='collections.name_length_hint'
defaultMessage='Continue' defaultMessage='40 characters limit'
/> />
)} }
</Button> value={name}
onChange={handleNameChange}
maxLength={40}
/>
<TextAreaField
required
label={
<FormattedMessage
id='collections.collection_description'
defaultMessage='Description'
/>
}
hint={
<FormattedMessage
id='collections.description_length_hint'
defaultMessage='100 characters limit'
/>
}
value={description}
onChange={handleDescriptionChange}
maxLength={100}
/>
<TextInputField
required={false}
label={
<FormattedMessage
id='collections.collection_topic'
defaultMessage='Topic'
/>
}
hint={
<FormattedMessage
id='collections.topic_hint'
defaultMessage='Add a hashtag that helps others understand the main topic of this collection.'
/>
}
value={topic}
onChange={handleTopicChange}
maxLength={40}
/>
<Fieldset
legend={
<FormattedMessage
id='collections.visibility_title'
defaultMessage='Visibility'
/>
}
>
<RadioButtonField
label={
<FormattedMessage
id='collections.visibility_public'
defaultMessage='Public'
/>
}
hint={
<FormattedMessage
id='collections.visibility_public_hint'
defaultMessage='Discoverable in search results and other areas where recommendations appear.'
/>
}
value='public'
checked={discoverable}
onChange={handleDiscoverableChange}
/>
<RadioButtonField
label={
<FormattedMessage
id='collections.visibility_unlisted'
defaultMessage='Unlisted'
/>
}
hint={
<FormattedMessage
id='collections.visibility_unlisted_hint'
defaultMessage='Visible to anyone with a link. Hidden from search results and recommendations.'
/>
}
value='unlisted'
checked={!discoverable}
onChange={handleDiscoverableChange}
/>
</Fieldset>
<Fieldset
legend={
<FormattedMessage
id='collections.content_warning'
defaultMessage='Content warning'
/>
}
>
<CheckboxField
label={
<FormattedMessage
id='collections.mark_as_sensitive'
defaultMessage='Mark as sensitive'
/>
}
hint={
<FormattedMessage
id='collections.mark_as_sensitive_hint'
defaultMessage="Hides the collection's description and accounts behind a content warning. The collection name will still be visible."
/>
}
checked={sensitive}
onChange={handleSensitiveChange}
/>
</Fieldset>
</FormStack>
<div className={classes.stickyFooter}>
<div className={classes.actionWrapper}>
<Button type='submit'>
{id ? (
<FormattedMessage id='lists.save' defaultMessage='Save' />
) : (
<FormattedMessage
id='collections.create_collection'
defaultMessage='Create collection'
/>
)}
</Button>
</div>
</div> </div>
</FormStack> </form>
); );
}; };

View File

@@ -21,7 +21,6 @@ import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { CollectionAccounts } from './accounts'; import { CollectionAccounts } from './accounts';
import { CollectionDetails } from './details'; import { CollectionDetails } from './details';
import { CollectionSettings } from './settings';
export const messages = defineMessages({ export const messages = defineMessages({
create: { create: {
@@ -34,20 +33,12 @@ export const messages = defineMessages({
}, },
editDetails: { editDetails: {
id: 'collections.edit_details', id: 'collections.edit_details',
defaultMessage: 'Edit basic details', defaultMessage: 'Edit details',
}, },
manageAccounts: { manageAccounts: {
id: 'collections.manage_accounts', id: 'collections.manage_accounts',
defaultMessage: 'Manage accounts', defaultMessage: 'Manage accounts',
}, },
manageAccountsLong: {
id: 'collections.manage_accounts_in_collection',
defaultMessage: 'Manage accounts in this collection',
},
editSettings: {
id: 'collections.edit_settings',
defaultMessage: 'Edit settings',
},
}); });
function usePageTitle(id: string | undefined) { function usePageTitle(id: string | undefined) {
@@ -62,8 +53,6 @@ function usePageTitle(id: string | undefined) {
return messages.manageAccounts; return messages.manageAccounts;
} else if (matchPath(location.pathname, { path: `${path}/details` })) { } else if (matchPath(location.pathname, { path: `${path}/details` })) {
return messages.editDetails; return messages.editDetails;
} else if (matchPath(location.pathname, { path: `${path}/settings` })) {
return messages.editSettings;
} else { } else {
throw new Error('No page title defined for route'); throw new Error('No page title defined for route');
} }
@@ -117,11 +106,6 @@ export const CollectionEditorPage: React.FC<{
// eslint-disable-next-line react/jsx-no-bind // eslint-disable-next-line react/jsx-no-bind
render={() => <CollectionDetails collection={collection} />} render={() => <CollectionDetails collection={collection} />}
/> />
<Route
path={`${path}/settings`}
// eslint-disable-next-line react/jsx-no-bind
render={() => <CollectionSettings collection={collection} />}
/>
</Switch> </Switch>
)} )}
</div> </div>

View File

@@ -1,199 +0,0 @@
import { useCallback, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { useHistory, useLocation } from 'react-router-dom';
import { isFulfilled } from '@reduxjs/toolkit';
import type {
ApiCollectionJSON,
ApiCreateCollectionPayload,
ApiUpdateCollectionPayload,
} from 'mastodon/api_types/collections';
import { Button } from 'mastodon/components/button';
import {
Fieldset,
FormStack,
CheckboxField,
RadioButtonField,
} from 'mastodon/components/form_fields';
import {
createCollection,
updateCollection,
} from 'mastodon/reducers/slices/collections';
import { useAppDispatch } from 'mastodon/store';
import type { TempCollectionState } from './state';
import { getCollectionEditorState } from './state';
import classes from './styles.module.scss';
import { WizardStepHeader } from './wizard_step_header';
export const CollectionSettings: React.FC<{
collection?: ApiCollectionJSON | null;
}> = ({ collection }) => {
const dispatch = useAppDispatch();
const history = useHistory();
const location = useLocation<TempCollectionState>();
const { id, initialDiscoverable, initialSensitive, ...editorState } =
getCollectionEditorState(collection, location.state);
const [discoverable, setDiscoverable] = useState(initialDiscoverable);
const [sensitive, setSensitive] = useState(initialSensitive);
const handleDiscoverableChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setDiscoverable(event.target.value === 'public');
},
[],
);
const handleSensitiveChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setSensitive(event.target.checked);
},
[],
);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (id) {
const payload: ApiUpdateCollectionPayload = {
id,
discoverable,
sensitive,
};
void dispatch(updateCollection({ payload })).then(() => {
history.push(`/collections/${id}`);
});
} else {
const payload: ApiCreateCollectionPayload = {
name: editorState.initialName,
description: editorState.initialDescription,
discoverable,
sensitive,
account_ids: editorState.initialItemIds,
};
if (editorState.initialTopic) {
payload.tag_name = editorState.initialTopic;
}
void dispatch(
createCollection({
payload,
}),
).then((result) => {
if (isFulfilled(result)) {
history.replace(
`/collections/${result.payload.collection.id}/edit/settings`,
);
history.push(`/collections`);
}
});
}
},
[id, discoverable, sensitive, dispatch, history, editorState],
);
return (
<FormStack as='form' onSubmit={handleSubmit}>
{!id && (
<WizardStepHeader
step={3}
title={
<FormattedMessage
id='collections.create.settings_title'
defaultMessage='Settings'
/>
}
/>
)}
<Fieldset
legend={
<FormattedMessage
id='collections.visibility_title'
defaultMessage='Visibility'
/>
}
>
<RadioButtonField
label={
<FormattedMessage
id='collections.visibility_public'
defaultMessage='Public'
/>
}
hint={
<FormattedMessage
id='collections.visibility_public_hint'
defaultMessage='Discoverable in search results and other areas where recommendations appear.'
/>
}
value='public'
checked={discoverable}
onChange={handleDiscoverableChange}
/>
<RadioButtonField
label={
<FormattedMessage
id='collections.visibility_unlisted'
defaultMessage='Unlisted'
/>
}
hint={
<FormattedMessage
id='collections.visibility_unlisted_hint'
defaultMessage='Visible to anyone with a link. Hidden from search results and recommendations.'
/>
}
value='unlisted'
checked={!discoverable}
onChange={handleDiscoverableChange}
/>
</Fieldset>
<Fieldset
legend={
<FormattedMessage
id='collections.content_warning'
defaultMessage='Content warning'
/>
}
>
<CheckboxField
label={
<FormattedMessage
id='collections.mark_as_sensitive'
defaultMessage='Mark as sensitive'
/>
}
hint={
<FormattedMessage
id='collections.mark_as_sensitive_hint'
defaultMessage="Hides the collection's description and accounts behind a content warning. The collection name will still be visible."
/>
}
checked={sensitive}
onChange={handleSensitiveChange}
/>
</Fieldset>
<div className={classes.actionWrapper}>
<Button type='submit'>
{id ? (
<FormattedMessage id='lists.save' defaultMessage='Save' />
) : (
<FormattedMessage
id='collections.create_collection'
defaultMessage='Create collection'
/>
)}
</Button>
</div>
</FormStack>
);
};

View File

@@ -12,7 +12,7 @@ export const WizardStepHeader: React.FC<{
<FormattedMessage <FormattedMessage
id='collections.create.steps' id='collections.create.steps'
defaultMessage='Step {step}/{total}' defaultMessage='Step {step}/{total}'
values={{ step, total: 3 }} values={{ step, total: 2 }}
> >
{(content) => <p className={classes.step}>{content}</p>} {(content) => <p className={classes.step}>{content}</p>}
</FormattedMessage> </FormattedMessage>

View File

@@ -67,6 +67,24 @@ export function emojiToUnicodeHex(emoji: string): string {
return codes.join('-'); return codes.join('-');
} }
const CHARS_ALLOWED_AROUND_EMOJI =
// eslint-disable-next-line no-control-regex
/[>< …\u0009-\u000d\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/;
// TODO: Move to picker file when that's being built out.
export function insertEmojiAtPosition(
text: string,
emoji: string,
position = text.length,
): string {
const isShortcode = isCustomEmoji(emoji);
const needsSpace =
isShortcode &&
position > 0 &&
!CHARS_ALLOWED_AROUND_EMOJI.test(text[position - 1] ?? '');
return `${text.slice(0, position)}${needsSpace ? ' ' : ''}${emoji} ${text.slice(position)}`;
}
function supportsRegExpSets() { function supportsRegExpSets() {
return 'unicodeSets' in RegExp.prototype; return 'unicodeSets' in RegExp.prototype;
} }

View File

@@ -10,8 +10,10 @@ import { useAppDispatch } from 'mastodon/store';
export function useSearchAccounts({ export function useSearchAccounts({
resetOnInputClear = true, resetOnInputClear = true,
onSettled, onSettled,
filterResults,
}: { }: {
onSettled?: (value: string) => void; onSettled?: (value: string) => void;
filterResults?: (account: ApiAccountJSON) => boolean;
resetOnInputClear?: boolean; resetOnInputClear?: boolean;
} = {}) { } = {}) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -49,8 +51,9 @@ export function useSearchAccounts({
}, },
}) })
.then((data) => { .then((data) => {
dispatch(importFetchedAccounts(data)); const accounts = filterResults ? data.filter(filterResults) : data;
setAccountIds(data.map((a) => a.id)); dispatch(importFetchedAccounts(accounts));
setAccountIds(accounts.map((a) => a.id));
setLoadingState('idle'); setLoadingState('idle');
onSettled?.(value); onSettled?.(value);
}) })

View File

@@ -11,20 +11,23 @@ export interface BaseConfirmationModalProps {
export const ConfirmationModal: React.FC< export const ConfirmationModal: React.FC<
{ {
title: React.ReactNode; title: React.ReactNode;
titleId?: string;
message?: React.ReactNode; message?: React.ReactNode;
confirm: React.ReactNode; confirm: React.ReactNode;
cancel?: React.ReactNode; cancel?: React.ReactNode;
secondary?: React.ReactNode; secondary?: React.ReactNode;
onSecondary?: () => void; onSecondary?: () => void;
onConfirm: () => void; onConfirm: () => void;
closeWhenConfirm?: boolean; noCloseOnConfirm?: boolean;
extraContent?: React.ReactNode; extraContent?: React.ReactNode;
children?: React.ReactNode;
updating?: boolean; updating?: boolean;
disabled?: boolean; disabled?: boolean;
noFocusButton?: boolean; noFocusButton?: boolean;
} & BaseConfirmationModalProps } & BaseConfirmationModalProps
> = ({ > = ({
title, title,
titleId,
message, message,
confirm, confirm,
cancel, cancel,
@@ -32,19 +35,20 @@ export const ConfirmationModal: React.FC<
onConfirm, onConfirm,
secondary, secondary,
onSecondary, onSecondary,
closeWhenConfirm = true,
extraContent, extraContent,
children,
updating, updating,
disabled, disabled,
noCloseOnConfirm = false,
noFocusButton = false, noFocusButton = false,
}) => { }) => {
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
if (closeWhenConfirm) { if (!noCloseOnConfirm) {
onClose(); onClose();
} }
onConfirm(); onConfirm();
}, [onClose, onConfirm, closeWhenConfirm]); }, [onClose, onConfirm, noCloseOnConfirm]);
const handleSecondary = useCallback(() => { const handleSecondary = useCallback(() => {
onClose(); onClose();
@@ -55,10 +59,10 @@ export const ConfirmationModal: React.FC<
<div className='modal-root__modal safety-action-modal'> <div className='modal-root__modal safety-action-modal'>
<div className='safety-action-modal__top'> <div className='safety-action-modal__top'>
<div className='safety-action-modal__confirmation'> <div className='safety-action-modal__confirmation'>
<h1>{title}</h1> <h1 id={titleId}>{title}</h1>
{message && <p>{message}</p>} {message && <p>{message}</p>}
{extraContent} {extraContent ?? children}
</div> </div>
</div> </div>

View File

@@ -1,3 +1,4 @@
export type { BaseConfirmationModalProps } from './confirmation_modal';
export { ConfirmationModal } from './confirmation_modal'; export { ConfirmationModal } from './confirmation_modal';
export { ConfirmDeleteStatusModal } from './delete_status'; export { ConfirmDeleteStatusModal } from './delete_status';
export { ConfirmDeleteListModal } from './delete_list'; export { ConfirmDeleteListModal } from './delete_list';

View File

@@ -33,7 +33,7 @@ const messages = defineMessages({
/** /**
* [1] Since we only want this modal to have two buttons "Don't ask again" and * [1] Since we only want this modal to have two buttons "Don't ask again" and
* "Got it" , we have to use the `onClose` handler to handle the "Don't ask again" * "Got it" , we have to use the `onClose` handler to handle the "Don't ask again"
* functionality. Because of this, we need to set `closeWhenConfirm` to false and * functionality. Because of this, we need to set `noCloseOnConfirm` to true and
* close the modal manually. * close the modal manually.
* This prevents the modal from being dismissed permanently when just confirming. * This prevents the modal from being dismissed permanently when just confirming.
*/ */
@@ -65,13 +65,13 @@ export const QuietPostQuoteInfoModal: React.FC<{ status: Status }> = ({
return ( return (
<ConfirmationModal <ConfirmationModal
closeWhenConfirm={false} // [1]
title={intl.formatMessage(messages.title)} title={intl.formatMessage(messages.title)}
message={intl.formatMessage(messages.message)} message={intl.formatMessage(messages.message)}
confirm={intl.formatMessage(messages.got_it)} confirm={intl.formatMessage(messages.got_it)}
cancel={intl.formatMessage(messages.dismiss)} cancel={intl.formatMessage(messages.dismiss)}
onConfirm={confirm} onConfirm={confirm}
onClose={dismiss} onClose={dismiss}
noCloseOnConfirm
/> />
); );
}; };

View File

@@ -88,6 +88,8 @@ export const MODAL_COMPONENTS = {
'ANNUAL_REPORT': AnnualReportModal, 'ANNUAL_REPORT': AnnualReportModal,
'COMPOSE_PRIVACY': () => Promise.resolve({ default: VisibilityModal }), 'COMPOSE_PRIVACY': () => Promise.resolve({ default: VisibilityModal }),
'ACCOUNT_NOTE': () => import('@/mastodon/features/account_timeline/modals/note_modal').then(module => ({ default: module.AccountNoteModal })), 'ACCOUNT_NOTE': () => import('@/mastodon/features/account_timeline/modals/note_modal').then(module => ({ default: module.AccountNoteModal })),
'ACCOUNT_EDIT_NAME': () => import('@/mastodon/features/account_edit/components/name_modal').then(module => ({ default: module.NameModal })),
'ACCOUNT_EDIT_BIO': () => import('@/mastodon/features/account_edit/components/bio_modal').then(module => ({ default: module.BioModal })),
}; };
export default class ModalRoot extends PureComponent { export default class ModalRoot extends PureComponent {

View File

@@ -263,6 +263,11 @@
"collections.create_collection": "Стварыць калекцыю", "collections.create_collection": "Стварыць калекцыю",
"collections.delete_collection": "Выдаліць калекцыю", "collections.delete_collection": "Выдаліць калекцыю",
"collections.description_length_hint": "Максімум 100 сімвалаў", "collections.description_length_hint": "Максімум 100 сімвалаў",
"collections.detail.accounts_heading": "Уліковыя запісы",
"collections.detail.curated_by_author": "Курыруе {author}",
"collections.detail.curated_by_you": "Курыруеце Вы",
"collections.detail.loading": "Загружаецца калекцыя…",
"collections.detail.share": "Падзяліцца гэтай калекцыяй",
"collections.edit_details": "Змяніць асноўныя звесткі", "collections.edit_details": "Змяніць асноўныя звесткі",
"collections.edit_settings": "Змяніць налады", "collections.edit_settings": "Змяніць налады",
"collections.error_loading_collections": "Адбылася памылка падчас загрузкі Вашых калекцый.", "collections.error_loading_collections": "Адбылася памылка падчас загрузкі Вашых калекцый.",

View File

@@ -13,11 +13,18 @@
"about.not_available": "Tato informace nebyla zpřístupněna na tomto serveru.", "about.not_available": "Tato informace nebyla zpřístupněna na tomto serveru.",
"about.powered_by": "Decentralizovaná sociální média poháněná {mastodon}", "about.powered_by": "Decentralizovaná sociální média poháněná {mastodon}",
"about.rules": "Pravidla serveru", "about.rules": "Pravidla serveru",
"account.about": "O účtu",
"account.account_note_header": "Osobní poznámka", "account.account_note_header": "Osobní poznámka",
"account.activity": "Aktivita", "account.activity": "Aktivita",
"account.add_note": "Přidat vlastní poznámku",
"account.add_or_remove_from_list": "Přidat nebo odstranit ze seznamů", "account.add_or_remove_from_list": "Přidat nebo odstranit ze seznamů",
"account.badges.admin": "Admin",
"account.badges.blocked": "Zablokovaný",
"account.badges.bot": "Bot", "account.badges.bot": "Bot",
"account.badges.domain_blocked": "Zablokovaná doména",
"account.badges.group": "Skupina", "account.badges.group": "Skupina",
"account.badges.muted": "Ztišeno",
"account.badges.muted_until": "Ztišen do {until}",
"account.block": "Blokovat @{name}", "account.block": "Blokovat @{name}",
"account.block_domain": "Blokovat doménu {domain}", "account.block_domain": "Blokovat doménu {domain}",
"account.block_short": "Zablokovat", "account.block_short": "Zablokovat",
@@ -28,6 +35,7 @@
"account.direct": "Soukromě zmínit @{name}", "account.direct": "Soukromě zmínit @{name}",
"account.disable_notifications": "Přestat mě upozorňovat, když @{name} zveřejní příspěvek", "account.disable_notifications": "Přestat mě upozorňovat, když @{name} zveřejní příspěvek",
"account.domain_blocking": "Blokované domény", "account.domain_blocking": "Blokované domény",
"account.edit_note": "Upravit vlastní poznámku",
"account.edit_profile": "Upravit profil", "account.edit_profile": "Upravit profil",
"account.edit_profile_short": "Upravit", "account.edit_profile_short": "Upravit",
"account.enable_notifications": "Oznamovat mi příspěvky @{name}", "account.enable_notifications": "Oznamovat mi příspěvky @{name}",
@@ -40,6 +48,12 @@
"account.featured.hashtags": "Hashtagy", "account.featured.hashtags": "Hashtagy",
"account.featured_tags.last_status_at": "Poslední příspěvek {date}", "account.featured_tags.last_status_at": "Poslední příspěvek {date}",
"account.featured_tags.last_status_never": "Žádné příspěvky", "account.featured_tags.last_status_never": "Žádné příspěvky",
"account.filters.all": "Veškerá aktivita",
"account.filters.boosts_toggle": "Zobrazit boosty",
"account.filters.posts_boosts": "Příspěvky a boosty",
"account.filters.posts_only": "Příspěvky",
"account.filters.posts_replies": "Příspěvky a odpovědi",
"account.filters.replies_toggle": "Zobrazit odpovědi",
"account.follow": "Sledovat", "account.follow": "Sledovat",
"account.follow_back": "Také sledovat", "account.follow_back": "Také sledovat",
"account.follow_back_short": "Také sledovat", "account.follow_back_short": "Také sledovat",
@@ -65,6 +79,24 @@
"account.locked_info": "Stav soukromí tohoto účtu je nastaven na zamčeno. Jeho vlastník ručně posuzuje, kdo ho může sledovat.", "account.locked_info": "Stav soukromí tohoto účtu je nastaven na zamčeno. Jeho vlastník ručně posuzuje, kdo ho může sledovat.",
"account.media": "Média", "account.media": "Média",
"account.mention": "Zmínit @{name}", "account.mention": "Zmínit @{name}",
"account.menu.add_to_list": "Přidat do seznamu…",
"account.menu.block": "Blokovat účet",
"account.menu.block_domain": "Blokovat {domain}",
"account.menu.copied": "Odkaz účtu byl zkopírován do schránky",
"account.menu.copy": "Zkopírovat odkaz",
"account.menu.direct": "Soukromě zmínit",
"account.menu.hide_reblogs": "Skrýt boosty na časové ose",
"account.menu.mention": "Zmínit",
"account.menu.mute": "Ztlumit účet",
"account.menu.note.description": "Viditelné pouze pro vás",
"account.menu.open_original_page": "Zobrazit na {domain}",
"account.menu.remove_follower": "Odstranit sledujícího",
"account.menu.report": "Nahlásit účet",
"account.menu.share": "Sdílet…",
"account.menu.show_reblogs": "Zobrazit boosty na časové ose",
"account.menu.unblock": "Odblokovat účet",
"account.menu.unblock_domain": "Odblokovat {domain}",
"account.menu.unmute": "Zrušit ztlumení účtu",
"account.moved_to": "Uživatel {name} uvedl, že jeho nový účet je nyní:", "account.moved_to": "Uživatel {name} uvedl, že jeho nový účet je nyní:",
"account.mute": "Skrýt @{name}", "account.mute": "Skrýt @{name}",
"account.mute_notifications_short": "Ztlumit upozornění", "account.mute_notifications_short": "Ztlumit upozornění",
@@ -72,7 +104,14 @@
"account.muted": "Skrytý", "account.muted": "Skrytý",
"account.muting": "Ztlumení", "account.muting": "Ztlumení",
"account.mutual": "Sledujete se navzájem", "account.mutual": "Sledujete se navzájem",
"account.name.help.domain": "{domain} je server, který hostuje profily a příspěvky uživatelů.",
"account.name.help.domain_self": "{domain} je váš server, který hostuje váš profil a příspěvky.",
"account.no_bio": "Nebyl poskytnut žádný popis.", "account.no_bio": "Nebyl poskytnut žádný popis.",
"account.node_modal.field_label": "Vlastní poznámka",
"account.node_modal.save": "Uložit",
"account.node_modal.title": "Přidat vlastní poznámku",
"account.note.edit_button": "Upravit",
"account.note.title": "Vlastní poznámka (viditelná pouze pro vás)",
"account.open_original_page": "Otevřít původní stránku", "account.open_original_page": "Otevřít původní stránku",
"account.posts": "Příspěvky", "account.posts": "Příspěvky",
"account.posts_with_replies": "Příspěvky a odpovědi", "account.posts_with_replies": "Příspěvky a odpovědi",
@@ -83,6 +122,8 @@
"account.share": "Sdílet profil @{name}", "account.share": "Sdílet profil @{name}",
"account.show_reblogs": "Zobrazit boosty od @{name}", "account.show_reblogs": "Zobrazit boosty od @{name}",
"account.statuses_counter": "{count, plural, one {{counter} příspěvek} few {{counter} příspěvky} many {{counter} příspěvků} other {{counter} příspěvků}}", "account.statuses_counter": "{count, plural, one {{counter} příspěvek} few {{counter} příspěvky} many {{counter} příspěvků} other {{counter} příspěvků}}",
"account.timeline.pinned": "Připnuto",
"account.timeline.pinned.view_all": "Zobrazit všechny připnuté příspěvky",
"account.unblock": "Odblokovat @{name}", "account.unblock": "Odblokovat @{name}",
"account.unblock_domain": "Odblokovat doménu {domain}", "account.unblock_domain": "Odblokovat doménu {domain}",
"account.unblock_domain_short": "Odblokovat", "account.unblock_domain_short": "Odblokovat",
@@ -92,6 +133,8 @@
"account.unmute": "Zrušit skrytí @{name}", "account.unmute": "Zrušit skrytí @{name}",
"account.unmute_notifications_short": "Zrušit ztlumení oznámení", "account.unmute_notifications_short": "Zrušit ztlumení oznámení",
"account.unmute_short": "Zrušit skrytí", "account.unmute_short": "Zrušit skrytí",
"account_edit.column_button": "Hotovo",
"account_edit.column_title": "Upravit profil",
"account_note.placeholder": "Klikněte pro přidání poznámky", "account_note.placeholder": "Klikněte pro přidání poznámky",
"admin.dashboard.daily_retention": "Míra udržení uživatelů podle dne po registraci", "admin.dashboard.daily_retention": "Míra udržení uživatelů podle dne po registraci",
"admin.dashboard.monthly_retention": "Míra udržení uživatelů podle měsíce po registraci", "admin.dashboard.monthly_retention": "Míra udržení uživatelů podle měsíce po registraci",
@@ -186,6 +229,7 @@
"bundle_modal_error.close": "Zavřít", "bundle_modal_error.close": "Zavřít",
"bundle_modal_error.message": "Něco se pokazilo při načítání této obrazovky.", "bundle_modal_error.message": "Něco se pokazilo při načítání této obrazovky.",
"bundle_modal_error.retry": "Zkusit znovu", "bundle_modal_error.retry": "Zkusit znovu",
"callout.dismiss": "Zamítnout",
"carousel.current": "<sr>Snímek</sr> {current, number}/{max, number}", "carousel.current": "<sr>Snímek</sr> {current, number}/{max, number}",
"carousel.slide": "Snímek {current, number} z {max, number}", "carousel.slide": "Snímek {current, number} z {max, number}",
"closed_registrations.other_server_instructions": "Protože Mastodon je decentralizovaný, můžete si vytvořit účet na jiném serveru a přesto komunikovat s tímto serverem.", "closed_registrations.other_server_instructions": "Protože Mastodon je decentralizovaný, můžete si vytvořit účet na jiném serveru a přesto komunikovat s tímto serverem.",
@@ -193,12 +237,24 @@
"closed_registrations_modal.find_another_server": "Najít jiný server", "closed_registrations_modal.find_another_server": "Najít jiný server",
"closed_registrations_modal.preamble": "Mastodon je decentralizovaný, takže bez ohledu na to, kde vytvoříte svůj účet, budete moci sledovat a komunikovat s kýmkoli na tomto serveru. Můžete ho dokonce hostovat!", "closed_registrations_modal.preamble": "Mastodon je decentralizovaný, takže bez ohledu na to, kde vytvoříte svůj účet, budete moci sledovat a komunikovat s kýmkoli na tomto serveru. Můžete ho dokonce hostovat!",
"closed_registrations_modal.title": "Registrace na Mastodon", "closed_registrations_modal.title": "Registrace na Mastodon",
"collections.continue": "Pokračovat",
"collections.mark_as_sensitive_hint": "Skryje popis kolekce a účty za varováním obsahu. Název kolekce bude stále viditelný.", "collections.mark_as_sensitive_hint": "Skryje popis kolekce a účty za varováním obsahu. Název kolekce bude stále viditelný.",
"collections.name_length_hint": "Max. 100 znaků", "collections.name_length_hint": "Max. 100 znaků",
"collections.new_collection": "Nová sbírka",
"collections.no_collections_yet": "Ještě nemáte žádné sbírky.",
"collections.remove_account": "Odstranit tento účet",
"collections.search_accounts_label": "Hledat účty pro přidání…",
"collections.search_accounts_max_reached": "Přidali jste maximální počet účtů",
"collections.topic_hint": "Přidat štítek, který pomůže ostatním pochopit hlavní téma této kolekce.", "collections.topic_hint": "Přidat štítek, který pomůže ostatním pochopit hlavní téma této kolekce.",
"collections.view_collection": "Zobrazit sbírku",
"collections.visibility_public": "Veřejné",
"collections.visibility_public_hint": "Objevitelné ve výsledcích vyhledávání a dalších místech, kde se objevují doporučení.",
"collections.visibility_title": "Viditelnost",
"collections.visibility_unlisted": "Neveřejný",
"column.about": "O aplikaci", "column.about": "O aplikaci",
"column.blocks": "Blokovaní uživatelé", "column.blocks": "Blokovaní uživatelé",
"column.bookmarks": "Záložky", "column.bookmarks": "Záložky",
"column.collections": "Mé sbírky",
"column.community": "Místní časová osa", "column.community": "Místní časová osa",
"column.create_list": "Vytvořit seznam", "column.create_list": "Vytvořit seznam",
"column.direct": "Soukromé zmínky", "column.direct": "Soukromé zmínky",
@@ -225,6 +281,10 @@
"column_header.show_settings": "Zobrazit nastavení", "column_header.show_settings": "Zobrazit nastavení",
"column_header.unpin": "Odepnout", "column_header.unpin": "Odepnout",
"column_search.cancel": "Zrušit", "column_search.cancel": "Zrušit",
"combobox.close_results": "Zavřít výsledky",
"combobox.loading": "Načítání",
"combobox.no_results_found": "Žádné výsledky pro toto vyhledávání",
"combobox.open_results": "Otevřít výsledky",
"community.column_settings.local_only": "Pouze místní", "community.column_settings.local_only": "Pouze místní",
"community.column_settings.media_only": "Pouze média", "community.column_settings.media_only": "Pouze média",
"community.column_settings.remote_only": "Pouze vzdálené", "community.column_settings.remote_only": "Pouze vzdálené",
@@ -258,6 +318,7 @@
"confirmations.delete.confirm": "Smazat", "confirmations.delete.confirm": "Smazat",
"confirmations.delete.message": "Opravdu chcete smazat tento příspěvek?", "confirmations.delete.message": "Opravdu chcete smazat tento příspěvek?",
"confirmations.delete.title": "Smazat příspěvek?", "confirmations.delete.title": "Smazat příspěvek?",
"confirmations.delete_collection.title": "Smazat „{name}“?",
"confirmations.delete_list.confirm": "Smazat", "confirmations.delete_list.confirm": "Smazat",
"confirmations.delete_list.message": "Opravdu chcete tento seznam navždy smazat?", "confirmations.delete_list.message": "Opravdu chcete tento seznam navždy smazat?",
"confirmations.delete_list.title": "Smazat seznam?", "confirmations.delete_list.title": "Smazat seznam?",
@@ -364,6 +425,8 @@
"emoji_button.search_results": "Výsledky hledání", "emoji_button.search_results": "Výsledky hledání",
"emoji_button.symbols": "Symboly", "emoji_button.symbols": "Symboly",
"emoji_button.travel": "Cestování a místa", "emoji_button.travel": "Cestování a místa",
"empty_column.account_about.me": "Zatím jste o sobě nepřidali žádné informace.",
"empty_column.account_about.other": "{acct} zatím o sobě nepřidali žádné informace.",
"empty_column.account_featured.me": "Zatím jste nic nezvýraznili. Věděli jste, že na svém profilu můžete zvýraznit hashtagy, které používáte nejvíce, a dokonce účty vašich přátel?", "empty_column.account_featured.me": "Zatím jste nic nezvýraznili. Věděli jste, že na svém profilu můžete zvýraznit hashtagy, které používáte nejvíce, a dokonce účty vašich přátel?",
"empty_column.account_featured.other": "{acct} zatím nic nezvýraznili. Věděli jste, že na svém profilu můžete zvýraznit hashtagy, které používáte nejvíce, a dokonce účty vašich přátel?", "empty_column.account_featured.other": "{acct} zatím nic nezvýraznili. Věděli jste, že na svém profilu můžete zvýraznit hashtagy, které používáte nejvíce, a dokonce účty vašich přátel?",
"empty_column.account_featured_other.unknown": "Tento účet zatím nemá nic zvýrazněného.", "empty_column.account_featured_other.unknown": "Tento účet zatím nemá nic zvýrazněného.",
@@ -389,6 +452,7 @@
"empty_column.notification_requests": "Vyčištěno! Nic tu není. Jakmile obdržíš nové notifikace, objeví se zde podle tvého nastavení.", "empty_column.notification_requests": "Vyčištěno! Nic tu není. Jakmile obdržíš nové notifikace, objeví se zde podle tvého nastavení.",
"empty_column.notifications": "Zatím nemáte žádná oznámení. Až s vámi někdo bude interagovat, uvidíte to zde.", "empty_column.notifications": "Zatím nemáte žádná oznámení. Až s vámi někdo bude interagovat, uvidíte to zde.",
"empty_column.public": "Tady nic není! Napište něco veřejně, nebo začněte ručně sledovat uživatele z jiných serverů, aby tu něco přibylo", "empty_column.public": "Tady nic není! Napište něco veřejně, nebo začněte ručně sledovat uživatele z jiných serverů, aby tu něco přibylo",
"empty_state.no_results": "Žádné výsledky",
"error.no_hashtag_feed_access": "Zaregistrujte se nebo se přihlaste k zobrazení a sledování tohoto hashtagu.", "error.no_hashtag_feed_access": "Zaregistrujte se nebo se přihlaste k zobrazení a sledování tohoto hashtagu.",
"error.unexpected_crash.explanation": "Kvůli chybě v našem kódu nebo problému s kompatibilitou prohlížeče nemohla být tato stránka správně zobrazena.", "error.unexpected_crash.explanation": "Kvůli chybě v našem kódu nebo problému s kompatibilitou prohlížeče nemohla být tato stránka správně zobrazena.",
"error.unexpected_crash.explanation_addons": "Tuto stránku nelze správně zobrazit. Takovou chybu obvykle způsobuje doplněk prohlížeče nebo nástroje pro automatický překlad.", "error.unexpected_crash.explanation_addons": "Tuto stránku nelze správně zobrazit. Takovou chybu obvykle způsobuje doplněk prohlížeče nebo nástroje pro automatický překlad.",
@@ -404,6 +468,7 @@
"featured_carousel.current": "<sr>Příspěvek</sr> {current, number}/{max, number}", "featured_carousel.current": "<sr>Příspěvek</sr> {current, number}/{max, number}",
"featured_carousel.header": "{count, plural, one {{counter} zvýrazněný příspěvek} few {{counter} zvýrazněné příspěvky} many {{counter} zvýrazněných příspěvků} other {{counter} zvýrazněných příspěvků}}", "featured_carousel.header": "{count, plural, one {{counter} zvýrazněný příspěvek} few {{counter} zvýrazněné příspěvky} many {{counter} zvýrazněných příspěvků} other {{counter} zvýrazněných příspěvků}}",
"featured_carousel.slide": "Příspěvek {current, number} z {max, number}", "featured_carousel.slide": "Příspěvek {current, number} z {max, number}",
"featured_tags.more_items": "+{count}",
"filter_modal.added.context_mismatch_explanation": "Tato kategorie filtrů se nevztahuje na kontext, ve kterém jste tento příspěvek otevřeli. Pokud chcete, aby byl příspěvek filtrován i v tomto kontextu, budete muset filtr upravit.", "filter_modal.added.context_mismatch_explanation": "Tato kategorie filtrů se nevztahuje na kontext, ve kterém jste tento příspěvek otevřeli. Pokud chcete, aby byl příspěvek filtrován i v tomto kontextu, budete muset filtr upravit.",
"filter_modal.added.context_mismatch_title": "Kontext se neshoduje!", "filter_modal.added.context_mismatch_title": "Kontext se neshoduje!",
"filter_modal.added.expired_explanation": "Tato kategorie filtrů vypršela, budete muset změnit datum vypršení platnosti, aby mohla být použita.", "filter_modal.added.expired_explanation": "Tato kategorie filtrů vypršela, budete muset změnit datum vypršení platnosti, aby mohla být použita.",
@@ -445,6 +510,7 @@
"follow_suggestions.view_all": "Zobrazit vše", "follow_suggestions.view_all": "Zobrazit vše",
"follow_suggestions.who_to_follow": "Koho sledovat", "follow_suggestions.who_to_follow": "Koho sledovat",
"followed_tags": "Sledované hashtagy", "followed_tags": "Sledované hashtagy",
"followers.hide_other_followers": "Tento uživatel se rozhodl nezveřejnit své další sledující",
"footer.about": "O aplikaci", "footer.about": "O aplikaci",
"footer.about_mastodon": "O Mastodonu", "footer.about_mastodon": "O Mastodonu",
"footer.about_server": "O {domain}", "footer.about_server": "O {domain}",
@@ -456,6 +522,7 @@
"footer.source_code": "Zobrazit zdrojový kód", "footer.source_code": "Zobrazit zdrojový kód",
"footer.status": "Stav", "footer.status": "Stav",
"footer.terms_of_service": "Obchodní podmínky", "footer.terms_of_service": "Obchodní podmínky",
"form_field.optional": "(volitelné)",
"generic.saved": "Uloženo", "generic.saved": "Uloženo",
"getting_started.heading": "Začínáme", "getting_started.heading": "Začínáme",
"hashtag.admin_moderation": "Otevřít moderátorské rozhraní pro #{name}", "hashtag.admin_moderation": "Otevřít moderátorské rozhraní pro #{name}",
@@ -791,6 +858,7 @@
"privacy.private.short": "Sledující", "privacy.private.short": "Sledující",
"privacy.public.long": "Kdokoliv na Mastodonu i mimo něj", "privacy.public.long": "Kdokoliv na Mastodonu i mimo něj",
"privacy.public.short": "Veřejné", "privacy.public.short": "Veřejné",
"privacy.quote.anyone": "{visibility}, citování povoleno",
"privacy.quote.disabled": "{visibility}, citování je zakázáno", "privacy.quote.disabled": "{visibility}, citování je zakázáno",
"privacy.quote.limited": "{visibility}, citování je omezené", "privacy.quote.limited": "{visibility}, citování je omezené",
"privacy.unlisted.additional": "Chová se stejně jako veřejný, až na to, že se příspěvek neobjeví v živých kanálech nebo hashtazích, v objevování nebo vyhledávání na Mastodonu, a to i když je účet nastaven tak, aby se zde všude tyto příspěvky zobrazovaly.", "privacy.unlisted.additional": "Chová se stejně jako veřejný, až na to, že se příspěvek neobjeví v živých kanálech nebo hashtazích, v objevování nebo vyhledávání na Mastodonu, a to i když je účet nastaven tak, aby se zde všude tyto příspěvky zobrazovaly.",

View File

@@ -20,7 +20,7 @@
"account.add_or_remove_from_list": "Tilføj eller fjern fra lister", "account.add_or_remove_from_list": "Tilføj eller fjern fra lister",
"account.badges.admin": "Admin", "account.badges.admin": "Admin",
"account.badges.blocked": "Blokeret", "account.badges.blocked": "Blokeret",
"account.badges.bot": "Automatisert", "account.badges.bot": "Automatiseret",
"account.badges.domain_blocked": "Blokeret domæne", "account.badges.domain_blocked": "Blokeret domæne",
"account.badges.group": "Gruppe", "account.badges.group": "Gruppe",
"account.badges.muted": "Skjult", "account.badges.muted": "Skjult",
@@ -263,6 +263,11 @@
"collections.create_collection": "Opret samling", "collections.create_collection": "Opret samling",
"collections.delete_collection": "Slet samling", "collections.delete_collection": "Slet samling",
"collections.description_length_hint": "Begrænset til 100 tegn", "collections.description_length_hint": "Begrænset til 100 tegn",
"collections.detail.accounts_heading": "Konti",
"collections.detail.curated_by_author": "Kurateret af {author}",
"collections.detail.curated_by_you": "Kurateret af dig",
"collections.detail.loading": "Indlæser samling…",
"collections.detail.share": "Del denne samling",
"collections.edit_details": "Rediger grundlæggende oplysninger", "collections.edit_details": "Rediger grundlæggende oplysninger",
"collections.edit_settings": "Rediger indstillinger", "collections.edit_settings": "Rediger indstillinger",
"collections.error_loading_collections": "Der opstod en fejl under indlæsning af dine samlinger.", "collections.error_loading_collections": "Der opstod en fejl under indlæsning af dine samlinger.",

View File

@@ -263,6 +263,11 @@
"collections.create_collection": "Sammlung erstellen", "collections.create_collection": "Sammlung erstellen",
"collections.delete_collection": "Sammlung löschen", "collections.delete_collection": "Sammlung löschen",
"collections.description_length_hint": "Maximal 100 Zeichen", "collections.description_length_hint": "Maximal 100 Zeichen",
"collections.detail.accounts_heading": "Konten",
"collections.detail.curated_by_author": "Kuratiert von {author}",
"collections.detail.curated_by_you": "Kuratiert von dir",
"collections.detail.loading": "Sammlung wird geladen …",
"collections.detail.share": "Sammlung teilen",
"collections.edit_details": "Allgemeine Informationen bearbeiten", "collections.edit_details": "Allgemeine Informationen bearbeiten",
"collections.edit_settings": "Einstellungen bearbeiten", "collections.edit_settings": "Einstellungen bearbeiten",
"collections.error_loading_collections": "Beim Laden deiner Sammlungen ist ein Fehler aufgetreten.", "collections.error_loading_collections": "Beim Laden deiner Sammlungen ist ein Fehler aufgetreten.",

View File

@@ -263,6 +263,11 @@
"collections.create_collection": "Δημιουργία συλλογής", "collections.create_collection": "Δημιουργία συλλογής",
"collections.delete_collection": "Διαγραφή συλλογής", "collections.delete_collection": "Διαγραφή συλλογής",
"collections.description_length_hint": "Όριο 100 χαρακτήρων", "collections.description_length_hint": "Όριο 100 χαρακτήρων",
"collections.detail.accounts_heading": "Λογαριασμοί",
"collections.detail.curated_by_author": "Επιμέλεια από {author}",
"collections.detail.curated_by_you": "Επιμέλεια από εσάς",
"collections.detail.loading": "Γίνεται φόρτωση της συλλογής…",
"collections.detail.share": "Κοινοποιήστε αυτήν τη συλλογή",
"collections.edit_details": "Επεξεργασία βασικών στοιχείων", "collections.edit_details": "Επεξεργασία βασικών στοιχείων",
"collections.edit_settings": "Επεξεργασία ρυθμίσεων", "collections.edit_settings": "Επεξεργασία ρυθμίσεων",
"collections.error_loading_collections": "Παρουσιάστηκε σφάλμα κατά την προσπάθεια φόρτωσης των συλλογών σας.", "collections.error_loading_collections": "Παρουσιάστηκε σφάλμα κατά την προσπάθεια φόρτωσης των συλλογών σας.",

View File

@@ -263,8 +263,12 @@
"collections.create_collection": "Create collection", "collections.create_collection": "Create collection",
"collections.delete_collection": "Delete collection", "collections.delete_collection": "Delete collection",
"collections.description_length_hint": "100 characters limit", "collections.description_length_hint": "100 characters limit",
"collections.edit_details": "Edit basic details", "collections.detail.accounts_heading": "Accounts",
"collections.edit_settings": "Edit settings", "collections.detail.curated_by_author": "Curated by {author}",
"collections.detail.curated_by_you": "Curated by you",
"collections.detail.loading": "Loading collection…",
"collections.detail.share": "Share this collection",
"collections.edit_details": "Edit details",
"collections.error_loading_collections": "There was an error when trying to load your collections.", "collections.error_loading_collections": "There was an error when trying to load your collections.",
"collections.hints.accounts_counter": "{count} / {max} accounts", "collections.hints.accounts_counter": "{count} / {max} accounts",
"collections.hints.add_more_accounts": "Add at least {count, plural, one {# account} other {# accounts}} to continue", "collections.hints.add_more_accounts": "Add at least {count, plural, one {# account} other {# accounts}} to continue",
@@ -274,7 +278,7 @@
"collections.manage_accounts_in_collection": "Manage accounts in this collection", "collections.manage_accounts_in_collection": "Manage accounts in this collection",
"collections.mark_as_sensitive": "Mark as sensitive", "collections.mark_as_sensitive": "Mark as sensitive",
"collections.mark_as_sensitive_hint": "Hides the collection's description and accounts behind a content warning. The collection name will still be visible.", "collections.mark_as_sensitive_hint": "Hides the collection's description and accounts behind a content warning. The collection name will still be visible.",
"collections.name_length_hint": "100 characters limit", "collections.name_length_hint": "40 characters limit",
"collections.new_collection": "New collection", "collections.new_collection": "New collection",
"collections.no_collections_yet": "No collections yet.", "collections.no_collections_yet": "No collections yet.",
"collections.remove_account": "Remove this account", "collections.remove_account": "Remove this account",

View File

@@ -141,8 +141,25 @@
"account.unmute": "Unmute @{name}", "account.unmute": "Unmute @{name}",
"account.unmute_notifications_short": "Unmute notifications", "account.unmute_notifications_short": "Unmute notifications",
"account.unmute_short": "Unmute", "account.unmute_short": "Unmute",
"account_edit.bio.placeholder": "Add a short introduction to help others identify you.",
"account_edit.bio.title": "Bio",
"account_edit.bio_modal.add_title": "Add bio",
"account_edit.bio_modal.edit_title": "Edit bio",
"account_edit.char_counter": "{currentLength}/{maxLength} characters",
"account_edit.column_button": "Done", "account_edit.column_button": "Done",
"account_edit.column_title": "Edit Profile", "account_edit.column_title": "Edit Profile",
"account_edit.custom_fields.placeholder": "Add your pronouns, external links, or anything else youd like to share.",
"account_edit.custom_fields.title": "Custom fields",
"account_edit.display_name.placeholder": "Your display name is how your name appears on your profile and in timelines.",
"account_edit.display_name.title": "Display name",
"account_edit.featured_hashtags.placeholder": "Help others identify, and have quick access to, your favorite topics.",
"account_edit.featured_hashtags.title": "Featured hashtags",
"account_edit.name_modal.add_title": "Add display name",
"account_edit.name_modal.edit_title": "Edit display name",
"account_edit.profile_tab.subtitle": "Customize the tabs on your profile and what they display.",
"account_edit.profile_tab.title": "Profile tab settings",
"account_edit.save": "Save",
"account_edit.section_edit_button": "Edit",
"account_note.placeholder": "Click to add note", "account_note.placeholder": "Click to add note",
"admin.dashboard.daily_retention": "User retention rate by day after sign-up", "admin.dashboard.daily_retention": "User retention rate by day after sign-up",
"admin.dashboard.monthly_retention": "User retention rate by month after sign-up", "admin.dashboard.monthly_retention": "User retention rate by month after sign-up",
@@ -257,7 +274,6 @@
"collections.create.accounts_subtitle": "Only accounts you follow who have opted into discovery can be added.", "collections.create.accounts_subtitle": "Only accounts you follow who have opted into discovery can be added.",
"collections.create.accounts_title": "Who will you feature in this collection?", "collections.create.accounts_title": "Who will you feature in this collection?",
"collections.create.basic_details_title": "Basic details", "collections.create.basic_details_title": "Basic details",
"collections.create.settings_title": "Settings",
"collections.create.steps": "Step {step}/{total}", "collections.create.steps": "Step {step}/{total}",
"collections.create_a_collection_hint": "Create a collection to recommend or share your favourite accounts with others.", "collections.create_a_collection_hint": "Create a collection to recommend or share your favourite accounts with others.",
"collections.create_collection": "Create collection", "collections.create_collection": "Create collection",
@@ -268,23 +284,22 @@
"collections.detail.curated_by_you": "Curated by you", "collections.detail.curated_by_you": "Curated by you",
"collections.detail.loading": "Loading collection…", "collections.detail.loading": "Loading collection…",
"collections.detail.share": "Share this collection", "collections.detail.share": "Share this collection",
"collections.edit_details": "Edit basic details", "collections.edit_details": "Edit details",
"collections.edit_settings": "Edit settings",
"collections.error_loading_collections": "There was an error when trying to load your collections.", "collections.error_loading_collections": "There was an error when trying to load your collections.",
"collections.hints.accounts_counter": "{count} / {max} accounts", "collections.hints.accounts_counter": "{count} / {max} accounts",
"collections.hints.add_more_accounts": "Add at least {count, plural, one {# account} other {# accounts}} to continue", "collections.hints.add_more_accounts": "Add at least {count, plural, one {# account} other {# accounts}} to continue",
"collections.hints.can_not_remove_more_accounts": "Collections must contain at least {count, plural, one {# account} other {# accounts}}. Removing more accounts is not possible.", "collections.hints.can_not_remove_more_accounts": "Collections must contain at least {count, plural, one {# account} other {# accounts}}. Removing more accounts is not possible.",
"collections.last_updated_at": "Last updated: {date}", "collections.last_updated_at": "Last updated: {date}",
"collections.manage_accounts": "Manage accounts", "collections.manage_accounts": "Manage accounts",
"collections.manage_accounts_in_collection": "Manage accounts in this collection",
"collections.mark_as_sensitive": "Mark as sensitive", "collections.mark_as_sensitive": "Mark as sensitive",
"collections.mark_as_sensitive_hint": "Hides the collection's description and accounts behind a content warning. The collection name will still be visible.", "collections.mark_as_sensitive_hint": "Hides the collection's description and accounts behind a content warning. The collection name will still be visible.",
"collections.name_length_hint": "100 characters limit", "collections.name_length_hint": "40 characters limit",
"collections.new_collection": "New collection", "collections.new_collection": "New collection",
"collections.no_collections_yet": "No collections yet.", "collections.no_collections_yet": "No collections yet.",
"collections.remove_account": "Remove this account", "collections.remove_account": "Remove this account",
"collections.search_accounts_label": "Search for accounts to add…", "collections.search_accounts_label": "Search for accounts to add…",
"collections.search_accounts_max_reached": "You have added the maximum number of accounts", "collections.search_accounts_max_reached": "You have added the maximum number of accounts",
"collections.sensitive": "Sensitive",
"collections.topic_hint": "Add a hashtag that helps others understand the main topic of this collection.", "collections.topic_hint": "Add a hashtag that helps others understand the main topic of this collection.",
"collections.view_collection": "View collection", "collections.view_collection": "View collection",
"collections.visibility_public": "Public", "collections.visibility_public": "Public",

View File

@@ -263,6 +263,11 @@
"collections.create_collection": "Crear colección", "collections.create_collection": "Crear colección",
"collections.delete_collection": "Eliminar colección", "collections.delete_collection": "Eliminar colección",
"collections.description_length_hint": "Límite de 100 caracteres", "collections.description_length_hint": "Límite de 100 caracteres",
"collections.detail.accounts_heading": "Cuentas",
"collections.detail.curated_by_author": "Curado por {author}",
"collections.detail.curated_by_you": "Curado por vos",
"collections.detail.loading": "Cargando colección…",
"collections.detail.share": "Compartir esta colección",
"collections.edit_details": "Editar detalles básicos", "collections.edit_details": "Editar detalles básicos",
"collections.edit_settings": "Editar configuración", "collections.edit_settings": "Editar configuración",
"collections.error_loading_collections": "Hubo un error al intentar cargar tus colecciones.", "collections.error_loading_collections": "Hubo un error al intentar cargar tus colecciones.",

View File

@@ -263,6 +263,11 @@
"collections.create_collection": "Crear colección", "collections.create_collection": "Crear colección",
"collections.delete_collection": "Eliminar colección", "collections.delete_collection": "Eliminar colección",
"collections.description_length_hint": "Limitado a 100 caracteres", "collections.description_length_hint": "Limitado a 100 caracteres",
"collections.detail.accounts_heading": "Cuentas",
"collections.detail.curated_by_author": "Seleccionado por {author}",
"collections.detail.curated_by_you": "Seleccionado por ti",
"collections.detail.loading": "Cargando colección…",
"collections.detail.share": "Compartir esta colección",
"collections.edit_details": "Editar detalles básicos", "collections.edit_details": "Editar detalles básicos",
"collections.edit_settings": "Editar configuración", "collections.edit_settings": "Editar configuración",
"collections.error_loading_collections": "Se produjo un error al intentar cargar tus colecciones.", "collections.error_loading_collections": "Se produjo un error al intentar cargar tus colecciones.",

View File

@@ -141,6 +141,8 @@
"account.unmute": "Dejar de silenciar a @{name}", "account.unmute": "Dejar de silenciar a @{name}",
"account.unmute_notifications_short": "Dejar de silenciar notificaciones", "account.unmute_notifications_short": "Dejar de silenciar notificaciones",
"account.unmute_short": "Dejar de silenciar", "account.unmute_short": "Dejar de silenciar",
"account_edit.column_button": "Hecho",
"account_edit.column_title": "Editar perfil",
"account_note.placeholder": "Haz clic para añadir nota", "account_note.placeholder": "Haz clic para añadir nota",
"admin.dashboard.daily_retention": "Tasa de retención de usuarios por día después del registro", "admin.dashboard.daily_retention": "Tasa de retención de usuarios por día después del registro",
"admin.dashboard.monthly_retention": "Tasa de retención de usuarios por mes después del registro", "admin.dashboard.monthly_retention": "Tasa de retención de usuarios por mes después del registro",
@@ -261,6 +263,11 @@
"collections.create_collection": "Crear colección", "collections.create_collection": "Crear colección",
"collections.delete_collection": "Eliminar colección", "collections.delete_collection": "Eliminar colección",
"collections.description_length_hint": "Limitado a 100 caracteres", "collections.description_length_hint": "Limitado a 100 caracteres",
"collections.detail.accounts_heading": "Cuentas",
"collections.detail.curated_by_author": "Seleccionado por {author}",
"collections.detail.curated_by_you": "Seleccionado por ti",
"collections.detail.loading": "Cargando colección…",
"collections.detail.share": "Compartir esta colección",
"collections.edit_details": "Editar datos básicos", "collections.edit_details": "Editar datos básicos",
"collections.edit_settings": "Cambiar ajustes", "collections.edit_settings": "Cambiar ajustes",
"collections.error_loading_collections": "Se ha producido un error al intentar cargar tus colecciones.", "collections.error_loading_collections": "Se ha producido un error al intentar cargar tus colecciones.",

View File

@@ -263,6 +263,11 @@
"collections.create_collection": "Luo kokoelma", "collections.create_collection": "Luo kokoelma",
"collections.delete_collection": "Poista kokoelma", "collections.delete_collection": "Poista kokoelma",
"collections.description_length_hint": "100 merkin rajoitus", "collections.description_length_hint": "100 merkin rajoitus",
"collections.detail.accounts_heading": "Tilit",
"collections.detail.curated_by_author": "Koonnut {author}",
"collections.detail.curated_by_you": "Itse kokoamasi",
"collections.detail.loading": "Ladataan kokoelmaa…",
"collections.detail.share": "Jaa tämä kokoelma",
"collections.edit_details": "Muokkaa perustietoja", "collections.edit_details": "Muokkaa perustietoja",
"collections.edit_settings": "Muokkaa asetuksia", "collections.edit_settings": "Muokkaa asetuksia",
"collections.error_loading_collections": "Kokoelmien latauksessa tapahtui virhe.", "collections.error_loading_collections": "Kokoelmien latauksessa tapahtui virhe.",

View File

@@ -141,6 +141,8 @@
"account.unmute": "Ne plus masquer @{name}", "account.unmute": "Ne plus masquer @{name}",
"account.unmute_notifications_short": "Ne plus masquer les notifications", "account.unmute_notifications_short": "Ne plus masquer les notifications",
"account.unmute_short": "Ne plus masquer", "account.unmute_short": "Ne plus masquer",
"account_edit.column_button": "Terminé",
"account_edit.column_title": "Modifier le profil",
"account_note.placeholder": "Cliquez pour ajouter une note", "account_note.placeholder": "Cliquez pour ajouter une note",
"admin.dashboard.daily_retention": "Taux de rétention des comptes par jour après inscription", "admin.dashboard.daily_retention": "Taux de rétention des comptes par jour après inscription",
"admin.dashboard.monthly_retention": "Taux de rétention des comptes par mois après inscription", "admin.dashboard.monthly_retention": "Taux de rétention des comptes par mois après inscription",
@@ -261,6 +263,11 @@
"collections.create_collection": "Créer une collection", "collections.create_collection": "Créer une collection",
"collections.delete_collection": "Supprimer la collection", "collections.delete_collection": "Supprimer la collection",
"collections.description_length_hint": "Maximum 100 caractères", "collections.description_length_hint": "Maximum 100 caractères",
"collections.detail.accounts_heading": "Comptes",
"collections.detail.curated_by_author": "Organisée par {author}",
"collections.detail.curated_by_you": "Organisée par vous",
"collections.detail.loading": "Chargement de la collection…",
"collections.detail.share": "Partager la collection",
"collections.edit_details": "Modifier les informations générales", "collections.edit_details": "Modifier les informations générales",
"collections.edit_settings": "Modifier les paramètres", "collections.edit_settings": "Modifier les paramètres",
"collections.error_loading_collections": "Une erreur s'est produite durant le chargement de vos collections.", "collections.error_loading_collections": "Une erreur s'est produite durant le chargement de vos collections.",

View File

@@ -141,6 +141,8 @@
"account.unmute": "Ne plus masquer @{name}", "account.unmute": "Ne plus masquer @{name}",
"account.unmute_notifications_short": "Réactiver les notifications", "account.unmute_notifications_short": "Réactiver les notifications",
"account.unmute_short": "Ne plus masquer", "account.unmute_short": "Ne plus masquer",
"account_edit.column_button": "Terminé",
"account_edit.column_title": "Modifier le profil",
"account_note.placeholder": "Cliquez pour ajouter une note", "account_note.placeholder": "Cliquez pour ajouter une note",
"admin.dashboard.daily_retention": "Taux de rétention des utilisateur·rice·s par jour après inscription", "admin.dashboard.daily_retention": "Taux de rétention des utilisateur·rice·s par jour après inscription",
"admin.dashboard.monthly_retention": "Taux de rétention des utilisateur·rice·s par mois après inscription", "admin.dashboard.monthly_retention": "Taux de rétention des utilisateur·rice·s par mois après inscription",
@@ -261,6 +263,11 @@
"collections.create_collection": "Créer une collection", "collections.create_collection": "Créer une collection",
"collections.delete_collection": "Supprimer la collection", "collections.delete_collection": "Supprimer la collection",
"collections.description_length_hint": "Maximum 100 caractères", "collections.description_length_hint": "Maximum 100 caractères",
"collections.detail.accounts_heading": "Comptes",
"collections.detail.curated_by_author": "Organisée par {author}",
"collections.detail.curated_by_you": "Organisée par vous",
"collections.detail.loading": "Chargement de la collection…",
"collections.detail.share": "Partager la collection",
"collections.edit_details": "Modifier les informations générales", "collections.edit_details": "Modifier les informations générales",
"collections.edit_settings": "Modifier les paramètres", "collections.edit_settings": "Modifier les paramètres",
"collections.error_loading_collections": "Une erreur s'est produite durant le chargement de vos collections.", "collections.error_loading_collections": "Une erreur s'est produite durant le chargement de vos collections.",

View File

@@ -141,6 +141,8 @@
"account.unmute": "Díbhalbhaigh @{name}", "account.unmute": "Díbhalbhaigh @{name}",
"account.unmute_notifications_short": "Díbhalbhaigh fógraí", "account.unmute_notifications_short": "Díbhalbhaigh fógraí",
"account.unmute_short": "Díbhalbhaigh", "account.unmute_short": "Díbhalbhaigh",
"account_edit.column_button": "Déanta",
"account_edit.column_title": "Cuir Próifíl in Eagar",
"account_note.placeholder": "Cliceáil chun nóta a chuir leis", "account_note.placeholder": "Cliceáil chun nóta a chuir leis",
"admin.dashboard.daily_retention": "Ráta coinneála an úsáideora de réir an lae tar éis clárú", "admin.dashboard.daily_retention": "Ráta coinneála an úsáideora de réir an lae tar éis clárú",
"admin.dashboard.monthly_retention": "Ráta coinneála na n-úsáideoirí de réir na míosa tar éis dóibh clárú", "admin.dashboard.monthly_retention": "Ráta coinneála na n-úsáideoirí de réir na míosa tar éis dóibh clárú",
@@ -261,6 +263,11 @@
"collections.create_collection": "Cruthaigh bailiúchán", "collections.create_collection": "Cruthaigh bailiúchán",
"collections.delete_collection": "Scrios bailiúchán", "collections.delete_collection": "Scrios bailiúchán",
"collections.description_length_hint": "Teorainn 100 carachtar", "collections.description_length_hint": "Teorainn 100 carachtar",
"collections.detail.accounts_heading": "Cuntais",
"collections.detail.curated_by_author": "Curtha i dtoll a chéile ag {author}",
"collections.detail.curated_by_you": "Curtha i dtoll a chéile agatsa",
"collections.detail.loading": "Ag lódáil an bhailiúcháin…",
"collections.detail.share": "Comhroinn an bailiúchán seo",
"collections.edit_details": "Cuir sonraí bunúsacha in eagar", "collections.edit_details": "Cuir sonraí bunúsacha in eagar",
"collections.edit_settings": "Socruithe a chur in eagar", "collections.edit_settings": "Socruithe a chur in eagar",
"collections.error_loading_collections": "Tharla earráid agus iarracht á déanamh do bhailiúcháin a luchtú.", "collections.error_loading_collections": "Tharla earráid agus iarracht á déanamh do bhailiúcháin a luchtú.",

View File

@@ -263,6 +263,11 @@
"collections.create_collection": "Crear colección", "collections.create_collection": "Crear colección",
"collections.delete_collection": "Eliminar colección", "collections.delete_collection": "Eliminar colección",
"collections.description_length_hint": "Límite de 100 caracteres", "collections.description_length_hint": "Límite de 100 caracteres",
"collections.detail.accounts_heading": "Contas",
"collections.detail.curated_by_author": "Seleccionadas por {author}",
"collections.detail.curated_by_you": "Seleccionadas por ti",
"collections.detail.loading": "Cargando colección…",
"collections.detail.share": "Compartir esta colección",
"collections.edit_details": "Editar detalles básicos", "collections.edit_details": "Editar detalles básicos",
"collections.edit_settings": "Editar axustes", "collections.edit_settings": "Editar axustes",
"collections.error_loading_collections": "Houbo un erro ao intentar cargar as túas coleccións.", "collections.error_loading_collections": "Houbo un erro ao intentar cargar as túas coleccións.",

View File

@@ -263,6 +263,11 @@
"collections.create_collection": "יצירת אוסף", "collections.create_collection": "יצירת אוסף",
"collections.delete_collection": "מחיקת האוסף", "collections.delete_collection": "מחיקת האוסף",
"collections.description_length_hint": "מגבלה של 100 תווים", "collections.description_length_hint": "מגבלה של 100 תווים",
"collections.detail.accounts_heading": "חשבונות",
"collections.detail.curated_by_author": "נאצר על ידי {author}",
"collections.detail.curated_by_you": "נאצר על ידיך",
"collections.detail.loading": "טוען אוסף…",
"collections.detail.share": "שיתוף אוסף",
"collections.edit_details": "עריכת פרטים בסיסיים", "collections.edit_details": "עריכת פרטים בסיסיים",
"collections.edit_settings": "עריכת הגדרות", "collections.edit_settings": "עריכת הגדרות",
"collections.error_loading_collections": "חלה שגיאה בנסיון לטעון את אוספיך.", "collections.error_loading_collections": "חלה שגיאה בנסיון לטעון את אוספיך.",

View File

@@ -263,6 +263,11 @@
"collections.create_collection": "Búa til safn", "collections.create_collection": "Búa til safn",
"collections.delete_collection": "Eyða safni", "collections.delete_collection": "Eyða safni",
"collections.description_length_hint": "100 stafa takmörk", "collections.description_length_hint": "100 stafa takmörk",
"collections.detail.accounts_heading": "Aðgangar",
"collections.detail.curated_by_author": "Safnað saman af {author}",
"collections.detail.curated_by_you": "Safnað saman af þér",
"collections.detail.loading": "Hleð inn safni…",
"collections.detail.share": "Deila þessu safni",
"collections.edit_details": "Breyta grunnupplýsingum", "collections.edit_details": "Breyta grunnupplýsingum",
"collections.edit_settings": "Breyta stillingum", "collections.edit_settings": "Breyta stillingum",
"collections.error_loading_collections": "Villa kom upp þegar reynt var að hlaða inn söfnunum þínum.", "collections.error_loading_collections": "Villa kom upp þegar reynt var að hlaða inn söfnunum þínum.",

View File

@@ -263,6 +263,11 @@
"collections.create_collection": "Crea la collezione", "collections.create_collection": "Crea la collezione",
"collections.delete_collection": "Cancella la collezione", "collections.delete_collection": "Cancella la collezione",
"collections.description_length_hint": "Limite di 100 caratteri", "collections.description_length_hint": "Limite di 100 caratteri",
"collections.detail.accounts_heading": "Account",
"collections.detail.curated_by_author": "Curata da {author}",
"collections.detail.curated_by_you": "Curata da te",
"collections.detail.loading": "Caricamento della collezione…",
"collections.detail.share": "Condividi questa collezione",
"collections.edit_details": "Modifica i dettagli di base", "collections.edit_details": "Modifica i dettagli di base",
"collections.edit_settings": "Modifica impostazioni", "collections.edit_settings": "Modifica impostazioni",
"collections.error_loading_collections": "Si è verificato un errore durante il tentativo di caricare le tue collezioni.", "collections.error_loading_collections": "Si è verificato un errore durante il tentativo di caricare le tue collezioni.",

View File

@@ -16,7 +16,9 @@
"account.about": "정보", "account.about": "정보",
"account.account_note_header": "개인 메모", "account.account_note_header": "개인 메모",
"account.activity": "활동", "account.activity": "활동",
"account.add_note": "개인 메모 추가",
"account.add_or_remove_from_list": "리스트에 추가 혹은 삭제", "account.add_or_remove_from_list": "리스트에 추가 혹은 삭제",
"account.badges.admin": "관리자",
"account.badges.blocked": "차단함", "account.badges.blocked": "차단함",
"account.badges.bot": "자동화됨", "account.badges.bot": "자동화됨",
"account.badges.domain_blocked": "차단한 도메인", "account.badges.domain_blocked": "차단한 도메인",
@@ -33,6 +35,7 @@
"account.direct": "@{name} 님에게 개인 멘션", "account.direct": "@{name} 님에게 개인 멘션",
"account.disable_notifications": "@{name} 의 게시물 알림 끄기", "account.disable_notifications": "@{name} 의 게시물 알림 끄기",
"account.domain_blocking": "도메인 차단함", "account.domain_blocking": "도메인 차단함",
"account.edit_note": "개인 메모 편집",
"account.edit_profile": "프로필 편집", "account.edit_profile": "프로필 편집",
"account.edit_profile_short": "수정", "account.edit_profile_short": "수정",
"account.enable_notifications": "@{name} 의 게시물 알림 켜기", "account.enable_notifications": "@{name} 의 게시물 알림 켜기",
@@ -45,6 +48,7 @@
"account.featured.hashtags": "해시태그", "account.featured.hashtags": "해시태그",
"account.featured_tags.last_status_at": "{date}에 마지막으로 게시", "account.featured_tags.last_status_at": "{date}에 마지막으로 게시",
"account.featured_tags.last_status_never": "게시물 없음", "account.featured_tags.last_status_never": "게시물 없음",
"account.filters.all": "모든 활동",
"account.filters.boosts_toggle": "부스트 보기", "account.filters.boosts_toggle": "부스트 보기",
"account.filters.replies_toggle": "답글 보기", "account.filters.replies_toggle": "답글 보기",
"account.follow": "팔로우", "account.follow": "팔로우",

View File

@@ -141,6 +141,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_edit.column_button": "Klaar",
"account_edit.column_title": "Profiel bewerken",
"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",
@@ -244,9 +246,12 @@
"closed_registrations_modal.preamble": "Mastodon is gedecentraliseerd. Op welke server je ook een account hebt, je kunt overal vandaan mensen op deze server volgen en er mee interactie hebben. Je kunt zelfs zelf een Mastodon-server hosten!", "closed_registrations_modal.preamble": "Mastodon is gedecentraliseerd. Op welke server je ook een account hebt, je kunt overal vandaan mensen op deze server volgen en er mee interactie hebben. Je kunt zelfs zelf een Mastodon-server hosten!",
"closed_registrations_modal.title": "Registreren op Mastodon", "closed_registrations_modal.title": "Registreren op Mastodon",
"collections.account_count": "{count, plural, one {# account} other {# accounts}}", "collections.account_count": "{count, plural, one {# account} other {# accounts}}",
"collections.accounts.empty_description": "Voeg tot {count} accounts toe die je volgt",
"collections.accounts.empty_title": "Deze verzameling is leeg",
"collections.collection_description": "Omschrijving", "collections.collection_description": "Omschrijving",
"collections.collection_name": "Naam", "collections.collection_name": "Naam",
"collections.collection_topic": "Onderwerp", "collections.collection_topic": "Onderwerp",
"collections.confirm_account_removal": "Weet je zeker dat je dit account uit deze verzameling wilt verwijderen?",
"collections.content_warning": "Inhoudswaarschuwing", "collections.content_warning": "Inhoudswaarschuwing",
"collections.continue": "Doorgaan", "collections.continue": "Doorgaan",
"collections.create.accounts_subtitle": "Alleen accounts die je volgt en ontdekt willen worden, kunnen worden toegevoegd.", "collections.create.accounts_subtitle": "Alleen accounts die je volgt en ontdekt willen worden, kunnen worden toegevoegd.",
@@ -258,9 +263,17 @@
"collections.create_collection": "Verzameling aanmaken", "collections.create_collection": "Verzameling aanmaken",
"collections.delete_collection": "Verzameling verwijderen", "collections.delete_collection": "Verzameling verwijderen",
"collections.description_length_hint": "Maximaal 100 karakters", "collections.description_length_hint": "Maximaal 100 karakters",
"collections.detail.accounts_heading": "Accounts",
"collections.detail.curated_by_author": "Samengesteld door {author}",
"collections.detail.curated_by_you": "Samengesteld door jou",
"collections.detail.loading": "Verzameling laden…",
"collections.detail.share": "Deze verzameling delen",
"collections.edit_details": "Basisgegevens bewerken", "collections.edit_details": "Basisgegevens bewerken",
"collections.edit_settings": "Instellingen bewerken", "collections.edit_settings": "Instellingen bewerken",
"collections.error_loading_collections": "Er is een fout opgetreden bij het laden van je verzamelingen.", "collections.error_loading_collections": "Er is een fout opgetreden bij het laden van je verzamelingen.",
"collections.hints.accounts_counter": "{count} / {max} accounts",
"collections.hints.add_more_accounts": "Voeg ten minste {count, plural, one {# account} other {# accounts}} toe om door te gaan",
"collections.hints.can_not_remove_more_accounts": "Verzamelingen moeten ten minste {count, plural, one {# account} other {# accounts}} bevatten. Meer accounts verwijderen is niet mogelijk.",
"collections.last_updated_at": "Laatst bijgewerkt: {date}", "collections.last_updated_at": "Laatst bijgewerkt: {date}",
"collections.manage_accounts": "Accounts beheren", "collections.manage_accounts": "Accounts beheren",
"collections.manage_accounts_in_collection": "Accounts in deze verzameling beheren", "collections.manage_accounts_in_collection": "Accounts in deze verzameling beheren",
@@ -269,6 +282,9 @@
"collections.name_length_hint": "100 tekens limiet", "collections.name_length_hint": "100 tekens limiet",
"collections.new_collection": "Nieuwe verzameling", "collections.new_collection": "Nieuwe verzameling",
"collections.no_collections_yet": "Nog geen verzamelingen.", "collections.no_collections_yet": "Nog geen verzamelingen.",
"collections.remove_account": "Deze account verwijderen",
"collections.search_accounts_label": "Zoek naar accounts om toe te voegen…",
"collections.search_accounts_max_reached": "Je hebt het maximum aantal accounts toegevoegd",
"collections.topic_hint": "Voeg een hashtag toe die anderen helpt het hoofdonderwerp van deze collectie te begrijpen.", "collections.topic_hint": "Voeg een hashtag toe die anderen helpt het hoofdonderwerp van deze collectie te begrijpen.",
"collections.view_collection": "Verzameling bekijken", "collections.view_collection": "Verzameling bekijken",
"collections.visibility_public": "Openbaar", "collections.visibility_public": "Openbaar",

View File

@@ -260,6 +260,11 @@
"collections.create_collection": "Krijoni koleksion", "collections.create_collection": "Krijoni koleksion",
"collections.delete_collection": "Fshije koleksionin", "collections.delete_collection": "Fshije koleksionin",
"collections.description_length_hint": "Kufi prej 100 shenjash", "collections.description_length_hint": "Kufi prej 100 shenjash",
"collections.detail.accounts_heading": "Llogari",
"collections.detail.curated_by_author": "Në kujdesin e {author}",
"collections.detail.curated_by_you": "Nën kujdesin tuaj",
"collections.detail.loading": "Po ngarkohet koleksion…",
"collections.detail.share": "Ndajeni këtë koleksion me të tjerë",
"collections.edit_details": "Përpunoni hollësi bazë", "collections.edit_details": "Përpunoni hollësi bazë",
"collections.edit_settings": "Përpunoni rregullime", "collections.edit_settings": "Përpunoni rregullime",
"collections.error_loading_collections": "Pati një gabim teksa provohej të ngarkoheshin koleksionet tuaj.", "collections.error_loading_collections": "Pati një gabim teksa provohej të ngarkoheshin koleksionet tuaj.",

View File

@@ -200,6 +200,7 @@
"collections.create_a_collection_hint": "Skapa en samling för att rekommendera eller dela dina favoritkonton med andra.", "collections.create_a_collection_hint": "Skapa en samling för att rekommendera eller dela dina favoritkonton med andra.",
"collections.create_collection": "Skapa samling", "collections.create_collection": "Skapa samling",
"collections.delete_collection": "Radera samling", "collections.delete_collection": "Radera samling",
"collections.detail.accounts_heading": "Konton",
"collections.error_loading_collections": "Det uppstod ett fel när dina samlingar skulle laddas.", "collections.error_loading_collections": "Det uppstod ett fel när dina samlingar skulle laddas.",
"collections.hints.accounts_counter": "{count} / {max} konton", "collections.hints.accounts_counter": "{count} / {max} konton",
"collections.no_collections_yet": "Inga samlingar än.", "collections.no_collections_yet": "Inga samlingar än.",

View File

@@ -141,6 +141,8 @@
"account.unmute": "@{name} adlı kişinin sesini aç", "account.unmute": "@{name} adlı kişinin sesini aç",
"account.unmute_notifications_short": "Bildirimlerin sesini aç", "account.unmute_notifications_short": "Bildirimlerin sesini aç",
"account.unmute_short": "Susturmayı kaldır", "account.unmute_short": "Susturmayı kaldır",
"account_edit.column_button": "Tamamlandı",
"account_edit.column_title": "Profili Düzenle",
"account_note.placeholder": "Not eklemek için tıklayın", "account_note.placeholder": "Not eklemek için tıklayın",
"admin.dashboard.daily_retention": "Kayıttan sonra günlük kullanıcı saklama oranı", "admin.dashboard.daily_retention": "Kayıttan sonra günlük kullanıcı saklama oranı",
"admin.dashboard.monthly_retention": "Kayıttan sonra aylık kullanıcı saklama oranı", "admin.dashboard.monthly_retention": "Kayıttan sonra aylık kullanıcı saklama oranı",
@@ -261,6 +263,11 @@
"collections.create_collection": "Koleksiyon oluştur", "collections.create_collection": "Koleksiyon oluştur",
"collections.delete_collection": "Koleksiyonu sil", "collections.delete_collection": "Koleksiyonu sil",
"collections.description_length_hint": "100 karakterle sınırlı", "collections.description_length_hint": "100 karakterle sınırlı",
"collections.detail.accounts_heading": "Hesaplar",
"collections.detail.curated_by_author": "{author} tarafından derlenen",
"collections.detail.curated_by_you": "Sizin derledikleriniz",
"collections.detail.loading": "Koleksiyon yükleniyor…",
"collections.detail.share": "Bu koleksiyonu paylaş",
"collections.edit_details": "Temel bilgileri düzenle", "collections.edit_details": "Temel bilgileri düzenle",
"collections.edit_settings": "Ayarları düzenle", "collections.edit_settings": "Ayarları düzenle",
"collections.error_loading_collections": "Koleksiyonlarınızı yüklemeye çalışırken bir hata oluştu.", "collections.error_loading_collections": "Koleksiyonlarınızı yüklemeye çalışırken bir hata oluştu.",

View File

@@ -263,6 +263,11 @@
"collections.create_collection": "Tạo collection", "collections.create_collection": "Tạo collection",
"collections.delete_collection": "Xóa collection", "collections.delete_collection": "Xóa collection",
"collections.description_length_hint": "Giới hạn 100 ký tự", "collections.description_length_hint": "Giới hạn 100 ký tự",
"collections.detail.accounts_heading": "Tài khoản",
"collections.detail.curated_by_author": "Tuyển chọn bởi {author}",
"collections.detail.curated_by_you": "Tuyển chọn bởi bạn",
"collections.detail.loading": "Đang tải collection…",
"collections.detail.share": "Chia sẻ collection này",
"collections.edit_details": "Sửa thông tin cơ bản", "collections.edit_details": "Sửa thông tin cơ bản",
"collections.edit_settings": "Sửa cài đặt", "collections.edit_settings": "Sửa cài đặt",
"collections.error_loading_collections": "Đã xảy ra lỗi khi tải những collection của bạn.", "collections.error_loading_collections": "Đã xảy ra lỗi khi tải những collection của bạn.",

View File

@@ -141,6 +141,8 @@
"account.unmute": "不再隐藏 @{name}", "account.unmute": "不再隐藏 @{name}",
"account.unmute_notifications_short": "恢复通知", "account.unmute_notifications_short": "恢复通知",
"account.unmute_short": "取消隐藏", "account.unmute_short": "取消隐藏",
"account_edit.column_button": "完成",
"account_edit.column_title": "修改个人资料",
"account_note.placeholder": "点击添加备注", "account_note.placeholder": "点击添加备注",
"admin.dashboard.daily_retention": "注册后用户留存率(按日计算)", "admin.dashboard.daily_retention": "注册后用户留存率(按日计算)",
"admin.dashboard.monthly_retention": "注册后用户留存率(按月计算)", "admin.dashboard.monthly_retention": "注册后用户留存率(按月计算)",
@@ -261,6 +263,11 @@
"collections.create_collection": "创建收藏列表", "collections.create_collection": "创建收藏列表",
"collections.delete_collection": "删除收藏列表", "collections.delete_collection": "删除收藏列表",
"collections.description_length_hint": "100字限制", "collections.description_length_hint": "100字限制",
"collections.detail.accounts_heading": "账号",
"collections.detail.curated_by_author": "由 {author} 精心挑选",
"collections.detail.curated_by_you": "由你精心挑选",
"collections.detail.loading": "正在加载收藏列表…",
"collections.detail.share": "分享此收藏列表",
"collections.edit_details": "编辑基本信息", "collections.edit_details": "编辑基本信息",
"collections.edit_settings": "编辑设置", "collections.edit_settings": "编辑设置",
"collections.error_loading_collections": "加载你的收藏列表时发生错误。", "collections.error_loading_collections": "加载你的收藏列表时发生错误。",

View File

@@ -263,6 +263,11 @@
"collections.create_collection": "建立收藏名單", "collections.create_collection": "建立收藏名單",
"collections.delete_collection": "刪除收藏名單", "collections.delete_collection": "刪除收藏名單",
"collections.description_length_hint": "100 字限制", "collections.description_length_hint": "100 字限制",
"collections.detail.accounts_heading": "帳號",
"collections.detail.curated_by_author": "由 {author} 精選",
"collections.detail.curated_by_you": "由您精選",
"collections.detail.loading": "讀取收藏名單中...",
"collections.detail.share": "分享此收藏名單",
"collections.edit_details": "編輯基本資料", "collections.edit_details": "編輯基本資料",
"collections.edit_settings": "編輯設定", "collections.edit_settings": "編輯設定",
"collections.error_loading_collections": "讀取您的收藏名單時發生錯誤。", "collections.error_loading_collections": "讀取您的收藏名單時發生錯誤。",

View File

@@ -69,6 +69,11 @@ export const accountDefaultValues: AccountShape = {
display_name: '', display_name: '',
display_name_html: '', display_name_html: '',
emojis: ImmutableList<CustomEmoji>(), emojis: ImmutableList<CustomEmoji>(),
feature_approval: {
automatic: [],
manual: [],
current_user: 'missing',
},
fields: ImmutableList<AccountField>(), fields: ImmutableList<AccountField>(),
group: false, group: false,
header: '', header: '',

View File

@@ -15,6 +15,7 @@ import type {
ApiCreateCollectionPayload, ApiCreateCollectionPayload,
ApiUpdateCollectionPayload, ApiUpdateCollectionPayload,
} from '@/mastodon/api_types/collections'; } from '@/mastodon/api_types/collections';
import { me } from '@/mastodon/initial_state';
import { import {
createAppSelector, createAppSelector,
createDataLoadingThunk, createDataLoadingThunk,
@@ -111,6 +112,14 @@ const collectionSlice = createSlice({
const { collectionId } = action.meta.arg; const { collectionId } = action.meta.arg;
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete state.collections[collectionId]; delete state.collections[collectionId];
if (me) {
let accountCollectionIds = state.accountCollections[me]?.collectionIds;
if (accountCollectionIds) {
accountCollectionIds = accountCollectionIds.filter(
(id) => id !== collectionId,
);
}
}
}); });
/** /**

View File

@@ -31,6 +31,11 @@ export const accountFactory: FactoryFunction<ApiAccountJSON> = ({
created_at: '2023-01-01T00:00:00.000Z', created_at: '2023-01-01T00:00:00.000Z',
discoverable: true, discoverable: true,
emojis: [], emojis: [],
feature_approval: {
automatic: [],
manual: [],
current_user: 'missing',
},
fields: [], fields: [],
followers_count: 0, followers_count: 0,
following_count: 0, following_count: 0,

View File

@@ -26,6 +26,7 @@ class Collection < ApplicationRecord
belongs_to :tag, optional: true belongs_to :tag, optional: true
has_many :collection_items, dependent: :delete_all has_many :collection_items, dependent: :delete_all
has_many :accepted_collection_items, -> { accepted }, class_name: 'CollectionItem', inverse_of: :collection # rubocop:disable Rails/HasManyOrHasOneDependent
has_many :collection_reports, dependent: :delete_all has_many :collection_reports, dependent: :delete_all
validates :name, presence: true validates :name, presence: true

View File

@@ -1,6 +1,7 @@
.batch-table__row{ class: [!account.unavailable? && account.user_pending? && 'batch-table__row--attention', (account.unavailable? || account.user_unconfirmed?) && 'batch-table__row--muted'] } .batch-table__row{ class: [!account.unavailable? && account.user_pending? && 'batch-table__row--attention', (account.unavailable? || account.user_unconfirmed?) && 'batch-table__row--muted'] }
%label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
= f.check_box :account_ids, { multiple: true, include_hidden: false }, account.id - if local_assigns[:f].present?
= f.check_box :account_ids, { multiple: true, include_hidden: false }, account.id
.batch-table__row__content.batch-table__row__content--unpadded .batch-table__row__content.batch-table__row__content--unpadded
%table.accounts-table %table.accounts-table
%tbody %tbody

View File

@@ -0,0 +1,21 @@
- content_for :page_title do
= t('admin.collections.collection_title', name: @account.pretty_acct)
- content_for :heading_actions do
= link_to t('admin.collections.open'), account_collection_path(@account, @collection), class: 'button', target: '_blank', rel: 'noopener'
%h3= t('admin.collections.contents')
= render 'admin/shared/collection', collection: @collection
%hr.spacer/
%h3= t('admin.collections.accounts')
.batch-table
.batch-table__toolbar
.batch-table__body
- if @collection.accepted_collection_items.none?
= nothing_here 'nothing-here--under-tabs'
- else
= render partial: 'admin/accounts/account', collection: @collection.accepted_collection_items.map(&:account)

View File

@@ -67,6 +67,11 @@
= material_symbol('photo_camera') = material_symbol('photo_camera')
= report.media_attachments_count = report.media_attachments_count
- if Mastodon::Feature.collections_enabled?
%span.report-card__summary__item__content__icon{ title: t('admin.accounts.collections') }
= material_symbol('groups-fill')
= report.collections.size
- if report.forwarded? - if report.forwarded?
· ·
= t('admin.reports.forwarded_to', domain: target_account.domain) = t('admin.reports.forwarded_to', domain: target_account.domain)

View File

@@ -32,7 +32,7 @@
%hr.spacer/ %hr.spacer/
%h3 %h3
= t 'admin.reports.statuses' = t 'admin.reports.reported_content'
%small.section-skip-link %small.section-skip-link
= link_to '#actions' do = link_to '#actions' do
= material_symbol 'keyboard_double_arrow_down' = material_symbol 'keyboard_double_arrow_down'
@@ -41,6 +41,9 @@
%p %p
= t 'admin.reports.statuses_description_html' = t 'admin.reports.statuses_description_html'
%h4
= t 'admin.reports.statuses'
= form_with model: @form, url: batch_admin_account_statuses_path(@report.target_account_id, report_id: @report.id) do |f| = form_with model: @form, url: batch_admin_account_statuses_path(@report.target_account_id, report_id: @report.id) do |f|
.batch-table .batch-table
.batch-table__toolbar .batch-table__toolbar
@@ -58,6 +61,22 @@
- else - else
= render partial: 'admin/shared/status_batch_row', collection: @statuses, as: :status, locals: { f: f } = render partial: 'admin/shared/status_batch_row', collection: @statuses, as: :status, locals: { f: f }
- if Mastodon::Feature.collections_enabled?
%h4
= t 'admin.reports.collections'
%form
.batch-table
.batch-table__toolbar
%label.batch-table__toolbar__select.batch-checkbox-all
-# = check_box_tag :batch_checkbox_all, nil, false
.batch-table__toolbar__actions
.batch-table__body
- if @report.collections.empty?
= nothing_here 'nothing-here--under-tabs'
- else
= render partial: 'admin/shared/collection_batch_row', collection: @report.collections, as: :collection
- if @report.unresolved? - if @report.unresolved?
%hr.spacer/ %hr.spacer/

View File

@@ -0,0 +1,22 @@
.status__card
- if collection.tag.present?
.status__prepend
= link_to collection.tag.formatted_name, admin_tag_path(collection.tag_id)
.status__content
%h6= collection.name
%p= collection.description
.detailed-status__meta
= conditional_link_to can?(:show, collection), admin_account_collection_path(collection.account.id, collection), class: 'detailed-status__datetime' do
%time.formatted{ datetime: collection.created_at.iso8601, title: l(collection.created_at) }><= l(collection.created_at)
- if collection.sensitive?
&nbsp;·
= material_symbol('visibility_off')
= t('stream_entries.sensitive_content')
&nbsp;·
= t('admin.collections.number_of_accounts', count: collection.accepted_collection_items.size)
&nbsp;·
= link_to account_collection_path(collection.account, collection), class: 'detailed-status__link', target: 'blank', rel: 'noopener' do
= t('admin.collections.view_publicly')

View File

@@ -0,0 +1,5 @@
.batch-table__row
%label.batch-table__row__select.batch-checkbox
-# = f.check_box :collection_ids, { multiple: true, include_hidden: false }, collection.id
.batch-table__row__content
= render partial: 'admin/shared/collection', object: collection

View File

@@ -585,7 +585,6 @@ an:
resolved_msg: La denuncia s'ha resuelto correctament! resolved_msg: La denuncia s'ha resuelto correctament!
skip_to_actions: Ir dreitament a las accions skip_to_actions: Ir dreitament a las accions
status: Estau status: Estau
statuses: Conteniu denunciau
statuses_description_html: Lo conteniu ofensivo se citará en a comunicación con a cuenta denunciada statuses_description_html: Lo conteniu ofensivo se citará en a comunicación con a cuenta denunciada
target_origin: Orichen d'a cuenta denunciada target_origin: Orichen d'a cuenta denunciada
title: Reportes title: Reportes

View File

@@ -752,7 +752,6 @@ ar:
resolved_msg: تمت معالجة الشكوى بنجاح! resolved_msg: تمت معالجة الشكوى بنجاح!
skip_to_actions: تخطي إلى الإجراءات skip_to_actions: تخطي إلى الإجراءات
status: الحالة status: الحالة
statuses: المحتوى المبلغ عنه
statuses_description_html: سيشار إلى المحتوى المخالف في الاتصال بالحساب المبلغ عنه statuses_description_html: سيشار إلى المحتوى المخالف في الاتصال بالحساب المبلغ عنه
summary: summary:
action_preambles: action_preambles:

View File

@@ -261,7 +261,6 @@ ast:
resolved_msg: "¡L'informe resolvióse correutamente!" resolved_msg: "¡L'informe resolvióse correutamente!"
skip_to_actions: Saltar a les aiciones skip_to_actions: Saltar a les aiciones
status: Estáu status: Estáu
statuses: Conteníu del que s'informó
statuses_description_html: El conteníu ofensivu cítase na comunicación cola cuenta de la que s'informó statuses_description_html: El conteníu ofensivu cítase na comunicación cola cuenta de la que s'informó
target_origin: Orixe de la cuenta de la que s'infomó target_origin: Orixe de la cuenta de la que s'infomó
title: Informes title: Informes

View File

@@ -739,7 +739,6 @@ be:
resolved_msg: Скарга была паспяхова вырашана! resolved_msg: Скарга была паспяхова вырашана!
skip_to_actions: Прапусціць дзеянні skip_to_actions: Прапусціць дзеянні
status: Стан status: Стан
statuses: Змесціва, на якое паскардзіліся
statuses_description_html: Крыўднае змесціва будзе згадвацца ў зносінах з уліковым запісам, на які пададзена скарга statuses_description_html: Крыўднае змесціва будзе згадвацца ў зносінах з уліковым запісам, на які пададзена скарга
summary: summary:
action_preambles: action_preambles:

View File

@@ -696,7 +696,6 @@ bg:
resolved_msg: Успешно разрешен доклад! resolved_msg: Успешно разрешен доклад!
skip_to_actions: Прескок към действия skip_to_actions: Прескок към действия
status: Състояние status: Състояние
statuses: Докладвано съдържание
statuses_description_html: Обидно съдържание ще се цитира в общуването с докладвания акаунт statuses_description_html: Обидно съдържание ще се цитира в общуването с докладвания акаунт
summary: summary:
action_preambles: action_preambles:

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