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

Merge upstream changes up to 4d2a148ccb
This commit is contained in:
Claire
2026-02-25 19:45:52 +01:00
committed by GitHub
114 changed files with 2763 additions and 1420 deletions

View File

@@ -9,7 +9,6 @@ on:
- 'package.json' - 'package.json'
- 'yarn.lock' - 'yarn.lock'
- '.nvmrc' - '.nvmrc'
- '.prettier*'
- 'stylelint.config.js' - 'stylelint.config.js'
- '**/*.css' - '**/*.css'
- '**/*.scss' - '**/*.scss'
@@ -21,7 +20,6 @@ on:
- 'package.json' - 'package.json'
- 'yarn.lock' - 'yarn.lock'
- '.nvmrc' - '.nvmrc'
- '.prettier*'
- 'stylelint.config.js' - 'stylelint.config.js'
- '**/*.css' - '**/*.css'
- '**/*.scss' - '**/*.scss'

View File

@@ -10,7 +10,6 @@ on:
- 'yarn.lock' - 'yarn.lock'
- 'tsconfig.json' - 'tsconfig.json'
- '.nvmrc' - '.nvmrc'
- '.prettier*'
- 'eslint.config.mjs' - 'eslint.config.mjs'
- '**/*.js' - '**/*.js'
- '**/*.jsx' - '**/*.jsx'
@@ -24,7 +23,6 @@ on:
- 'yarn.lock' - 'yarn.lock'
- 'tsconfig.json' - 'tsconfig.json'
- '.nvmrc' - '.nvmrc'
- '.prettier*'
- 'eslint.config.mjs' - 'eslint.config.mjs'
- '**/*.js' - '**/*.js'
- '**/*.jsx' - '**/*.jsx'

View File

@@ -13,7 +13,7 @@ class Admin::Reports::ActionsController < Admin::BaseController
case action_from_button case action_from_button
when 'delete', 'mark_as_sensitive' when 'delete', 'mark_as_sensitive'
Admin::StatusBatchAction.new(status_batch_action_params).save! Admin::ModerationAction.new(moderation_action_params).save!
when 'silence', 'suspend' when 'silence', 'suspend'
Admin::AccountAction.new(account_action_params).save! Admin::AccountAction.new(account_action_params).save!
else else
@@ -25,9 +25,8 @@ class Admin::Reports::ActionsController < Admin::BaseController
private private
def status_batch_action_params def moderation_action_params
shared_params shared_params
.merge(status_ids: @report.status_ids)
end end
def account_action_params def account_action_params

View File

@@ -78,8 +78,6 @@ module Admin
'report' 'report'
elsif params[:remove_from_report] elsif params[:remove_from_report]
'remove_from_report' 'remove_from_report'
elsif params[:delete]
'delete'
end end
end end
end end

View File

@@ -19,7 +19,7 @@ module Admin::ActionLogsHelper
link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id) link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id)
when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain' when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain'
log.human_identifier.present? ? link_to(log.human_identifier, admin_instance_path(log.human_identifier)) : I18n.t('admin.action_logs.unavailable_instance') log.human_identifier.present? ? link_to(log.human_identifier, admin_instance_path(log.human_identifier)) : I18n.t('admin.action_logs.unavailable_instance')
when 'Status' when 'Status', 'Collection'
link_to log.human_identifier, log.permalink link_to log.human_identifier, log.permalink
when 'AccountWarning' when 'AccountWarning'
link_to log.human_identifier, disputes_strike_path(log.target_id) link_to log.human_identifier, disputes_strike_path(log.target_id)

View File

@@ -182,15 +182,25 @@ function loaded() {
({ target }) => { ({ target }) => {
if (!(target instanceof HTMLInputElement)) return; if (!(target instanceof HTMLInputElement)) return;
if (target.value && target.value.length > 0) { const checkedUsername = target.value;
if (checkedUsername && checkedUsername.length > 0) {
axios axios
.get('/api/v1/accounts/lookup', { params: { acct: target.value } }) .get('/api/v1/accounts/lookup', {
params: { acct: checkedUsername },
})
.then(() => { .then(() => {
target.setCustomValidity(formatMessage(messages.usernameTaken)); // Only update the validity if the result is for the currently-typed username
if (checkedUsername === target.value) {
target.setCustomValidity(formatMessage(messages.usernameTaken));
}
return true; return true;
}) })
.catch(() => { .catch(() => {
target.setCustomValidity(''); // Only update the validity if the result is for the currently-typed username
if (checkedUsername === target.value) {
target.setCustomValidity('');
}
}); });
} else { } else {
target.setCustomValidity(''); target.setCustomValidity('');

View File

@@ -20,18 +20,7 @@ export interface EmojiHTMLProps {
} }
export const EmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>( export const EmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
( ({ extraEmojis, htmlString, onElement, onAttribute, ...props }, ref) => {
{
extraEmojis,
htmlString,
as: asProp = 'div', // Rename for syntax highlighting
className,
onElement,
onAttribute,
...props
},
ref,
) => {
const contents = useMemo( const contents = useMemo(
() => () =>
htmlStringToComponents(htmlString, { htmlStringToComponents(htmlString, {
@@ -44,12 +33,7 @@ export const EmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
return ( return (
<CustomEmojiProvider emojis={extraEmojis}> <CustomEmojiProvider emojis={extraEmojis}>
<AnimateEmojiProvider <AnimateEmojiProvider {...props} ref={ref}>
{...props}
as={asProp}
className={className}
ref={ref}
>
{contents} {contents}
</AnimateEmojiProvider> </AnimateEmojiProvider>
</CustomEmojiProvider> </CustomEmojiProvider>

View File

@@ -23,7 +23,17 @@ export type MiniCardProps = OmitUnion<
export const MiniCard = forwardRef<HTMLDivElement, MiniCardProps>( export const MiniCard = forwardRef<HTMLDivElement, MiniCardProps>(
( (
{ label, value, className, hidden, icon, iconId, iconClassName, ...props }, {
label,
value,
className,
hidden,
icon,
iconId,
iconClassName,
children,
...props
},
ref, ref,
) => { ) => {
if (!label) { if (!label) {
@@ -50,6 +60,7 @@ export const MiniCard = forwardRef<HTMLDivElement, MiniCardProps>(
)} )}
<dt className={classes.label}>{label}</dt> <dt className={classes.label}>{label}</dt>
<dd className={classes.value}>{value}</dd> <dd className={classes.value}>{value}</dd>
{children}
</div> </div>
); );
}, },

View File

@@ -182,15 +182,25 @@ function loaded() {
({ target }) => { ({ target }) => {
if (!(target instanceof HTMLInputElement)) return; if (!(target instanceof HTMLInputElement)) return;
if (target.value && target.value.length > 0) { const checkedUsername = target.value;
if (checkedUsername && checkedUsername.length > 0) {
axios axios
.get('/api/v1/accounts/lookup', { params: { acct: target.value } }) .get('/api/v1/accounts/lookup', {
params: { acct: checkedUsername },
})
.then(() => { .then(() => {
target.setCustomValidity(formatMessage(messages.usernameTaken)); // Only update the validity if the result is for the currently-typed username
if (checkedUsername === target.value) {
target.setCustomValidity(formatMessage(messages.usernameTaken));
}
return true; return true;
}) })
.catch(() => { .catch(() => {
target.setCustomValidity(''); // Only update the validity if the result is for the currently-typed username
if (checkedUsername === target.value) {
target.setCustomValidity('');
}
}); });
} else { } else {
target.setCustomValidity(''); target.setCustomValidity('');

View File

@@ -1,125 +0,0 @@
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { useParams } from 'react-router';
import { AccountBio } from '@/flavours/glitch/components/account_bio';
import { Column } from '@/flavours/glitch/components/column';
import { ColumnBackButton } from '@/flavours/glitch/components/column_back_button';
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
import BundleColumnError from '@/flavours/glitch/features/ui/components/bundle_column_error';
import type { AccountId } from '@/flavours/glitch/hooks/useAccountId';
import { useAccountId } from '@/flavours/glitch/hooks/useAccountId';
import { useAccountVisibility } from '@/flavours/glitch/hooks/useAccountVisibility';
import { createAppSelector, useAppSelector } from '@/flavours/glitch/store';
import { AccountHeader } from '../account_timeline/components/account_header';
import { AccountHeaderFields } from '../account_timeline/components/fields';
import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint';
import classes from './styles.module.css';
const selectIsProfileEmpty = createAppSelector(
[(state) => state.accounts, (_, accountId: AccountId) => accountId],
(accounts, accountId) => {
// Null means still loading, otherwise it's a boolean.
if (!accountId) {
return null;
}
const account = accounts.get(accountId);
if (!account) {
return null;
}
return !account.note && !account.fields.size;
},
);
export const AccountAbout: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
const accountId = useAccountId();
const { blockedBy, hidden, suspended } = useAccountVisibility(accountId);
const forceEmptyState = blockedBy || hidden || suspended;
const isProfileEmpty = useAppSelector((state) =>
selectIsProfileEmpty(state, accountId),
);
if (accountId === null) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
}
if (!accountId || isProfileEmpty === null) {
return (
<Column bindToDocument={!multiColumn}>
<LoadingIndicator />
</Column>
);
}
const showEmptyMessage = forceEmptyState || isProfileEmpty;
return (
<Column bindToDocument={!multiColumn}>
<ColumnBackButton />
<div className='scrollable scrollable--flex'>
<AccountHeader accountId={accountId} hideTabs={forceEmptyState} />
<div className={classes.wrapper}>
{!showEmptyMessage ? (
<>
<AccountBio
accountId={accountId}
className={`${classes.bio} account__header__content`}
/>
<AccountHeaderFields accountId={accountId} />
</>
) : (
<div className='empty-column-indicator'>
<EmptyMessage accountId={accountId} />
</div>
)}
</div>
</div>
</Column>
);
};
const EmptyMessage: FC<{ accountId: string }> = ({ accountId }) => {
const { blockedBy, hidden, suspended } = useAccountVisibility(accountId);
const currentUserId = useAppSelector(
(state) => state.meta.get('me') as string | null,
);
const { acct } = useParams<{ acct?: string }>();
if (suspended) {
return (
<FormattedMessage
id='empty_column.account_suspended'
defaultMessage='Account suspended'
/>
);
} else if (hidden) {
return <LimitedAccountHint accountId={accountId} />;
} else if (blockedBy) {
return (
<FormattedMessage
id='empty_column.account_unavailable'
defaultMessage='Profile unavailable'
/>
);
} else if (accountId === currentUserId) {
return (
<FormattedMessage
id='empty_column.account_about.me'
defaultMessage='You have not added any information about yourself yet.'
/>
);
}
return (
<FormattedMessage
id='empty_column.account_about.other'
defaultMessage='{acct} has not added any information about themselves yet.'
values={{ acct }}
/>
);
};

View File

@@ -1,7 +0,0 @@
.wrapper {
padding: 16px;
}
.bio {
color: var(--color-text-primary);
}

View File

@@ -17,8 +17,15 @@ import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_col
import Column from 'flavours/glitch/features/ui/components/column'; import Column from 'flavours/glitch/features/ui/components/column';
import { useAccountId } from 'flavours/glitch/hooks/useAccountId'; import { useAccountId } from 'flavours/glitch/hooks/useAccountId';
import { useAccountVisibility } from 'flavours/glitch/hooks/useAccountVisibility'; import { useAccountVisibility } from 'flavours/glitch/hooks/useAccountVisibility';
import {
fetchAccountCollections,
selectAccountCollections,
} from 'flavours/glitch/reducers/slices/collections';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
import { CollectionListItem } from '../collections/detail/collection_list_item';
import { areCollectionsEnabled } from '../collections/utils';
import { EmptyMessage } from './components/empty_message'; import { EmptyMessage } from './components/empty_message';
import { FeaturedTag } from './components/featured_tag'; import { FeaturedTag } from './components/featured_tag';
import type { TagMap } from './components/featured_tag'; import type { TagMap } from './components/featured_tag';
@@ -42,6 +49,9 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
if (accountId) { if (accountId) {
void dispatch(fetchFeaturedTags({ accountId })); void dispatch(fetchFeaturedTags({ accountId }));
void dispatch(fetchEndorsedAccounts({ accountId })); void dispatch(fetchEndorsedAccounts({ accountId }));
if (areCollectionsEnabled()) {
void dispatch(fetchAccountCollections({ accountId }));
}
} }
}, [accountId, dispatch]); }, [accountId, dispatch]);
@@ -64,6 +74,14 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
ImmutableList(), ImmutableList(),
) as ImmutableList<string>, ) as ImmutableList<string>,
); );
const { collections, status } = useAppSelector((state) =>
selectAccountCollections(state, accountId ?? null),
);
const publicCollections = collections.filter(
// This filter only applies when viewing your own profile, where the endpoint
// returns all collections, but we hide unlisted ones here to avoid confusion
(item) => item.discoverable,
);
if (accountId === null) { if (accountId === null) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />; return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
@@ -101,6 +119,25 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
{accountId && ( {accountId && (
<AccountHeader accountId={accountId} hideTabs={forceEmptyState} /> <AccountHeader accountId={accountId} hideTabs={forceEmptyState} />
)} )}
{publicCollections.length > 0 && status === 'idle' && (
<>
<h4 className='column-subheading'>
<FormattedMessage
id='account.featured.collections'
defaultMessage='Collections'
/>
</h4>
<section>
{publicCollections.map((item, index) => (
<CollectionListItem
key={item.id}
collection={item}
withoutBorder={index === publicCollections.length - 1}
/>
))}
</section>
</>
)}
{!featuredTags.isEmpty() && ( {!featuredTags.isEmpty() && (
<> <>
<h4 className='column-subheading'> <h4 className='column-subheading'>

View File

@@ -1,5 +1,12 @@
import type { AccountFieldShape } from '@/flavours/glitch/models/account';
import { isServerFeatureEnabled } from '@/flavours/glitch/utils/environment'; import { isServerFeatureEnabled } from '@/flavours/glitch/utils/environment';
export function isRedesignEnabled() { export function isRedesignEnabled() {
return isServerFeatureEnabled('profile_redesign'); return isServerFeatureEnabled('profile_redesign');
} }
export interface AccountField extends AccountFieldShape {
nameHasEmojis: boolean;
value_plain: string;
valueHasEmojis: boolean;
}

View File

@@ -210,18 +210,14 @@ export const AccountHeader: React.FC<{
<AccountNote accountId={accountId} /> <AccountNote accountId={accountId} />
))} ))}
{(!isRedesign || layout === 'single-column') && ( <AccountBio
<> accountId={accountId}
<AccountBio className={classNames(
accountId={accountId} 'account__header__content',
className={classNames( isRedesign && redesignClasses.bio,
'account__header__content', )}
isRedesign && redesignClasses.bio, />
)} <AccountHeaderFields accountId={accountId} />
/>
<AccountHeaderFields accountId={accountId} />
</>
)}
</div> </div>
</div> </div>
)} )}

View File

@@ -1,28 +1,31 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import type { FC, Key } from 'react'; import type { FC } from 'react';
import { defineMessage, FormattedMessage, useIntl } from 'react-intl'; import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import htmlConfig from '@/config/html-tags.json'; import { openModal } from '@/flavours/glitch/actions/modal';
import { AccountFields } from '@/flavours/glitch/components/account_fields'; import { AccountFields } from '@/flavours/glitch/components/account_fields';
import { CustomEmojiProvider } from '@/flavours/glitch/components/emoji/context'; import { CustomEmojiProvider } from '@/flavours/glitch/components/emoji/context';
import type { EmojiHTMLProps } from '@/flavours/glitch/components/emoji/html'; import type { EmojiHTMLProps } from '@/flavours/glitch/components/emoji/html';
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html'; import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
import { FormattedDateWrapper } from '@/flavours/glitch/components/formatted_date'; import { FormattedDateWrapper } from '@/flavours/glitch/components/formatted_date';
import { Icon } from '@/flavours/glitch/components/icon'; import { Icon } from '@/flavours/glitch/components/icon';
import { IconButton } from '@/flavours/glitch/components/icon_button';
import { MiniCard } from '@/flavours/glitch/components/mini_card';
import { useElementHandledLink } from '@/flavours/glitch/components/status/handled_link'; import { useElementHandledLink } from '@/flavours/glitch/components/status/handled_link';
import { useAccount } from '@/flavours/glitch/hooks/useAccount'; import { useAccount } from '@/flavours/glitch/hooks/useAccount';
import type { import { useResizeObserver } from '@/flavours/glitch/hooks/useObserver';
Account, import type { Account } from '@/flavours/glitch/models/account';
AccountFieldShape, import { useAppDispatch } from '@/flavours/glitch/store';
} from '@/flavours/glitch/models/account';
import type { OnElementHandler } from '@/flavours/glitch/utils/html';
import IconVerified from '@/images/icons/icon_verified.svg?react'; import IconVerified from '@/images/icons/icon_verified.svg?react';
import MoreIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { cleanExtraEmojis } from '../../emoji/normalize'; import { cleanExtraEmojis } from '../../emoji/normalize';
import type { AccountField } from '../common';
import { isRedesignEnabled } from '../common'; import { isRedesignEnabled } from '../common';
import { useFieldHtml } from '../hooks/useFieldHtml';
import classes from './redesign.module.scss'; import classes from './redesign.module.scss';
@@ -77,172 +80,310 @@ const RedesignAccountHeaderFields: FC<{ account: Account }> = ({ account }) => {
() => cleanExtraEmojis(account.emojis), () => cleanExtraEmojis(account.emojis),
[account.emojis], [account.emojis],
); );
const textHasCustomEmoji = useCallback( const fields: AccountField[] = useMemo(() => {
(text?: string | null) => { const fields = account.fields.toJS();
if (!emojis || !text) { if (!emojis) {
return false; return fields.map((field) => ({
} ...field,
for (const emoji of Object.keys(emojis)) { nameHasEmojis: false,
if (text.includes(`:${emoji}:`)) { value_plain: field.value_plain ?? '',
return true; valueHasEmojis: false,
} }));
} }
return false;
}, const shortcodes = Object.keys(emojis);
[emojis], return fields.map((field) => ({
); ...field,
nameHasEmojis: shortcodes.some((code) =>
field.name.includes(`:${code}:`),
),
value_plain: field.value_plain ?? '',
valueHasEmojis: shortcodes.some((code) =>
field.value_plain?.includes(`:${code}:`),
),
}));
}, [account.fields, emojis]);
const htmlHandlers = useElementHandledLink({ const htmlHandlers = useElementHandledLink({
hashtagAccountId: account.id, hashtagAccountId: account.id,
}); });
if (account.fields.isEmpty()) { const { wrapperRef } = useColumnWrap();
if (fields.length === 0) {
return null; return null;
} }
return ( return (
<CustomEmojiProvider emojis={emojis}> <CustomEmojiProvider emojis={emojis}>
<dl className={classes.fieldList}> <dl className={classes.fieldList} ref={wrapperRef}>
{account.fields.map((field, key) => ( {fields.map((field, key) => (
<FieldRow <FieldRow key={key} field={field} htmlHandlers={htmlHandlers} />
key={key}
{...field.toJSON()}
htmlHandlers={htmlHandlers}
textHasCustomEmoji={textHasCustomEmoji}
/>
))} ))}
</dl> </dl>
</CustomEmojiProvider> </CustomEmojiProvider>
); );
}; };
const FieldRow: FC< const FieldRow: FC<{
{ htmlHandlers: ReturnType<typeof useElementHandledLink>;
textHasCustomEmoji: (text?: string | null) => boolean; field: AccountField;
htmlHandlers: ReturnType<typeof useElementHandledLink>; }> = ({ htmlHandlers, field }) => {
} & AccountFieldShape
> = ({
textHasCustomEmoji,
htmlHandlers,
name,
name_emojified,
value_emojified,
value_plain,
verified_at,
}) => {
const intl = useIntl(); const intl = useIntl();
const [showAll, setShowAll] = useState(false); const {
const handleClick = useCallback(() => { name,
setShowAll((prev) => !prev); name_emojified,
}, []); nameHasEmojis,
value_emojified,
value_plain,
valueHasEmojis,
verified_at,
} = field;
const { wrapperRef, isLabelOverflowing, isValueOverflowing } =
useFieldOverflow();
const dispatch = useAppDispatch();
const handleOverflowClick = useCallback(() => {
dispatch(
openModal({
modalType: 'ACCOUNT_FIELD_OVERFLOW',
modalProps: { field },
}),
);
}, [dispatch, field]);
return ( return (
/* eslint-disable -- This method of showing field contents is not very accessible, but it's what we've got for now */ <MiniCard
<div
className={classNames( className={classNames(
classes.fieldRow, classes.fieldItem,
verified_at && classes.fieldVerified, verified_at && classes.fieldVerified,
showAll && classes.fieldShowAll,
)} )}
onClick={handleClick} label={
/* eslint-enable */
>
<FieldHTML
as='dt'
text={name}
textEmojified={name_emojified}
textHasCustomEmoji={textHasCustomEmoji(name)}
titleLength={50}
className='translate'
{...htmlHandlers}
/>
<dd>
<FieldHTML <FieldHTML
as='span' text={name}
text={value_plain ?? ''} textEmojified={name_emojified}
textEmojified={value_emojified} textHasCustomEmoji={nameHasEmojis}
textHasCustomEmoji={textHasCustomEmoji(value_plain ?? '')} className='translate'
titleLength={120} isOverflowing={isLabelOverflowing}
onOverflowClick={handleOverflowClick}
{...htmlHandlers} {...htmlHandlers}
/> />
}
{verified_at && ( value={
<Icon <FieldHTML
id='verified' text={value_plain}
icon={IconVerified} textEmojified={value_emojified}
className={classes.fieldVerifiedIcon} textHasCustomEmoji={valueHasEmojis}
aria-label={intl.formatMessage(verifyMessage, { isOverflowing={isValueOverflowing}
date: intl.formatDate(verified_at, dateFormatOptions), onOverflowClick={handleOverflowClick}
})} {...htmlHandlers}
noFill />
/> }
)} ref={wrapperRef}
</dd> >
</div> {verified_at && (
<Icon
id='verified'
icon={IconVerified}
className={classes.fieldVerifiedIcon}
aria-label={intl.formatMessage(verifyMessage, {
date: intl.formatDate(verified_at, dateFormatOptions),
})}
noFill
/>
)}
</MiniCard>
); );
}; };
const FieldHTML: FC< type FieldHTMLProps = {
{ text: string;
as?: 'span' | 'dt'; textEmojified: string;
text: string; textHasCustomEmoji: boolean;
textEmojified: string; isOverflowing?: boolean;
textHasCustomEmoji: boolean; onOverflowClick?: () => void;
titleLength: number; } & Omit<EmojiHTMLProps, 'htmlString'>;
} & Omit<EmojiHTMLProps, 'htmlString'>
> = ({ const FieldHTML: FC<FieldHTMLProps> = ({
as,
className, className,
extraEmojis, extraEmojis,
text, text,
textEmojified, textEmojified,
textHasCustomEmoji, textHasCustomEmoji,
titleLength, isOverflowing,
onOverflowClick,
onElement, onElement,
...props ...props
}) => { }) => {
const handleElement: OnElementHandler = useCallback( const intl = useIntl();
(element, props, children, extra) => { const handleElement = useFieldHtml(textHasCustomEmoji, onElement);
if (element instanceof HTMLAnchorElement) {
// Don't allow custom emoji and links in the same field to prevent verification spoofing.
if (textHasCustomEmoji) {
return (
<span {...filterAttributesForSpan(props)} key={props.key as Key}>
{children}
</span>
);
}
return onElement?.(element, props, children, extra);
}
return undefined;
},
[onElement, textHasCustomEmoji],
);
return ( const html = (
<EmojiHTML <EmojiHTML
as={as} as='span'
htmlString={textEmojified} htmlString={textEmojified}
title={showTitleOnLength(text, titleLength)}
className={className} className={className}
onElement={handleElement} onElement={handleElement}
data-contents
{...props} {...props}
/> />
); );
if (!isOverflowing) {
return html;
}
return (
<>
{html}
<IconButton
icon='ellipsis'
iconComponent={MoreIcon}
title={intl.formatMessage({
id: 'account.field_overflow',
defaultMessage: 'Show full content',
})}
className={classes.fieldOverflowButton}
onClick={onOverflowClick}
/>
</>
);
}; };
function filterAttributesForSpan(props: Record<string, unknown>) { function useColumnWrap() {
const validAttributes: Record<string, unknown> = {}; const listRef = useRef<HTMLDListElement | null>(null);
for (const key of Object.keys(props)) {
if (key in htmlConfig.tags.span.attributes) { const handleRecalculate = useCallback(() => {
validAttributes[key] = props[key]; const listEle = listRef.current;
if (!listEle) {
return;
} }
}
return validAttributes; // Calculate dimensions from styles and element size to determine column spans.
const styles = getComputedStyle(listEle);
const gap = parseFloat(styles.columnGap || styles.gap || '0');
const columnCount = parseInt(styles.getPropertyValue('--cols')) || 2;
const listWidth = listEle.offsetWidth;
const colWidth = (listWidth - gap * (columnCount - 1)) / columnCount;
// Matrix to hold the grid layout.
const itemGrid: { ele: HTMLElement; span: number }[][] = [];
// First, determine the column span for each item and populate the grid matrix.
let currentRow = 0;
for (const child of listEle.children) {
if (!(child instanceof HTMLElement)) {
continue;
}
// This uses a data attribute to detect which elements to measure that overflow.
const contents = child.querySelectorAll('[data-contents]');
const childStyles = getComputedStyle(child);
const padding =
parseFloat(childStyles.paddingLeft) +
parseFloat(childStyles.paddingRight);
const contentWidth =
Math.max(
...Array.from(contents).map((content) => content.scrollWidth),
) + padding;
const contentSpan = Math.ceil(contentWidth / colWidth);
const maxColSpan = Math.min(contentSpan, columnCount);
const curRow = itemGrid[currentRow] ?? [];
const availableCols =
columnCount - curRow.reduce((carry, curr) => carry + curr.span, 0);
// Move to next row if current item doesn't fit.
if (maxColSpan > availableCols) {
currentRow++;
}
itemGrid[currentRow] = (itemGrid[currentRow] ?? []).concat({
ele: child,
span: maxColSpan,
});
}
// Next, iterate through the grid matrix and set the column spans and row breaks.
for (const row of itemGrid) {
let remainingRowSpan = columnCount;
for (let i = 0; i < row.length; i++) {
const item = row[i];
if (!item) {
break;
}
const { ele, span } = item;
if (i < row.length - 1) {
ele.dataset.cols = span.toString();
remainingRowSpan -= span;
} else {
// Last item in the row takes up remaining space to fill the row.
ele.dataset.cols = remainingRowSpan.toString();
break;
}
}
}
}, []);
const observer = useResizeObserver(handleRecalculate);
const wrapperRefCallback = useCallback(
(element: HTMLDListElement | null) => {
if (element) {
listRef.current = element;
observer.observe(element);
}
},
[observer],
);
return { wrapperRef: wrapperRefCallback };
} }
function showTitleOnLength(value: string | null, maxLength: number) { function useFieldOverflow() {
if (value && value.length > maxLength) { const [isLabelOverflowing, setIsLabelOverflowing] = useState(false);
return value; const [isValueOverflowing, setIsValueOverflowing] = useState(false);
}
return undefined; const wrapperRef = useRef<HTMLElement | null>(null);
const handleRecalculate = useCallback(() => {
const wrapperEle = wrapperRef.current;
if (!wrapperEle) return;
const wrapperStyles = getComputedStyle(wrapperEle);
const maxWidth =
wrapperEle.offsetWidth -
(parseFloat(wrapperStyles.paddingLeft) +
parseFloat(wrapperStyles.paddingRight));
const label = wrapperEle.querySelector<HTMLSpanElement>(
'dt > [data-contents]',
);
const value = wrapperEle.querySelector<HTMLSpanElement>(
'dd > [data-contents]',
);
setIsLabelOverflowing(label ? label.scrollWidth > maxWidth : false);
setIsValueOverflowing(value ? value.scrollWidth > maxWidth : false);
}, []);
const observer = useResizeObserver(handleRecalculate);
const wrapperRefCallback = useCallback(
(element: HTMLElement | null) => {
if (element) {
wrapperRef.current = element;
observer.observe(element);
}
},
[observer],
);
return {
isLabelOverflowing,
isValueOverflowing,
wrapperRef: wrapperRefCallback,
};
} }

View File

@@ -214,64 +214,90 @@ svg.badgeIcon {
} }
.fieldList { .fieldList {
--cols: 4;
position: relative;
display: grid; display: grid;
grid-template-columns: 160px 1fr; grid-template-columns: repeat(var(--cols), 1fr);
column-gap: 12px; gap: 4px;
margin: 16px 0; margin: 16px 0;
border-top: 0.5px solid var(--color-border-primary);
@container (width < 420px) { @container (width < 420px) {
grid-template-columns: 100px 1fr; --cols: 2;
} }
} }
.fieldRow { .fieldItem {
display: grid; --col-span: 1;
grid-column: 1 / -1;
align-items: start;
grid-template-columns: subgrid;
padding: 8px;
border-bottom: 0.5px solid var(--color-border-primary);
> :is(dt, dd) { grid-column: span var(--col-span);
&:not(.fieldShowAll) { position: relative;
display: -webkit-box;
-webkit-box-orient: vertical; @for $col from 2 through 4 {
-webkit-line-clamp: 2; &[data-cols='#{$col}'] {
line-clamp: 2; --col-span: #{$col};
overflow: hidden;
text-overflow: ellipsis;
} }
} }
> dt { dt {
color: var(--color-text-secondary); font-weight: normal;
} }
> dd { dd {
display: flex; font-weight: 500;
align-items: center;
gap: 4px;
} }
a { :is(dt, dd) {
color: inherit; text-overflow: initial;
text-decoration: none;
&:hover, // Override the MiniCard link styles
&:focus { a {
text-decoration: underline; color: inherit;
font-weight: inherit;
&:hover,
&:focus {
color: inherit;
text-decoration: underline;
}
} }
} }
} }
.fieldVerified { .fieldVerified {
background-color: var(--color-bg-success-softer); background-color: var(--color-bg-success-softer);
dt {
padding-right: 24px;
}
} }
.fieldVerifiedIcon { .fieldVerifiedIcon {
width: 16px; width: 16px;
height: 16px; height: 16px;
position: absolute;
top: 8px;
right: 8px;
}
.fieldOverflowButton {
--default-bg-color: var(--color-bg-secondary-solid);
--hover-bg-color: color-mix(
in oklab,
var(--color-bg-brand-base),
var(--default-bg-color) var(--overlay-strength-brand)
);
position: absolute;
right: 8px;
padding: 0 2px;
transition: background-color 0.2s ease-in-out;
border: 2px solid var(--color-bg-primary);
> svg {
width: 16px;
height: 12px;
}
} }
.fieldNumbersWrapper { .fieldNumbersWrapper {

View File

@@ -5,23 +5,15 @@ import { FormattedMessage } from 'react-intl';
import type { NavLinkProps } from 'react-router-dom'; import type { NavLinkProps } from 'react-router-dom';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { useLayout } from '@/flavours/glitch/hooks/useLayout';
import { isRedesignEnabled } from '../common'; import { isRedesignEnabled } from '../common';
import classes from './redesign.module.scss'; import classes from './redesign.module.scss';
export const AccountTabs: FC<{ acct: string }> = ({ acct }) => { export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
const { layout } = useLayout();
if (isRedesignEnabled()) { if (isRedesignEnabled()) {
return ( return (
<div className={classes.tabs}> <div className={classes.tabs}>
{layout !== 'single-column' && ( <NavLink isActive={isActive} to={`/@${acct}`}>
<NavLink exact to={`/@${acct}/about`}>
<FormattedMessage id='account.about' defaultMessage='About' />
</NavLink>
)}
<NavLink isActive={isActive} to={`/@${acct}/posts`}>
<FormattedMessage id='account.activity' defaultMessage='Activity' /> <FormattedMessage id='account.activity' defaultMessage='Activity' />
</NavLink> </NavLink>
<NavLink exact to={`/@${acct}/media`}> <NavLink exact to={`/@${acct}/media`}>

View File

@@ -0,0 +1,38 @@
import type { Key } from 'react';
import { useCallback } from 'react';
import htmlConfig from '@/config/html-tags.json';
import type { OnElementHandler } from '@/flavours/glitch/utils/html';
export function useFieldHtml(
hasCustomEmoji: boolean,
onElement?: OnElementHandler,
): OnElementHandler {
return useCallback(
(element, props, children, extra) => {
if (element instanceof HTMLAnchorElement) {
// Don't allow custom emoji and links in the same field to prevent verification spoofing.
if (hasCustomEmoji) {
return (
<span {...filterAttributesForSpan(props)} key={props.key as Key}>
{children}
</span>
);
}
return onElement?.(element, props, children, extra);
}
return undefined;
},
[onElement, hasCustomEmoji],
);
}
function filterAttributesForSpan(props: Record<string, unknown>) {
const validAttributes: Record<string, unknown> = {};
for (const key of Object.keys(props)) {
if (key in htmlConfig.tags.span.attributes) {
validAttributes[key] = props[key];
}
}
return validAttributes;
}

View File

@@ -0,0 +1,44 @@
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
import type { AccountField } from '../common';
import { useFieldHtml } from '../hooks/useFieldHtml';
import classes from './styles.module.css';
export const AccountFieldModal: FC<{
onClose: () => void;
field: AccountField;
}> = ({ onClose, field }) => {
const handleLabelElement = useFieldHtml(field.nameHasEmojis);
const handleValueElement = useFieldHtml(field.valueHasEmojis);
return (
<div className='modal-root__modal safety-action-modal'>
<div className='safety-action-modal__top'>
<div className='safety-action-modal__confirmation'>
<EmojiHTML
as='p'
htmlString={field.name_emojified}
onElement={handleLabelElement}
/>
<EmojiHTML
as='p'
htmlString={field.value_emojified}
onElement={handleValueElement}
className={classes.fieldValue}
/>
</div>
</div>
<div className='safety-action-modal__bottom'>
<div className='safety-action-modal__actions'>
<button onClick={onClose} className='link-button' type='button'>
<FormattedMessage id='lightbox.close' defaultMessage='Close' />
</button>
</div>
</div>
</div>
);
};

View File

@@ -13,7 +13,7 @@ import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
import { ConfirmationModal } from '../../ui/components/confirmation_modals'; import { ConfirmationModal } from '../../ui/components/confirmation_modals';
import classes from './modals.module.css'; import classes from './styles.module.css';
const messages = defineMessages({ const messages = defineMessages({
newTitle: { newTitle: {

View File

@@ -19,3 +19,9 @@
outline: var(--outline-focus-default); outline: var(--outline-focus-default);
outline-offset: 2px; outline-offset: 2px;
} }
.fieldValue {
color: var(--color-text-primary);
font-weight: 600;
margin-top: 4px;
}

View File

@@ -2,15 +2,17 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
margin-inline: 10px; padding-inline: 16px;
padding-inline-end: 5px;
border-bottom: 1px solid var(--color-border-primary); &:not(.wrapperWithoutBorder) {
border-bottom: 1px solid var(--color-border-primary);
}
} }
.content { .content {
position: relative; position: relative;
flex-grow: 1; flex-grow: 1;
padding: 15px 5px; padding-block: 15px;
} }
.link { .link {

View File

@@ -67,13 +67,18 @@ export const CollectionMetaData: React.FC<{
export const CollectionListItem: React.FC<{ export const CollectionListItem: React.FC<{
collection: ApiCollectionJSON; collection: ApiCollectionJSON;
}> = ({ collection }) => { withoutBorder?: boolean;
}> = ({ collection, withoutBorder }) => {
const { id, name } = collection; const { id, name } = collection;
const linkId = useId(); const linkId = useId();
return ( return (
<article <article
className={classNames(classes.wrapper, 'focusable')} className={classNames(
classes.wrapper,
'focusable',
withoutBorder && classes.wrapperWithoutBorder,
)}
tabIndex={-1} tabIndex={-1}
aria-labelledby={linkId} aria-labelledby={linkId}
> >

View File

@@ -2,11 +2,16 @@ import { useCallback, useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { matchPath } from 'react-router';
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
import MoreVertIcon from '@/material-icons/400-24px/more_vert.svg?react'; import MoreVertIcon from '@/material-icons/400-24px/more_vert.svg?react';
import { openModal } from 'flavours/glitch/actions/modal'; import { openModal } from 'flavours/glitch/actions/modal';
import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections'; import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
import { Dropdown } from 'flavours/glitch/components/dropdown_menu'; import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
import { IconButton } from 'flavours/glitch/components/icon_button'; import { IconButton } from 'flavours/glitch/components/icon_button';
import { me } from 'flavours/glitch/initial_state';
import type { MenuItem } from 'flavours/glitch/models/dropdown_menu';
import { useAppDispatch } from 'flavours/glitch/store'; import { useAppDispatch } from 'flavours/glitch/store';
import { messages as editorMessages } from '../editor'; import { messages as editorMessages } from '../editor';
@@ -16,10 +21,18 @@ const messages = defineMessages({
id: 'collections.view_collection', id: 'collections.view_collection',
defaultMessage: 'View collection', defaultMessage: 'View collection',
}, },
viewOtherCollections: {
id: 'collections.view_other_collections_by_user',
defaultMessage: 'View other collections by this user',
},
delete: { delete: {
id: 'collections.delete_collection', id: 'collections.delete_collection',
defaultMessage: 'Delete collection', defaultMessage: 'Delete collection',
}, },
report: {
id: 'collections.report_collection',
defaultMessage: 'Report this collection',
},
more: { id: 'status.more', defaultMessage: 'More' }, more: { id: 'status.more', defaultMessage: 'More' },
}); });
@@ -31,9 +44,11 @@ export const CollectionMenu: React.FC<{
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const intl = useIntl(); const intl = useIntl();
const { id, name } = collection; const { id, name, account_id } = collection;
const isOwnCollection = account_id === me;
const ownerAccount = useAccount(account_id);
const handleDeleteClick = useCallback(() => { const openDeleteConfirmation = useCallback(() => {
dispatch( dispatch(
openModal({ openModal({
modalType: 'CONFIRM_DELETE_COLLECTION', modalType: 'CONFIRM_DELETE_COLLECTION',
@@ -45,34 +60,83 @@ export const CollectionMenu: React.FC<{
); );
}, [dispatch, id, name]); }, [dispatch, id, name]);
const menu = useMemo(() => { const openReportModal = useCallback(() => {
const commonItems = [ dispatch(
{ openModal({
text: intl.formatMessage(editorMessages.manageAccounts), modalType: 'REPORT_COLLECTION',
to: `/collections/${id}/edit`, modalProps: {
}, collection,
{ },
text: intl.formatMessage(editorMessages.editDetails), }),
to: `/collections/${id}/edit/details`, );
}, }, [collection, dispatch]);
null,
{
text: intl.formatMessage(messages.delete),
action: handleDeleteClick,
dangerous: true,
},
];
if (context === 'list') { const menu = useMemo(() => {
return [ if (isOwnCollection) {
{ text: intl.formatMessage(messages.view), to: `/collections/${id}` }, const commonItems: MenuItem[] = [
{
text: intl.formatMessage(editorMessages.manageAccounts),
to: `/collections/${id}/edit`,
},
{
text: intl.formatMessage(editorMessages.editDetails),
to: `/collections/${id}/edit/details`,
},
null, null,
...commonItems, {
text: intl.formatMessage(messages.delete),
action: openDeleteConfirmation,
dangerous: true,
},
]; ];
if (context === 'list') {
return [
{ text: intl.formatMessage(messages.view), to: `/collections/${id}` },
null,
...commonItems,
];
} else {
return commonItems;
}
} else if (ownerAccount) {
const items: MenuItem[] = [
{
text: intl.formatMessage(messages.report),
action: openReportModal,
},
];
const featuredCollectionsPath = `/@${ownerAccount.acct}/featured`;
// Don't show menu link to featured collections while on that very page
if (
!matchPath(location.pathname, {
path: featuredCollectionsPath,
exact: true,
})
) {
items.unshift(
...[
{
text: intl.formatMessage(messages.viewOtherCollections),
to: featuredCollectionsPath,
},
null,
],
);
}
return items;
} else { } else {
return commonItems; return [];
} }
}, [intl, id, handleDeleteClick, context]); }, [
isOwnCollection,
intl,
id,
openDeleteConfirmation,
context,
ownerAccount,
openReportModal,
]);
return ( return (
<Dropdown scrollKey='collections' items={menu}> <Dropdown scrollKey='collections' items={menu}>

View File

@@ -5,6 +5,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { useRelationship } from '@/flavours/glitch/hooks/useRelationship';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import ShareIcon from '@/material-icons/400-24px/share.svg?react'; import ShareIcon from '@/material-icons/400-24px/share.svg?react';
import { showAlert } from 'flavours/glitch/actions/alerts'; import { showAlert } from 'flavours/glitch/actions/alerts';
@@ -79,7 +80,7 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
collection, collection,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const { name, description, tag } = collection; const { name, description, tag, account_id } = collection;
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const handleShare = useCallback(() => { const handleShare = useCallback(() => {
@@ -114,7 +115,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 extended={account_id === me}
collection={collection} collection={collection}
className={classes.metaData} className={classes.metaData}
/> />
@@ -123,6 +124,28 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
); );
}; };
const CollectionAccountItem: React.FC<{
accountId: string | undefined;
collectionOwnerId: string;
}> = ({ accountId, collectionOwnerId }) => {
const relationship = useRelationship(accountId);
if (!accountId) {
return null;
}
// When viewing your own collection, only show the Follow button
// for accounts you're not following (anymore).
// Otherwise, always show the follow button in its various states.
const withoutButton =
accountId === me ||
!relationship ||
(collectionOwnerId === me &&
(relationship.following || relationship.requested));
return <Account minimal={withoutButton} withMenu={false} id={accountId} />;
};
export const CollectionDetailPage: React.FC<{ export const CollectionDetailPage: React.FC<{
multiColumn?: boolean; multiColumn?: boolean;
}> = ({ multiColumn }) => { }> = ({ multiColumn }) => {
@@ -163,11 +186,13 @@ export const CollectionDetailPage: React.FC<{
collection ? <CollectionHeader collection={collection} /> : null collection ? <CollectionHeader collection={collection} /> : null
} }
> >
{collection?.items.map(({ account_id }) => {collection?.items.map(({ account_id }) => (
account_id ? ( <CollectionAccountItem
<Account key={account_id} minimal id={account_id} /> key={account_id}
) : null, accountId={account_id}
)} collectionOwnerId={collection.account_id}
/>
))}
</ScrollableList> </ScrollableList>
<Helmet> <Helmet>

View File

@@ -14,7 +14,7 @@ import { Icon } from 'flavours/glitch/components/icon';
import ScrollableList from 'flavours/glitch/components/scrollable_list'; import ScrollableList from 'flavours/glitch/components/scrollable_list';
import { import {
fetchAccountCollections, fetchAccountCollections,
selectMyCollections, selectAccountCollections,
} from 'flavours/glitch/reducers/slices/collections'; } from 'flavours/glitch/reducers/slices/collections';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store'; import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
@@ -31,7 +31,9 @@ export const Collections: React.FC<{
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const intl = useIntl(); const intl = useIntl();
const me = useAppSelector((state) => state.meta.get('me') as string); const me = useAppSelector((state) => state.meta.get('me') as string);
const { collections, status } = useAppSelector(selectMyCollections); const { collections, status } = useAppSelector((state) =>
selectAccountCollections(state, me),
);
useEffect(() => { useEffect(() => {
void dispatch(fetchAccountCollections({ accountId: me })); void dispatch(fetchAccountCollections({ accountId: me }));

View File

@@ -1,121 +0,0 @@
import PropTypes from 'prop-types';
import { useCallback, useEffect, useRef } from 'react';
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import { createSelector } from '@reduxjs/toolkit';
import { OrderedSet, List as ImmutableList } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { shallowEqual } from 'react-redux';
import Toggle from 'react-toggle';
import { fetchAccount } from 'flavours/glitch/actions/accounts';
import { Button } from 'flavours/glitch/components/button';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
const messages = defineMessages({
placeholder: { id: 'report.placeholder', defaultMessage: 'Type or paste additional comments' },
});
const selectRepliedToAccountIds = createSelector(
[
(state) => state.get('statuses'),
(_, statusIds) => statusIds,
],
(statusesMap, statusIds) => statusIds.map((statusId) => statusesMap.getIn([statusId, 'in_reply_to_account_id'])),
{
resultEqualityCheck: shallowEqual,
}
);
const Comment = ({ comment, domain, statusIds, isRemote, isSubmitting, selectedDomains, onSubmit, onChangeComment, onToggleDomain }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const loadedRef = useRef(false);
const handleClick = useCallback(() => onSubmit(), [onSubmit]);
const handleChange = useCallback((e) => onChangeComment(e.target.value), [onChangeComment]);
const handleToggleDomain = useCallback(e => onToggleDomain(e.target.value, e.target.checked), [onToggleDomain]);
const handleKeyDown = useCallback((e) => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
handleClick();
}
}, [handleClick]);
// Memoize accountIds since we don't want it to trigger `useEffect` on each render
const accountIds = useAppSelector((state) => domain ? selectRepliedToAccountIds(state, statusIds) : ImmutableList());
// While we could memoize `availableDomains`, it is pretty inexpensive to recompute
const accountsMap = useAppSelector((state) => state.get('accounts'));
const availableDomains = domain ? OrderedSet([domain]).union(accountIds.map((accountId) => accountsMap.getIn([accountId, 'acct'], '').split('@')[1]).filter(domain => !!domain)) : OrderedSet();
useEffect(() => {
if (loadedRef.current) {
return;
}
loadedRef.current = true;
// First, pre-select known domains
availableDomains.forEach((domain) => {
onToggleDomain(domain, true);
});
// Then, fetch missing replied-to accounts
const unknownAccounts = OrderedSet(accountIds.filter(accountId => accountId && !accountsMap.has(accountId)));
unknownAccounts.forEach((accountId) => {
dispatch(fetchAccount(accountId));
});
});
return (
<>
<h3 className='report-dialog-modal__title'><FormattedMessage id='report.comment.title' defaultMessage='Is there anything else you think we should know?' /></h3>
<textarea
className='report-dialog-modal__textarea'
placeholder={intl.formatMessage(messages.placeholder)}
value={comment}
onChange={handleChange}
onKeyDown={handleKeyDown}
disabled={isSubmitting}
/>
{isRemote && (
<>
<p className='report-dialog-modal__lead'><FormattedMessage id='report.forward_hint' defaultMessage='The account is from another server. Send an anonymized copy of the report there as well?' /></p>
{ availableDomains.map((domain) => (
<label className='report-dialog-modal__toggle' key={`toggle-${domain}`}>
<Toggle checked={selectedDomains.includes(domain)} disabled={isSubmitting} onChange={handleToggleDomain} value={domain} />
<FormattedMessage id='report.forward' defaultMessage='Forward to {target}' values={{ target: domain }} />
</label>
))}
</>
)}
<div className='flex-spacer' />
<div className='report-dialog-modal__actions'>
<Button onClick={handleClick} disabled={isSubmitting}><FormattedMessage id='report.submit' defaultMessage='Submit report' /></Button>
</div>
</>
);
};
Comment.propTypes = {
comment: PropTypes.string.isRequired,
domain: PropTypes.string,
statusIds: ImmutablePropTypes.list.isRequired,
isRemote: PropTypes.bool,
isSubmitting: PropTypes.bool,
selectedDomains: ImmutablePropTypes.set.isRequired,
onSubmit: PropTypes.func.isRequired,
onChangeComment: PropTypes.func.isRequired,
onToggleDomain: PropTypes.func.isRequired,
};
export default Comment;

View File

@@ -0,0 +1,217 @@
import { useCallback, useEffect, useId, useRef } from 'react';
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import type { Map } from 'immutable';
import { OrderedSet } from 'immutable';
import { shallowEqual } from 'react-redux';
import Toggle from 'react-toggle';
import { fetchAccount } from 'flavours/glitch/actions/accounts';
import { Button } from 'flavours/glitch/components/button';
import type { Status } from 'flavours/glitch/models/status';
import type { RootState } from 'flavours/glitch/store';
import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from 'flavours/glitch/store';
const messages = defineMessages({
placeholder: {
id: 'report.placeholder',
defaultMessage: 'Type or paste additional comments',
},
});
const selectRepliedToAccountIds = createAppSelector(
[
(state: RootState) => state.statuses,
(_: unknown, statusIds: string[]) => statusIds,
],
(statusesMap: Map<string, Status>, statusIds: string[]) =>
statusIds.map(
(statusId) =>
statusesMap.getIn([statusId, 'in_reply_to_account_id']) as string,
),
{
memoizeOptions: {
resultEqualityCheck: shallowEqual,
},
},
);
interface Props {
modalTitle?: React.ReactNode;
comment: string;
domain?: string;
statusIds: string[];
isRemote?: boolean;
isSubmitting?: boolean;
selectedDomains: string[];
submitError?: React.ReactNode;
onSubmit: () => void;
onChangeComment: (newComment: string) => void;
onToggleDomain: (toggledDomain: string, checked: boolean) => void;
}
const Comment: React.FC<Props> = ({
modalTitle,
comment,
domain,
statusIds,
isRemote,
isSubmitting,
selectedDomains,
submitError,
onSubmit,
onChangeComment,
onToggleDomain,
}) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const loadedRef = useRef(false);
const handleSubmit = useCallback(() => {
onSubmit();
}, [onSubmit]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChangeComment(e.target.value);
},
[onChangeComment],
);
const handleToggleDomain = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onToggleDomain(e.target.value, e.target.checked);
},
[onToggleDomain],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
handleSubmit();
}
},
[handleSubmit],
);
// Memoize accountIds since we don't want it to trigger `useEffect` on each render
const accountIds = useAppSelector((state) =>
domain ? selectRepliedToAccountIds(state, statusIds) : [],
);
// While we could memoize `availableDomains`, it is pretty inexpensive to recompute
const accountsMap = useAppSelector((state) => state.accounts);
const availableDomains = domain
? OrderedSet([domain]).union(
accountIds
.map(
(accountId) =>
(accountsMap.getIn([accountId, 'acct'], '') as string).split(
'@',
)[1],
)
.filter((domain): domain is string => !!domain),
)
: OrderedSet<string>();
useEffect(() => {
if (loadedRef.current) {
return;
}
loadedRef.current = true;
// First, pre-select known domains
availableDomains.forEach((domain) => {
onToggleDomain(domain, true);
});
// Then, fetch missing replied-to accounts
const unknownAccounts = OrderedSet(
accountIds.filter(
(accountId) => accountId && !accountsMap.has(accountId),
),
);
unknownAccounts.forEach((accountId) => {
dispatch(fetchAccount(accountId));
});
});
const titleId = useId();
return (
<>
<h3 className='report-dialog-modal__title' id={titleId}>
{modalTitle ?? (
<FormattedMessage
id='report.comment.title'
defaultMessage='Is there anything else you think we should know?'
/>
)}
</h3>
<textarea
className='report-dialog-modal__textarea'
placeholder={intl.formatMessage(messages.placeholder)}
aria-labelledby={titleId}
value={comment}
onChange={handleChange}
onKeyDown={handleKeyDown}
disabled={isSubmitting}
/>
{isRemote && (
<>
<p className='report-dialog-modal__lead'>
<FormattedMessage
id='report.forward_hint'
defaultMessage='The account is from another server. Send an anonymized copy of the report there as well?'
/>
</p>
{availableDomains.map((domain) => (
<label
className='report-dialog-modal__toggle'
key={`toggle-${domain}`}
htmlFor={`input-${domain}`}
>
<Toggle
checked={selectedDomains.includes(domain)}
disabled={isSubmitting}
onChange={handleToggleDomain}
value={domain}
id={`input-${domain}`}
/>
<FormattedMessage
id='report.forward'
defaultMessage='Forward to {target}'
values={{ target: domain }}
/>
</label>
))}
</>
)}
{submitError}
<div className='flex-spacer' />
<div className='report-dialog-modal__actions'>
<Button onClick={handleSubmit} disabled={isSubmitting}>
<FormattedMessage id='report.submit' defaultMessage='Submit report' />
</Button>
</div>
</>
);
};
// eslint-disable-next-line import/no-default-export
export default Comment;

View File

@@ -10,6 +10,7 @@ import {
BlockModal, BlockModal,
DomainBlockModal, DomainBlockModal,
ReportModal, ReportModal,
ReportCollectionModal,
SettingsModal, SettingsModal,
EmbedModal, EmbedModal,
ListAdder, ListAdder,
@@ -83,6 +84,7 @@ export const MODAL_COMPONENTS = {
'BLOCK': BlockModal, 'BLOCK': BlockModal,
'DOMAIN_BLOCK': DomainBlockModal, 'DOMAIN_BLOCK': DomainBlockModal,
'REPORT': ReportModal, 'REPORT': ReportModal,
'REPORT_COLLECTION': ReportCollectionModal,
'SETTINGS': SettingsModal, 'SETTINGS': SettingsModal,
'DEPRECATED_SETTINGS': () => Promise.resolve({ default: DeprecatedSettingsModal }), 'DEPRECATED_SETTINGS': () => Promise.resolve({ default: DeprecatedSettingsModal }),
'ACTIONS': () => Promise.resolve({ default: ActionsModal }), 'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
@@ -98,6 +100,7 @@ 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_FIELD_OVERFLOW': () => import('@/flavours/glitch/features/account_timeline/modals/field_modal').then(module => ({ default: module.AccountFieldModal })),
'ACCOUNT_EDIT_NAME': () => import('@/flavours/glitch/features/account_edit/components/name_modal').then(module => ({ default: module.NameModal })), '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 })), 'ACCOUNT_EDIT_BIO': () => import('@/flavours/glitch/features/account_edit/components/bio_modal').then(module => ({ default: module.BioModal })),
}; };

View File

@@ -0,0 +1,173 @@
import { useCallback, useEffect, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Callout } from '@/flavours/glitch/components/callout';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { submitReport } from 'flavours/glitch/actions/reports';
import { fetchServer } from 'flavours/glitch/actions/server';
import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
import { Button } from 'flavours/glitch/components/button';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { useAccount } from 'flavours/glitch/hooks/useAccount';
import { useAppDispatch } from 'flavours/glitch/store';
import Comment from '../../report/comment';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
const CollectionThanks: React.FC<{
onClose: () => void;
}> = ({ onClose }) => {
return (
<>
<h3 className='report-dialog-modal__title'>
<FormattedMessage
id='report.thanks.title_actionable'
defaultMessage="Thanks for reporting, we'll look into this."
/>
</h3>
<div className='flex-spacer' />
<div className='report-dialog-modal__actions'>
<Button onClick={onClose}>
<FormattedMessage id='report.close' defaultMessage='Done' />
</Button>
</div>
</>
);
};
export const ReportCollectionModal: React.FC<{
collection: ApiCollectionJSON;
onClose: () => void;
}> = ({ collection, onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { id: collectionId, name, account_id } = collection;
const account = useAccount(account_id);
useEffect(() => {
dispatch(fetchServer());
}, [dispatch]);
const [submitState, setSubmitState] = useState<
'idle' | 'submitting' | 'submitted' | 'error'
>('idle');
const [step, setStep] = useState<'comment' | 'thanks'>('comment');
const [comment, setComment] = useState('');
const [selectedDomains, setSelectedDomains] = useState<string[]>([]);
const handleDomainToggle = useCallback((domain: string, checked: boolean) => {
if (checked) {
setSelectedDomains((domains) => [...domains, domain]);
} else {
setSelectedDomains((domains) => domains.filter((d) => d !== domain));
}
}, []);
const handleSubmit = useCallback(() => {
setSubmitState('submitting');
dispatch(
submitReport(
{
account_id,
status_ids: [],
collection_ids: [collectionId],
forward_to_domains: selectedDomains,
comment,
forward: selectedDomains.length > 0,
category: 'spam',
},
() => {
setSubmitState('submitted');
setStep('thanks');
},
() => {
setSubmitState('error');
},
),
);
}, [account_id, comment, dispatch, collectionId, selectedDomains]);
if (!account) {
return null;
}
const domain = account.get('acct').split('@')[1];
const isRemote = !!domain;
let stepComponent;
switch (step) {
case 'comment':
stepComponent = (
<Comment
modalTitle={
<FormattedMessage
id='report.collection_comment'
defaultMessage='Why do you want to report this collection?'
/>
}
submitError={
submitState === 'error' && (
<Callout
variant='error'
title={
<FormattedMessage
id='report.submission_error'
defaultMessage='Report could not be submitted'
/>
}
>
<FormattedMessage
id='report.submission_error_details'
defaultMessage='Please check your network connection and try again later.'
/>
</Callout>
)
}
onSubmit={handleSubmit}
isSubmitting={submitState === 'submitting'}
isRemote={isRemote}
comment={comment}
domain={domain}
onChangeComment={setComment}
statusIds={[]}
selectedDomains={selectedDomains}
onToggleDomain={handleDomainToggle}
/>
);
break;
case 'thanks':
stepComponent = <CollectionThanks onClose={onClose} />;
}
return (
<div className='modal-root__modal report-dialog-modal'>
<div className='report-modal__target'>
<IconButton
className='report-modal__close'
title={intl.formatMessage(messages.close)}
icon='times'
iconComponent={CloseIcon}
onClick={onClose}
/>
<FormattedMessage
id='report.target'
defaultMessage='Report {target}'
values={{ target: <strong>{name}</strong> }}
/>
</div>
<div className='report-dialog-modal__container'>{stepComponent}</div>
</div>
);
};

View File

@@ -25,7 +25,7 @@ import { layoutFromWindow } from 'flavours/glitch/is_mobile';
import { selectUnreadNotificationGroupsCount } from 'flavours/glitch/selectors/notifications'; import { selectUnreadNotificationGroupsCount } from 'flavours/glitch/selectors/notifications';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router'; import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
import { checkAnnualReport } from '@/flavours/glitch/reducers/slices/annual_report'; import { checkAnnualReport } from '@/flavours/glitch/reducers/slices/annual_report';
import { isClientFeatureEnabled, isServerFeatureEnabled } from '@/flavours/glitch/utils/environment'; import { isClientFeatureEnabled } from '@/flavours/glitch/utils/environment';
import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose'; import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose';
import { clearHeight } from '../../actions/height_cache'; import { clearHeight } from '../../actions/height_cache';
@@ -83,7 +83,6 @@ import {
PrivacyPolicy, PrivacyPolicy,
TermsOfService, TermsOfService,
AccountFeatured, AccountFeatured,
AccountAbout,
AccountEdit, AccountEdit,
AccountEditFeaturedTags, AccountEditFeaturedTags,
Quotes, Quotes,
@@ -174,36 +173,6 @@ class SwitchingColumnsArea extends PureComponent {
} }
const profileRedesignRoutes = []; const profileRedesignRoutes = [];
if (isServerFeatureEnabled('profile_redesign')) {
profileRedesignRoutes.push(
<WrappedRoute key="posts" path={['/@:acct/posts', '/accounts/:id/posts']} exact component={AccountTimeline} content={children} />,
);
// Check if we're in single-column mode. Confusingly, the singleColumn prop includes mobile.
if (this.props.layout === 'single-column') {
// When in single column mode (desktop w/o advanced view), redirect both the root and about to the posts tab.
profileRedesignRoutes.push(
<Redirect key="acct-redirect" from='/@:acct' to='/@:acct/posts' exact />,
<Redirect key="id-redirect" from='/accounts/:id' to='/accounts/:id/posts' exact />,
<Redirect key="about-acct-redirect" from='/@:acct/about' to='/@:acct/posts' exact />,
<Redirect key="about-id-redirect" from='/accounts/:id/about' to='/accounts/:id/posts' exact />,
);
} else {
// Otherwise, provide and redirect to the /about page.
profileRedesignRoutes.push(
<WrappedRoute key="about" path={['/@:acct/about', '/accounts/:id/about']} component={AccountAbout} content={children} />,
<Redirect key="acct-redirect" from='/@:acct' to='/@:acct/about' exact />,
<Redirect key="id-redirect" from='/accounts/:id' to='/accounts/:id/about' exact />
);
}
} else {
profileRedesignRoutes.push(
<WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />,
// If the redesign is not enabled but someone shares an /about link, redirect to the root.
<Redirect key="about-acct-redirect" from='/@:acct/about' to='/@:acct' exact />,
<Redirect key="about-id-redirect" from='/accounts/:id/about' to='/accounts/:id' exact />
);
}
if (isClientFeatureEnabled('profile_editing')) { if (isClientFeatureEnabled('profile_editing')) {
profileRedesignRoutes.push( profileRedesignRoutes.push(
<WrappedRoute key="edit" path='/profile/edit' component={AccountEdit} content={children} />, <WrappedRoute key="edit" path='/profile/edit' component={AccountEdit} content={children} />,
@@ -265,6 +234,7 @@ class SwitchingColumnsArea extends PureComponent {
{...profileRedesignRoutes} {...profileRedesignRoutes}
<WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />
<WrappedRoute path={['/@:acct/featured', '/accounts/:id/featured']} component={AccountFeatured} content={children} /> <WrappedRoute path={['/@:acct/featured', '/accounts/:id/featured']} component={AccountFeatured} content={children} />
<WrappedRoute path='/@:acct/tagged/:tagged?' exact component={AccountTimeline} content={children} /> <WrappedRoute path='/@:acct/tagged/:tagged?' exact component={AccountTimeline} content={children} />
<WrappedRoute path={['/@:acct/with_replies', '/accounts/:id/with_replies']} component={AccountTimeline} content={children} componentParams={{ withReplies: true }} /> <WrappedRoute path={['/@:acct/with_replies', '/accounts/:id/with_replies']} component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />

View File

@@ -93,11 +93,6 @@ export function AccountFeatured() {
return import('../../account_featured'); return import('../../account_featured');
} }
export function AccountAbout() {
return import('../../account_about')
.then((module) => ({ default: module.AccountAbout }));
}
export function AccountEdit() { export function AccountEdit() {
return import('../../account_edit') return import('../../account_edit')
.then((module) => ({ default: module.AccountEdit })); .then((module) => ({ default: module.AccountEdit }));
@@ -176,6 +171,11 @@ export function SettingsModal () {
return import('../../local_settings'); return import('../../local_settings');
} }
export function ReportCollectionModal () {
return import('../components/report_collection_modal')
.then((module) => ({ default: module.ReportCollectionModal }));;
}
export function IgnoreNotificationsModal () { export function IgnoreNotificationsModal () {
return import('../components/ignore_notifications_modal'); return import('../components/ignore_notifications_modal');
} }

View File

@@ -0,0 +1,29 @@
import { useEffect, useRef } from 'react';
export function useResizeObserver(callback: ResizeObserverCallback) {
const observerRef = useRef<ResizeObserver | null>(null);
observerRef.current ??= new ResizeObserver(callback);
useEffect(() => {
const observer = observerRef.current;
return () => {
observer?.disconnect();
};
}, []);
return observerRef.current;
}
export function useMutationObserver(callback: MutationCallback) {
const observerRef = useRef<MutationObserver | null>(null);
observerRef.current ??= new MutationObserver(callback);
useEffect(() => {
const observer = observerRef.current;
return () => {
observer?.disconnect();
};
}, []);
return observerRef.current;
}

View File

@@ -1,6 +1,8 @@
import type { MutableRefObject, RefCallback } from 'react'; import type { MutableRefObject, RefCallback } from 'react';
import { useState, useRef, useCallback, useEffect } from 'react'; import { useState, useRef, useCallback, useEffect } from 'react';
import { useMutationObserver, useResizeObserver } from './useObserver';
/** /**
* Hook to manage overflow of items in a container with a "more" button. * Hook to manage overflow of items in a container with a "more" button.
* *
@@ -182,48 +184,30 @@ export function useOverflowObservers({
// This is the item container element. // This is the item container element.
const listRef = useRef<HTMLElement | null>(null); const listRef = useRef<HTMLElement | null>(null);
// Set up observers to watch for size and content changes. const resizeObserver = useResizeObserver(onRecalculate);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const mutationObserverRef = useRef<MutationObserver | null>(null);
// Helper to get or create the resize observer.
const resizeObserver = useCallback(() => {
const observer = (resizeObserverRef.current ??= new ResizeObserver(
onRecalculate,
));
return observer;
}, [onRecalculate]);
// Iterate through children and observe them for size changes. // Iterate through children and observe them for size changes.
const handleChildrenChange = useCallback(() => { const handleChildrenChange = useCallback(() => {
const listEle = listRef.current; const listEle = listRef.current;
const observer = resizeObserver();
if (listEle) { if (listEle) {
for (const child of listEle.children) { for (const child of listEle.children) {
if (child instanceof HTMLElement) { if (child instanceof HTMLElement) {
observer.observe(child); resizeObserver.observe(child);
} }
} }
} }
onRecalculate(); onRecalculate();
}, [onRecalculate, resizeObserver]); }, [onRecalculate, resizeObserver]);
// Helper to get or create the mutation observer. const mutationObserver = useMutationObserver(handleChildrenChange);
const mutationObserver = useCallback(() => {
const observer = (mutationObserverRef.current ??= new MutationObserver(
handleChildrenChange,
));
return observer;
}, [handleChildrenChange]);
// Set up observers. // Set up observers.
const handleObserve = useCallback(() => { const handleObserve = useCallback(() => {
if (wrapperRef.current) { if (wrapperRef.current) {
resizeObserver().observe(wrapperRef.current); resizeObserver.observe(wrapperRef.current);
} }
if (listRef.current) { if (listRef.current) {
mutationObserver().observe(listRef.current, { childList: true }); mutationObserver.observe(listRef.current, { childList: true });
handleChildrenChange(); handleChildrenChange();
} }
}, [handleChildrenChange, mutationObserver, resizeObserver]); }, [handleChildrenChange, mutationObserver, resizeObserver]);
@@ -233,12 +217,12 @@ export function useOverflowObservers({
const wrapperRefCallback = useCallback( const wrapperRefCallback = useCallback(
(node: HTMLElement | null) => { (node: HTMLElement | null) => {
if (node) { if (node) {
wrapperRef.current = node; wrapperRef.current = node; // eslint-disable-line react-hooks/immutability -- https://github.com/facebook/react/issues/34955
handleObserve(); handleObserve();
if (typeof onWrapperRef === 'function') { if (typeof onWrapperRef === 'function') {
onWrapperRef(node); onWrapperRef(node);
} else if (onWrapperRef && 'current' in onWrapperRef) { } else if (onWrapperRef && 'current' in onWrapperRef) {
onWrapperRef.current = node; onWrapperRef.current = node; // eslint-disable-line react-hooks/immutability -- https://github.com/facebook/react/issues/34955
} }
} }
}, },
@@ -254,28 +238,13 @@ export function useOverflowObservers({
if (typeof onListRef === 'function') { if (typeof onListRef === 'function') {
onListRef(node); onListRef(node);
} else if (onListRef && 'current' in onListRef) { } else if (onListRef && 'current' in onListRef) {
onListRef.current = node; onListRef.current = node; // eslint-disable-line react-hooks/immutability -- https://github.com/facebook/react/issues/34955
} }
} }
}, },
[handleObserve, onListRef], [handleObserve, onListRef],
); );
useEffect(() => {
handleObserve();
return () => {
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect();
resizeObserverRef.current = null;
}
if (mutationObserverRef.current) {
mutationObserverRef.current.disconnect();
mutationObserverRef.current = null;
}
};
}, [handleObserve]);
return { return {
wrapperRefCallback, wrapperRefCallback,
listRefCallback, listRefCallback,

View File

@@ -229,14 +229,16 @@ interface AccountCollectionQuery {
collections: ApiCollectionJSON[]; collections: ApiCollectionJSON[];
} }
export const selectMyCollections = createAppSelector( export const selectAccountCollections = createAppSelector(
[ [
(state) => state.meta.get('me') as string, (_, accountId: string | null) => accountId,
(state) => state.collections.accountCollections, (state) => state.collections.accountCollections,
(state) => state.collections.collections, (state) => state.collections.collections,
], ],
(me, collectionsByAccountId, collectionsMap) => { (accountId, collectionsByAccountId, collectionsMap) => {
const myCollectionsQuery = collectionsByAccountId[me]; const myCollectionsQuery = accountId
? collectionsByAccountId[accountId]
: null;
if (!myCollectionsQuery) { if (!myCollectionsQuery) {
return { return {

View File

@@ -20,18 +20,7 @@ export interface EmojiHTMLProps {
} }
export const EmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>( export const EmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
( ({ extraEmojis, htmlString, onElement, onAttribute, ...props }, ref) => {
{
extraEmojis,
htmlString,
as: asProp = 'div', // Rename for syntax highlighting
className,
onElement,
onAttribute,
...props
},
ref,
) => {
const contents = useMemo( const contents = useMemo(
() => () =>
htmlStringToComponents(htmlString, { htmlStringToComponents(htmlString, {
@@ -44,12 +33,7 @@ export const EmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
return ( return (
<CustomEmojiProvider emojis={extraEmojis}> <CustomEmojiProvider emojis={extraEmojis}>
<AnimateEmojiProvider <AnimateEmojiProvider {...props} ref={ref}>
{...props}
as={asProp}
className={className}
ref={ref}
>
{contents} {contents}
</AnimateEmojiProvider> </AnimateEmojiProvider>
</CustomEmojiProvider> </CustomEmojiProvider>

View File

@@ -23,7 +23,17 @@ export type MiniCardProps = OmitUnion<
export const MiniCard = forwardRef<HTMLDivElement, MiniCardProps>( export const MiniCard = forwardRef<HTMLDivElement, MiniCardProps>(
( (
{ label, value, className, hidden, icon, iconId, iconClassName, ...props }, {
label,
value,
className,
hidden,
icon,
iconId,
iconClassName,
children,
...props
},
ref, ref,
) => { ) => {
if (!label) { if (!label) {
@@ -50,6 +60,7 @@ export const MiniCard = forwardRef<HTMLDivElement, MiniCardProps>(
)} )}
<dt className={classes.label}>{label}</dt> <dt className={classes.label}>{label}</dt>
<dd className={classes.value}>{value}</dd> <dd className={classes.value}>{value}</dd>
{children}
</div> </div>
); );
}, },

View File

@@ -1,125 +0,0 @@
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { useParams } from 'react-router';
import { AccountBio } from '@/mastodon/components/account_bio';
import { Column } from '@/mastodon/components/column';
import { ColumnBackButton } from '@/mastodon/components/column_back_button';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error';
import type { AccountId } from '@/mastodon/hooks/useAccountId';
import { useAccountId } from '@/mastodon/hooks/useAccountId';
import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
import { createAppSelector, useAppSelector } from '@/mastodon/store';
import { AccountHeader } from '../account_timeline/components/account_header';
import { AccountHeaderFields } from '../account_timeline/components/fields';
import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint';
import classes from './styles.module.css';
const selectIsProfileEmpty = createAppSelector(
[(state) => state.accounts, (_, accountId: AccountId) => accountId],
(accounts, accountId) => {
// Null means still loading, otherwise it's a boolean.
if (!accountId) {
return null;
}
const account = accounts.get(accountId);
if (!account) {
return null;
}
return !account.note && !account.fields.size;
},
);
export const AccountAbout: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
const accountId = useAccountId();
const { blockedBy, hidden, suspended } = useAccountVisibility(accountId);
const forceEmptyState = blockedBy || hidden || suspended;
const isProfileEmpty = useAppSelector((state) =>
selectIsProfileEmpty(state, accountId),
);
if (accountId === null) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
}
if (!accountId || isProfileEmpty === null) {
return (
<Column bindToDocument={!multiColumn}>
<LoadingIndicator />
</Column>
);
}
const showEmptyMessage = forceEmptyState || isProfileEmpty;
return (
<Column bindToDocument={!multiColumn}>
<ColumnBackButton />
<div className='scrollable scrollable--flex'>
<AccountHeader accountId={accountId} hideTabs={forceEmptyState} />
<div className={classes.wrapper}>
{!showEmptyMessage ? (
<>
<AccountBio
accountId={accountId}
className={`${classes.bio} account__header__content`}
/>
<AccountHeaderFields accountId={accountId} />
</>
) : (
<div className='empty-column-indicator'>
<EmptyMessage accountId={accountId} />
</div>
)}
</div>
</div>
</Column>
);
};
const EmptyMessage: FC<{ accountId: string }> = ({ accountId }) => {
const { blockedBy, hidden, suspended } = useAccountVisibility(accountId);
const currentUserId = useAppSelector(
(state) => state.meta.get('me') as string | null,
);
const { acct } = useParams<{ acct?: string }>();
if (suspended) {
return (
<FormattedMessage
id='empty_column.account_suspended'
defaultMessage='Account suspended'
/>
);
} else if (hidden) {
return <LimitedAccountHint accountId={accountId} />;
} else if (blockedBy) {
return (
<FormattedMessage
id='empty_column.account_unavailable'
defaultMessage='Profile unavailable'
/>
);
} else if (accountId === currentUserId) {
return (
<FormattedMessage
id='empty_column.account_about.me'
defaultMessage='You have not added any information about yourself yet.'
/>
);
}
return (
<FormattedMessage
id='empty_column.account_about.other'
defaultMessage='{acct} has not added any information about themselves yet.'
values={{ acct }}
/>
);
};

View File

@@ -1,7 +0,0 @@
.wrapper {
padding: 16px;
}
.bio {
color: var(--color-text-primary);
}

View File

@@ -17,8 +17,15 @@ import BundleColumnError from 'mastodon/features/ui/components/bundle_column_err
import Column from 'mastodon/features/ui/components/column'; import Column from 'mastodon/features/ui/components/column';
import { useAccountId } from 'mastodon/hooks/useAccountId'; import { useAccountId } from 'mastodon/hooks/useAccountId';
import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility'; import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility';
import {
fetchAccountCollections,
selectAccountCollections,
} from 'mastodon/reducers/slices/collections';
import { useAppDispatch, useAppSelector } from 'mastodon/store'; import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { CollectionListItem } from '../collections/detail/collection_list_item';
import { areCollectionsEnabled } from '../collections/utils';
import { EmptyMessage } from './components/empty_message'; import { EmptyMessage } from './components/empty_message';
import { FeaturedTag } from './components/featured_tag'; import { FeaturedTag } from './components/featured_tag';
import type { TagMap } from './components/featured_tag'; import type { TagMap } from './components/featured_tag';
@@ -42,6 +49,9 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
if (accountId) { if (accountId) {
void dispatch(fetchFeaturedTags({ accountId })); void dispatch(fetchFeaturedTags({ accountId }));
void dispatch(fetchEndorsedAccounts({ accountId })); void dispatch(fetchEndorsedAccounts({ accountId }));
if (areCollectionsEnabled()) {
void dispatch(fetchAccountCollections({ accountId }));
}
} }
}, [accountId, dispatch]); }, [accountId, dispatch]);
@@ -64,6 +74,14 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
ImmutableList(), ImmutableList(),
) as ImmutableList<string>, ) as ImmutableList<string>,
); );
const { collections, status } = useAppSelector((state) =>
selectAccountCollections(state, accountId ?? null),
);
const publicCollections = collections.filter(
// This filter only applies when viewing your own profile, where the endpoint
// returns all collections, but we hide unlisted ones here to avoid confusion
(item) => item.discoverable,
);
if (accountId === null) { if (accountId === null) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />; return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
@@ -101,6 +119,25 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
{accountId && ( {accountId && (
<AccountHeader accountId={accountId} hideTabs={forceEmptyState} /> <AccountHeader accountId={accountId} hideTabs={forceEmptyState} />
)} )}
{publicCollections.length > 0 && status === 'idle' && (
<>
<h4 className='column-subheading'>
<FormattedMessage
id='account.featured.collections'
defaultMessage='Collections'
/>
</h4>
<section>
{publicCollections.map((item, index) => (
<CollectionListItem
key={item.id}
collection={item}
withoutBorder={index === publicCollections.length - 1}
/>
))}
</section>
</>
)}
{!featuredTags.isEmpty() && ( {!featuredTags.isEmpty() && (
<> <>
<h4 className='column-subheading'> <h4 className='column-subheading'>

View File

@@ -1,5 +1,12 @@
import type { AccountFieldShape } from '@/mastodon/models/account';
import { isServerFeatureEnabled } from '@/mastodon/utils/environment'; import { isServerFeatureEnabled } from '@/mastodon/utils/environment';
export function isRedesignEnabled() { export function isRedesignEnabled() {
return isServerFeatureEnabled('profile_redesign'); return isServerFeatureEnabled('profile_redesign');
} }
export interface AccountField extends AccountFieldShape {
nameHasEmojis: boolean;
value_plain: string;
valueHasEmojis: boolean;
}

View File

@@ -210,18 +210,14 @@ export const AccountHeader: React.FC<{
<AccountNote accountId={accountId} /> <AccountNote accountId={accountId} />
))} ))}
{(!isRedesign || layout === 'single-column') && ( <AccountBio
<> accountId={accountId}
<AccountBio className={classNames(
accountId={accountId} 'account__header__content',
className={classNames( isRedesign && redesignClasses.bio,
'account__header__content', )}
isRedesign && redesignClasses.bio, />
)} <AccountHeaderFields accountId={accountId} />
/>
<AccountHeaderFields accountId={accountId} />
</>
)}
</div> </div>
<AccountNumberFields accountId={accountId} /> <AccountNumberFields accountId={accountId} />

View File

@@ -1,25 +1,31 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import type { FC, Key } from 'react'; import type { FC } from 'react';
import { defineMessage, FormattedMessage, useIntl } from 'react-intl'; import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import htmlConfig from '@/config/html-tags.json';
import IconVerified from '@/images/icons/icon_verified.svg?react'; import IconVerified from '@/images/icons/icon_verified.svg?react';
import { openModal } from '@/mastodon/actions/modal';
import { AccountFields } from '@/mastodon/components/account_fields'; import { AccountFields } from '@/mastodon/components/account_fields';
import { CustomEmojiProvider } from '@/mastodon/components/emoji/context'; import { CustomEmojiProvider } from '@/mastodon/components/emoji/context';
import type { EmojiHTMLProps } from '@/mastodon/components/emoji/html'; import type { EmojiHTMLProps } from '@/mastodon/components/emoji/html';
import { EmojiHTML } from '@/mastodon/components/emoji/html'; import { EmojiHTML } from '@/mastodon/components/emoji/html';
import { FormattedDateWrapper } from '@/mastodon/components/formatted_date'; import { FormattedDateWrapper } from '@/mastodon/components/formatted_date';
import { Icon } from '@/mastodon/components/icon'; import { Icon } from '@/mastodon/components/icon';
import { IconButton } from '@/mastodon/components/icon_button';
import { MiniCard } from '@/mastodon/components/mini_card';
import { useElementHandledLink } from '@/mastodon/components/status/handled_link'; import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
import { useAccount } from '@/mastodon/hooks/useAccount'; import { useAccount } from '@/mastodon/hooks/useAccount';
import type { Account, AccountFieldShape } from '@/mastodon/models/account'; import { useResizeObserver } from '@/mastodon/hooks/useObserver';
import type { OnElementHandler } from '@/mastodon/utils/html'; import type { Account } from '@/mastodon/models/account';
import { useAppDispatch } from '@/mastodon/store';
import MoreIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { cleanExtraEmojis } from '../../emoji/normalize'; import { cleanExtraEmojis } from '../../emoji/normalize';
import type { AccountField } from '../common';
import { isRedesignEnabled } from '../common'; import { isRedesignEnabled } from '../common';
import { useFieldHtml } from '../hooks/useFieldHtml';
import classes from './redesign.module.scss'; import classes from './redesign.module.scss';
@@ -74,172 +80,310 @@ const RedesignAccountHeaderFields: FC<{ account: Account }> = ({ account }) => {
() => cleanExtraEmojis(account.emojis), () => cleanExtraEmojis(account.emojis),
[account.emojis], [account.emojis],
); );
const textHasCustomEmoji = useCallback( const fields: AccountField[] = useMemo(() => {
(text?: string | null) => { const fields = account.fields.toJS();
if (!emojis || !text) { if (!emojis) {
return false; return fields.map((field) => ({
} ...field,
for (const emoji of Object.keys(emojis)) { nameHasEmojis: false,
if (text.includes(`:${emoji}:`)) { value_plain: field.value_plain ?? '',
return true; valueHasEmojis: false,
} }));
} }
return false;
}, const shortcodes = Object.keys(emojis);
[emojis], return fields.map((field) => ({
); ...field,
nameHasEmojis: shortcodes.some((code) =>
field.name.includes(`:${code}:`),
),
value_plain: field.value_plain ?? '',
valueHasEmojis: shortcodes.some((code) =>
field.value_plain?.includes(`:${code}:`),
),
}));
}, [account.fields, emojis]);
const htmlHandlers = useElementHandledLink({ const htmlHandlers = useElementHandledLink({
hashtagAccountId: account.id, hashtagAccountId: account.id,
}); });
if (account.fields.isEmpty()) { const { wrapperRef } = useColumnWrap();
if (fields.length === 0) {
return null; return null;
} }
return ( return (
<CustomEmojiProvider emojis={emojis}> <CustomEmojiProvider emojis={emojis}>
<dl className={classes.fieldList}> <dl className={classes.fieldList} ref={wrapperRef}>
{account.fields.map((field, key) => ( {fields.map((field, key) => (
<FieldRow <FieldRow key={key} field={field} htmlHandlers={htmlHandlers} />
key={key}
{...field.toJSON()}
htmlHandlers={htmlHandlers}
textHasCustomEmoji={textHasCustomEmoji}
/>
))} ))}
</dl> </dl>
</CustomEmojiProvider> </CustomEmojiProvider>
); );
}; };
const FieldRow: FC< const FieldRow: FC<{
{ htmlHandlers: ReturnType<typeof useElementHandledLink>;
textHasCustomEmoji: (text?: string | null) => boolean; field: AccountField;
htmlHandlers: ReturnType<typeof useElementHandledLink>; }> = ({ htmlHandlers, field }) => {
} & AccountFieldShape
> = ({
textHasCustomEmoji,
htmlHandlers,
name,
name_emojified,
value_emojified,
value_plain,
verified_at,
}) => {
const intl = useIntl(); const intl = useIntl();
const [showAll, setShowAll] = useState(false); const {
const handleClick = useCallback(() => { name,
setShowAll((prev) => !prev); name_emojified,
}, []); nameHasEmojis,
value_emojified,
value_plain,
valueHasEmojis,
verified_at,
} = field;
const { wrapperRef, isLabelOverflowing, isValueOverflowing } =
useFieldOverflow();
const dispatch = useAppDispatch();
const handleOverflowClick = useCallback(() => {
dispatch(
openModal({
modalType: 'ACCOUNT_FIELD_OVERFLOW',
modalProps: { field },
}),
);
}, [dispatch, field]);
return ( return (
/* eslint-disable -- This method of showing field contents is not very accessible, but it's what we've got for now */ <MiniCard
<div
className={classNames( className={classNames(
classes.fieldRow, classes.fieldItem,
verified_at && classes.fieldVerified, verified_at && classes.fieldVerified,
showAll && classes.fieldShowAll,
)} )}
onClick={handleClick} label={
/* eslint-enable */
>
<FieldHTML
as='dt'
text={name}
textEmojified={name_emojified}
textHasCustomEmoji={textHasCustomEmoji(name)}
titleLength={50}
className='translate'
{...htmlHandlers}
/>
<dd>
<FieldHTML <FieldHTML
as='span' text={name}
text={value_plain ?? ''} textEmojified={name_emojified}
textEmojified={value_emojified} textHasCustomEmoji={nameHasEmojis}
textHasCustomEmoji={textHasCustomEmoji(value_plain ?? '')} className='translate'
titleLength={120} isOverflowing={isLabelOverflowing}
onOverflowClick={handleOverflowClick}
{...htmlHandlers} {...htmlHandlers}
/> />
}
{verified_at && ( value={
<Icon <FieldHTML
id='verified' text={value_plain}
icon={IconVerified} textEmojified={value_emojified}
className={classes.fieldVerifiedIcon} textHasCustomEmoji={valueHasEmojis}
aria-label={intl.formatMessage(verifyMessage, { isOverflowing={isValueOverflowing}
date: intl.formatDate(verified_at, dateFormatOptions), onOverflowClick={handleOverflowClick}
})} {...htmlHandlers}
noFill />
/> }
)} ref={wrapperRef}
</dd> >
</div> {verified_at && (
<Icon
id='verified'
icon={IconVerified}
className={classes.fieldVerifiedIcon}
aria-label={intl.formatMessage(verifyMessage, {
date: intl.formatDate(verified_at, dateFormatOptions),
})}
noFill
/>
)}
</MiniCard>
); );
}; };
const FieldHTML: FC< type FieldHTMLProps = {
{ text: string;
as?: 'span' | 'dt'; textEmojified: string;
text: string; textHasCustomEmoji: boolean;
textEmojified: string; isOverflowing?: boolean;
textHasCustomEmoji: boolean; onOverflowClick?: () => void;
titleLength: number; } & Omit<EmojiHTMLProps, 'htmlString'>;
} & Omit<EmojiHTMLProps, 'htmlString'>
> = ({ const FieldHTML: FC<FieldHTMLProps> = ({
as,
className, className,
extraEmojis, extraEmojis,
text, text,
textEmojified, textEmojified,
textHasCustomEmoji, textHasCustomEmoji,
titleLength, isOverflowing,
onOverflowClick,
onElement, onElement,
...props ...props
}) => { }) => {
const handleElement: OnElementHandler = useCallback( const intl = useIntl();
(element, props, children, extra) => { const handleElement = useFieldHtml(textHasCustomEmoji, onElement);
if (element instanceof HTMLAnchorElement) {
// Don't allow custom emoji and links in the same field to prevent verification spoofing.
if (textHasCustomEmoji) {
return (
<span {...filterAttributesForSpan(props)} key={props.key as Key}>
{children}
</span>
);
}
return onElement?.(element, props, children, extra);
}
return undefined;
},
[onElement, textHasCustomEmoji],
);
return ( const html = (
<EmojiHTML <EmojiHTML
as={as} as='span'
htmlString={textEmojified} htmlString={textEmojified}
title={showTitleOnLength(text, titleLength)}
className={className} className={className}
onElement={handleElement} onElement={handleElement}
data-contents
{...props} {...props}
/> />
); );
if (!isOverflowing) {
return html;
}
return (
<>
{html}
<IconButton
icon='ellipsis'
iconComponent={MoreIcon}
title={intl.formatMessage({
id: 'account.field_overflow',
defaultMessage: 'Show full content',
})}
className={classes.fieldOverflowButton}
onClick={onOverflowClick}
/>
</>
);
}; };
function filterAttributesForSpan(props: Record<string, unknown>) { function useColumnWrap() {
const validAttributes: Record<string, unknown> = {}; const listRef = useRef<HTMLDListElement | null>(null);
for (const key of Object.keys(props)) {
if (key in htmlConfig.tags.span.attributes) { const handleRecalculate = useCallback(() => {
validAttributes[key] = props[key]; const listEle = listRef.current;
if (!listEle) {
return;
} }
}
return validAttributes; // Calculate dimensions from styles and element size to determine column spans.
const styles = getComputedStyle(listEle);
const gap = parseFloat(styles.columnGap || styles.gap || '0');
const columnCount = parseInt(styles.getPropertyValue('--cols')) || 2;
const listWidth = listEle.offsetWidth;
const colWidth = (listWidth - gap * (columnCount - 1)) / columnCount;
// Matrix to hold the grid layout.
const itemGrid: { ele: HTMLElement; span: number }[][] = [];
// First, determine the column span for each item and populate the grid matrix.
let currentRow = 0;
for (const child of listEle.children) {
if (!(child instanceof HTMLElement)) {
continue;
}
// This uses a data attribute to detect which elements to measure that overflow.
const contents = child.querySelectorAll('[data-contents]');
const childStyles = getComputedStyle(child);
const padding =
parseFloat(childStyles.paddingLeft) +
parseFloat(childStyles.paddingRight);
const contentWidth =
Math.max(
...Array.from(contents).map((content) => content.scrollWidth),
) + padding;
const contentSpan = Math.ceil(contentWidth / colWidth);
const maxColSpan = Math.min(contentSpan, columnCount);
const curRow = itemGrid[currentRow] ?? [];
const availableCols =
columnCount - curRow.reduce((carry, curr) => carry + curr.span, 0);
// Move to next row if current item doesn't fit.
if (maxColSpan > availableCols) {
currentRow++;
}
itemGrid[currentRow] = (itemGrid[currentRow] ?? []).concat({
ele: child,
span: maxColSpan,
});
}
// Next, iterate through the grid matrix and set the column spans and row breaks.
for (const row of itemGrid) {
let remainingRowSpan = columnCount;
for (let i = 0; i < row.length; i++) {
const item = row[i];
if (!item) {
break;
}
const { ele, span } = item;
if (i < row.length - 1) {
ele.dataset.cols = span.toString();
remainingRowSpan -= span;
} else {
// Last item in the row takes up remaining space to fill the row.
ele.dataset.cols = remainingRowSpan.toString();
break;
}
}
}
}, []);
const observer = useResizeObserver(handleRecalculate);
const wrapperRefCallback = useCallback(
(element: HTMLDListElement | null) => {
if (element) {
listRef.current = element;
observer.observe(element);
}
},
[observer],
);
return { wrapperRef: wrapperRefCallback };
} }
function showTitleOnLength(value: string | null, maxLength: number) { function useFieldOverflow() {
if (value && value.length > maxLength) { const [isLabelOverflowing, setIsLabelOverflowing] = useState(false);
return value; const [isValueOverflowing, setIsValueOverflowing] = useState(false);
}
return undefined; const wrapperRef = useRef<HTMLElement | null>(null);
const handleRecalculate = useCallback(() => {
const wrapperEle = wrapperRef.current;
if (!wrapperEle) return;
const wrapperStyles = getComputedStyle(wrapperEle);
const maxWidth =
wrapperEle.offsetWidth -
(parseFloat(wrapperStyles.paddingLeft) +
parseFloat(wrapperStyles.paddingRight));
const label = wrapperEle.querySelector<HTMLSpanElement>(
'dt > [data-contents]',
);
const value = wrapperEle.querySelector<HTMLSpanElement>(
'dd > [data-contents]',
);
setIsLabelOverflowing(label ? label.scrollWidth > maxWidth : false);
setIsValueOverflowing(value ? value.scrollWidth > maxWidth : false);
}, []);
const observer = useResizeObserver(handleRecalculate);
const wrapperRefCallback = useCallback(
(element: HTMLElement | null) => {
if (element) {
wrapperRef.current = element;
observer.observe(element);
}
},
[observer],
);
return {
isLabelOverflowing,
isValueOverflowing,
wrapperRef: wrapperRefCallback,
};
} }

View File

@@ -214,64 +214,90 @@ svg.badgeIcon {
} }
.fieldList { .fieldList {
--cols: 4;
position: relative;
display: grid; display: grid;
grid-template-columns: 160px 1fr; grid-template-columns: repeat(var(--cols), 1fr);
column-gap: 12px; gap: 4px;
margin: 16px 0; margin: 16px 0;
border-top: 0.5px solid var(--color-border-primary);
@container (width < 420px) { @container (width < 420px) {
grid-template-columns: 100px 1fr; --cols: 2;
} }
} }
.fieldRow { .fieldItem {
display: grid; --col-span: 1;
grid-column: 1 / -1;
align-items: start;
grid-template-columns: subgrid;
padding: 8px;
border-bottom: 0.5px solid var(--color-border-primary);
> :is(dt, dd) { grid-column: span var(--col-span);
&:not(.fieldShowAll) { position: relative;
display: -webkit-box;
-webkit-box-orient: vertical; @for $col from 2 through 4 {
-webkit-line-clamp: 2; &[data-cols='#{$col}'] {
line-clamp: 2; --col-span: #{$col};
overflow: hidden;
text-overflow: ellipsis;
} }
} }
> dt { dt {
color: var(--color-text-secondary); font-weight: normal;
} }
> dd { dd {
display: flex; font-weight: 500;
align-items: center;
gap: 4px;
} }
a { :is(dt, dd) {
color: inherit; text-overflow: initial;
text-decoration: none;
&:hover, // Override the MiniCard link styles
&:focus { a {
text-decoration: underline; color: inherit;
font-weight: inherit;
&:hover,
&:focus {
color: inherit;
text-decoration: underline;
}
} }
} }
} }
.fieldVerified { .fieldVerified {
background-color: var(--color-bg-success-softer); background-color: var(--color-bg-success-softer);
dt {
padding-right: 24px;
}
} }
.fieldVerifiedIcon { .fieldVerifiedIcon {
width: 16px; width: 16px;
height: 16px; height: 16px;
position: absolute;
top: 8px;
right: 8px;
}
.fieldOverflowButton {
--default-bg-color: var(--color-bg-secondary-solid);
--hover-bg-color: color-mix(
in oklab,
var(--color-bg-brand-base),
var(--default-bg-color) var(--overlay-strength-brand)
);
position: absolute;
right: 8px;
padding: 0 2px;
transition: background-color 0.2s ease-in-out;
border: 2px solid var(--color-bg-primary);
> svg {
width: 16px;
height: 12px;
}
} }
.fieldNumbersWrapper { .fieldNumbersWrapper {

View File

@@ -5,23 +5,15 @@ import { FormattedMessage } from 'react-intl';
import type { NavLinkProps } from 'react-router-dom'; import type { NavLinkProps } from 'react-router-dom';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { useLayout } from '@/mastodon/hooks/useLayout';
import { isRedesignEnabled } from '../common'; import { isRedesignEnabled } from '../common';
import classes from './redesign.module.scss'; import classes from './redesign.module.scss';
export const AccountTabs: FC<{ acct: string }> = ({ acct }) => { export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
const { layout } = useLayout();
if (isRedesignEnabled()) { if (isRedesignEnabled()) {
return ( return (
<div className={classes.tabs}> <div className={classes.tabs}>
{layout !== 'single-column' && ( <NavLink isActive={isActive} to={`/@${acct}`}>
<NavLink exact to={`/@${acct}/about`}>
<FormattedMessage id='account.about' defaultMessage='About' />
</NavLink>
)}
<NavLink isActive={isActive} to={`/@${acct}/posts`}>
<FormattedMessage id='account.activity' defaultMessage='Activity' /> <FormattedMessage id='account.activity' defaultMessage='Activity' />
</NavLink> </NavLink>
<NavLink exact to={`/@${acct}/media`}> <NavLink exact to={`/@${acct}/media`}>

View File

@@ -0,0 +1,38 @@
import type { Key } from 'react';
import { useCallback } from 'react';
import htmlConfig from '@/config/html-tags.json';
import type { OnElementHandler } from '@/mastodon/utils/html';
export function useFieldHtml(
hasCustomEmoji: boolean,
onElement?: OnElementHandler,
): OnElementHandler {
return useCallback(
(element, props, children, extra) => {
if (element instanceof HTMLAnchorElement) {
// Don't allow custom emoji and links in the same field to prevent verification spoofing.
if (hasCustomEmoji) {
return (
<span {...filterAttributesForSpan(props)} key={props.key as Key}>
{children}
</span>
);
}
return onElement?.(element, props, children, extra);
}
return undefined;
},
[onElement, hasCustomEmoji],
);
}
function filterAttributesForSpan(props: Record<string, unknown>) {
const validAttributes: Record<string, unknown> = {};
for (const key of Object.keys(props)) {
if (key in htmlConfig.tags.span.attributes) {
validAttributes[key] = props[key];
}
}
return validAttributes;
}

View File

@@ -0,0 +1,44 @@
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { EmojiHTML } from '@/mastodon/components/emoji/html';
import type { AccountField } from '../common';
import { useFieldHtml } from '../hooks/useFieldHtml';
import classes from './styles.module.css';
export const AccountFieldModal: FC<{
onClose: () => void;
field: AccountField;
}> = ({ onClose, field }) => {
const handleLabelElement = useFieldHtml(field.nameHasEmojis);
const handleValueElement = useFieldHtml(field.valueHasEmojis);
return (
<div className='modal-root__modal safety-action-modal'>
<div className='safety-action-modal__top'>
<div className='safety-action-modal__confirmation'>
<EmojiHTML
as='p'
htmlString={field.name_emojified}
onElement={handleLabelElement}
/>
<EmojiHTML
as='p'
htmlString={field.value_emojified}
onElement={handleValueElement}
className={classes.fieldValue}
/>
</div>
</div>
<div className='safety-action-modal__bottom'>
<div className='safety-action-modal__actions'>
<button onClick={onClose} className='link-button' type='button'>
<FormattedMessage id='lightbox.close' defaultMessage='Close' />
</button>
</div>
</div>
</div>
);
};

View File

@@ -13,7 +13,7 @@ import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { ConfirmationModal } from '../../ui/components/confirmation_modals'; import { ConfirmationModal } from '../../ui/components/confirmation_modals';
import classes from './modals.module.css'; import classes from './styles.module.css';
const messages = defineMessages({ const messages = defineMessages({
newTitle: { newTitle: {

View File

@@ -19,3 +19,9 @@
outline: var(--outline-focus-default); outline: var(--outline-focus-default);
outline-offset: 2px; outline-offset: 2px;
} }
.fieldValue {
color: var(--color-text-primary);
font-weight: 600;
margin-top: 4px;
}

View File

@@ -2,15 +2,17 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
margin-inline: 10px; padding-inline: 16px;
padding-inline-end: 5px;
border-bottom: 1px solid var(--color-border-primary); &:not(.wrapperWithoutBorder) {
border-bottom: 1px solid var(--color-border-primary);
}
} }
.content { .content {
position: relative; position: relative;
flex-grow: 1; flex-grow: 1;
padding: 15px 5px; padding-block: 15px;
} }
.link { .link {

View File

@@ -67,13 +67,18 @@ export const CollectionMetaData: React.FC<{
export const CollectionListItem: React.FC<{ export const CollectionListItem: React.FC<{
collection: ApiCollectionJSON; collection: ApiCollectionJSON;
}> = ({ collection }) => { withoutBorder?: boolean;
}> = ({ collection, withoutBorder }) => {
const { id, name } = collection; const { id, name } = collection;
const linkId = useId(); const linkId = useId();
return ( return (
<article <article
className={classNames(classes.wrapper, 'focusable')} className={classNames(
classes.wrapper,
'focusable',
withoutBorder && classes.wrapperWithoutBorder,
)}
tabIndex={-1} tabIndex={-1}
aria-labelledby={linkId} aria-labelledby={linkId}
> >

View File

@@ -2,11 +2,16 @@ import { useCallback, useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { matchPath } from 'react-router';
import { useAccount } from '@/mastodon/hooks/useAccount';
import MoreVertIcon from '@/material-icons/400-24px/more_vert.svg?react'; import MoreVertIcon from '@/material-icons/400-24px/more_vert.svg?react';
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
import type { ApiCollectionJSON } from 'mastodon/api_types/collections'; import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
import { Dropdown } from 'mastodon/components/dropdown_menu'; import { Dropdown } from 'mastodon/components/dropdown_menu';
import { IconButton } from 'mastodon/components/icon_button'; import { IconButton } from 'mastodon/components/icon_button';
import { me } from 'mastodon/initial_state';
import type { MenuItem } from 'mastodon/models/dropdown_menu';
import { useAppDispatch } from 'mastodon/store'; import { useAppDispatch } from 'mastodon/store';
import { messages as editorMessages } from '../editor'; import { messages as editorMessages } from '../editor';
@@ -16,10 +21,18 @@ const messages = defineMessages({
id: 'collections.view_collection', id: 'collections.view_collection',
defaultMessage: 'View collection', defaultMessage: 'View collection',
}, },
viewOtherCollections: {
id: 'collections.view_other_collections_by_user',
defaultMessage: 'View other collections by this user',
},
delete: { delete: {
id: 'collections.delete_collection', id: 'collections.delete_collection',
defaultMessage: 'Delete collection', defaultMessage: 'Delete collection',
}, },
report: {
id: 'collections.report_collection',
defaultMessage: 'Report this collection',
},
more: { id: 'status.more', defaultMessage: 'More' }, more: { id: 'status.more', defaultMessage: 'More' },
}); });
@@ -31,9 +44,11 @@ export const CollectionMenu: React.FC<{
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const intl = useIntl(); const intl = useIntl();
const { id, name } = collection; const { id, name, account_id } = collection;
const isOwnCollection = account_id === me;
const ownerAccount = useAccount(account_id);
const handleDeleteClick = useCallback(() => { const openDeleteConfirmation = useCallback(() => {
dispatch( dispatch(
openModal({ openModal({
modalType: 'CONFIRM_DELETE_COLLECTION', modalType: 'CONFIRM_DELETE_COLLECTION',
@@ -45,34 +60,83 @@ export const CollectionMenu: React.FC<{
); );
}, [dispatch, id, name]); }, [dispatch, id, name]);
const menu = useMemo(() => { const openReportModal = useCallback(() => {
const commonItems = [ dispatch(
{ openModal({
text: intl.formatMessage(editorMessages.manageAccounts), modalType: 'REPORT_COLLECTION',
to: `/collections/${id}/edit`, modalProps: {
}, collection,
{ },
text: intl.formatMessage(editorMessages.editDetails), }),
to: `/collections/${id}/edit/details`, );
}, }, [collection, dispatch]);
null,
{
text: intl.formatMessage(messages.delete),
action: handleDeleteClick,
dangerous: true,
},
];
if (context === 'list') { const menu = useMemo(() => {
return [ if (isOwnCollection) {
{ text: intl.formatMessage(messages.view), to: `/collections/${id}` }, const commonItems: MenuItem[] = [
{
text: intl.formatMessage(editorMessages.manageAccounts),
to: `/collections/${id}/edit`,
},
{
text: intl.formatMessage(editorMessages.editDetails),
to: `/collections/${id}/edit/details`,
},
null, null,
...commonItems, {
text: intl.formatMessage(messages.delete),
action: openDeleteConfirmation,
dangerous: true,
},
]; ];
if (context === 'list') {
return [
{ text: intl.formatMessage(messages.view), to: `/collections/${id}` },
null,
...commonItems,
];
} else {
return commonItems;
}
} else if (ownerAccount) {
const items: MenuItem[] = [
{
text: intl.formatMessage(messages.report),
action: openReportModal,
},
];
const featuredCollectionsPath = `/@${ownerAccount.acct}/featured`;
// Don't show menu link to featured collections while on that very page
if (
!matchPath(location.pathname, {
path: featuredCollectionsPath,
exact: true,
})
) {
items.unshift(
...[
{
text: intl.formatMessage(messages.viewOtherCollections),
to: featuredCollectionsPath,
},
null,
],
);
}
return items;
} else { } else {
return commonItems; return [];
} }
}, [intl, id, handleDeleteClick, context]); }, [
isOwnCollection,
intl,
id,
openDeleteConfirmation,
context,
ownerAccount,
openReportModal,
]);
return ( return (
<Dropdown scrollKey='collections' items={menu}> <Dropdown scrollKey='collections' items={menu}>

View File

@@ -5,6 +5,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { useRelationship } from '@/mastodon/hooks/useRelationship';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import ShareIcon from '@/material-icons/400-24px/share.svg?react'; import ShareIcon from '@/material-icons/400-24px/share.svg?react';
import { showAlert } from 'mastodon/actions/alerts'; import { showAlert } from 'mastodon/actions/alerts';
@@ -79,7 +80,7 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
collection, collection,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const { name, description, tag } = collection; const { name, description, tag, account_id } = collection;
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const handleShare = useCallback(() => { const handleShare = useCallback(() => {
@@ -114,7 +115,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 extended={account_id === me}
collection={collection} collection={collection}
className={classes.metaData} className={classes.metaData}
/> />
@@ -123,6 +124,28 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
); );
}; };
const CollectionAccountItem: React.FC<{
accountId: string | undefined;
collectionOwnerId: string;
}> = ({ accountId, collectionOwnerId }) => {
const relationship = useRelationship(accountId);
if (!accountId) {
return null;
}
// When viewing your own collection, only show the Follow button
// for accounts you're not following (anymore).
// Otherwise, always show the follow button in its various states.
const withoutButton =
accountId === me ||
!relationship ||
(collectionOwnerId === me &&
(relationship.following || relationship.requested));
return <Account minimal={withoutButton} withMenu={false} id={accountId} />;
};
export const CollectionDetailPage: React.FC<{ export const CollectionDetailPage: React.FC<{
multiColumn?: boolean; multiColumn?: boolean;
}> = ({ multiColumn }) => { }> = ({ multiColumn }) => {
@@ -163,11 +186,13 @@ export const CollectionDetailPage: React.FC<{
collection ? <CollectionHeader collection={collection} /> : null collection ? <CollectionHeader collection={collection} /> : null
} }
> >
{collection?.items.map(({ account_id }) => {collection?.items.map(({ account_id }) => (
account_id ? ( <CollectionAccountItem
<Account key={account_id} minimal id={account_id} /> key={account_id}
) : null, accountId={account_id}
)} collectionOwnerId={collection.account_id}
/>
))}
</ScrollableList> </ScrollableList>
<Helmet> <Helmet>

View File

@@ -14,7 +14,7 @@ import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list'; import ScrollableList from 'mastodon/components/scrollable_list';
import { import {
fetchAccountCollections, fetchAccountCollections,
selectMyCollections, selectAccountCollections,
} from 'mastodon/reducers/slices/collections'; } from 'mastodon/reducers/slices/collections';
import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { useAppSelector, useAppDispatch } from 'mastodon/store';
@@ -31,7 +31,9 @@ export const Collections: React.FC<{
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const intl = useIntl(); const intl = useIntl();
const me = useAppSelector((state) => state.meta.get('me') as string); const me = useAppSelector((state) => state.meta.get('me') as string);
const { collections, status } = useAppSelector(selectMyCollections); const { collections, status } = useAppSelector((state) =>
selectAccountCollections(state, me),
);
useEffect(() => { useEffect(() => {
void dispatch(fetchAccountCollections({ accountId: me })); void dispatch(fetchAccountCollections({ accountId: me }));

View File

@@ -1,121 +0,0 @@
import PropTypes from 'prop-types';
import { useCallback, useEffect, useRef } from 'react';
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import { createSelector } from '@reduxjs/toolkit';
import { OrderedSet, List as ImmutableList } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { shallowEqual } from 'react-redux';
import Toggle from 'react-toggle';
import { fetchAccount } from 'mastodon/actions/accounts';
import { Button } from 'mastodon/components/button';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
placeholder: { id: 'report.placeholder', defaultMessage: 'Type or paste additional comments' },
});
const selectRepliedToAccountIds = createSelector(
[
(state) => state.get('statuses'),
(_, statusIds) => statusIds,
],
(statusesMap, statusIds) => statusIds.map((statusId) => statusesMap.getIn([statusId, 'in_reply_to_account_id'])),
{
resultEqualityCheck: shallowEqual,
}
);
const Comment = ({ comment, domain, statusIds, isRemote, isSubmitting, selectedDomains, onSubmit, onChangeComment, onToggleDomain }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const loadedRef = useRef(false);
const handleClick = useCallback(() => onSubmit(), [onSubmit]);
const handleChange = useCallback((e) => onChangeComment(e.target.value), [onChangeComment]);
const handleToggleDomain = useCallback(e => onToggleDomain(e.target.value, e.target.checked), [onToggleDomain]);
const handleKeyDown = useCallback((e) => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
handleClick();
}
}, [handleClick]);
// Memoize accountIds since we don't want it to trigger `useEffect` on each render
const accountIds = useAppSelector((state) => domain ? selectRepliedToAccountIds(state, statusIds) : ImmutableList());
// While we could memoize `availableDomains`, it is pretty inexpensive to recompute
const accountsMap = useAppSelector((state) => state.get('accounts'));
const availableDomains = domain ? OrderedSet([domain]).union(accountIds.map((accountId) => accountsMap.getIn([accountId, 'acct'], '').split('@')[1]).filter(domain => !!domain)) : OrderedSet();
useEffect(() => {
if (loadedRef.current) {
return;
}
loadedRef.current = true;
// First, pre-select known domains
availableDomains.forEach((domain) => {
onToggleDomain(domain, true);
});
// Then, fetch missing replied-to accounts
const unknownAccounts = OrderedSet(accountIds.filter(accountId => accountId && !accountsMap.has(accountId)));
unknownAccounts.forEach((accountId) => {
dispatch(fetchAccount(accountId));
});
});
return (
<>
<h3 className='report-dialog-modal__title'><FormattedMessage id='report.comment.title' defaultMessage='Is there anything else you think we should know?' /></h3>
<textarea
className='report-dialog-modal__textarea'
placeholder={intl.formatMessage(messages.placeholder)}
value={comment}
onChange={handleChange}
onKeyDown={handleKeyDown}
disabled={isSubmitting}
/>
{isRemote && (
<>
<p className='report-dialog-modal__lead'><FormattedMessage id='report.forward_hint' defaultMessage='The account is from another server. Send an anonymized copy of the report there as well?' /></p>
{ availableDomains.map((domain) => (
<label className='report-dialog-modal__toggle' key={`toggle-${domain}`}>
<Toggle checked={selectedDomains.includes(domain)} disabled={isSubmitting} onChange={handleToggleDomain} value={domain} />
<FormattedMessage id='report.forward' defaultMessage='Forward to {target}' values={{ target: domain }} />
</label>
))}
</>
)}
<div className='flex-spacer' />
<div className='report-dialog-modal__actions'>
<Button onClick={handleClick} disabled={isSubmitting}><FormattedMessage id='report.submit' defaultMessage='Submit report' /></Button>
</div>
</>
);
};
Comment.propTypes = {
comment: PropTypes.string.isRequired,
domain: PropTypes.string,
statusIds: ImmutablePropTypes.list.isRequired,
isRemote: PropTypes.bool,
isSubmitting: PropTypes.bool,
selectedDomains: ImmutablePropTypes.set.isRequired,
onSubmit: PropTypes.func.isRequired,
onChangeComment: PropTypes.func.isRequired,
onToggleDomain: PropTypes.func.isRequired,
};
export default Comment;

View File

@@ -0,0 +1,217 @@
import { useCallback, useEffect, useId, useRef } from 'react';
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import type { Map } from 'immutable';
import { OrderedSet } from 'immutable';
import { shallowEqual } from 'react-redux';
import Toggle from 'react-toggle';
import { fetchAccount } from 'mastodon/actions/accounts';
import { Button } from 'mastodon/components/button';
import type { Status } from 'mastodon/models/status';
import type { RootState } from 'mastodon/store';
import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from 'mastodon/store';
const messages = defineMessages({
placeholder: {
id: 'report.placeholder',
defaultMessage: 'Type or paste additional comments',
},
});
const selectRepliedToAccountIds = createAppSelector(
[
(state: RootState) => state.statuses,
(_: unknown, statusIds: string[]) => statusIds,
],
(statusesMap: Map<string, Status>, statusIds: string[]) =>
statusIds.map(
(statusId) =>
statusesMap.getIn([statusId, 'in_reply_to_account_id']) as string,
),
{
memoizeOptions: {
resultEqualityCheck: shallowEqual,
},
},
);
interface Props {
modalTitle?: React.ReactNode;
comment: string;
domain?: string;
statusIds: string[];
isRemote?: boolean;
isSubmitting?: boolean;
selectedDomains: string[];
submitError?: React.ReactNode;
onSubmit: () => void;
onChangeComment: (newComment: string) => void;
onToggleDomain: (toggledDomain: string, checked: boolean) => void;
}
const Comment: React.FC<Props> = ({
modalTitle,
comment,
domain,
statusIds,
isRemote,
isSubmitting,
selectedDomains,
submitError,
onSubmit,
onChangeComment,
onToggleDomain,
}) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const loadedRef = useRef(false);
const handleSubmit = useCallback(() => {
onSubmit();
}, [onSubmit]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChangeComment(e.target.value);
},
[onChangeComment],
);
const handleToggleDomain = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onToggleDomain(e.target.value, e.target.checked);
},
[onToggleDomain],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
handleSubmit();
}
},
[handleSubmit],
);
// Memoize accountIds since we don't want it to trigger `useEffect` on each render
const accountIds = useAppSelector((state) =>
domain ? selectRepliedToAccountIds(state, statusIds) : [],
);
// While we could memoize `availableDomains`, it is pretty inexpensive to recompute
const accountsMap = useAppSelector((state) => state.accounts);
const availableDomains = domain
? OrderedSet([domain]).union(
accountIds
.map(
(accountId) =>
(accountsMap.getIn([accountId, 'acct'], '') as string).split(
'@',
)[1],
)
.filter((domain): domain is string => !!domain),
)
: OrderedSet<string>();
useEffect(() => {
if (loadedRef.current) {
return;
}
loadedRef.current = true;
// First, pre-select known domains
availableDomains.forEach((domain) => {
onToggleDomain(domain, true);
});
// Then, fetch missing replied-to accounts
const unknownAccounts = OrderedSet(
accountIds.filter(
(accountId) => accountId && !accountsMap.has(accountId),
),
);
unknownAccounts.forEach((accountId) => {
dispatch(fetchAccount(accountId));
});
});
const titleId = useId();
return (
<>
<h3 className='report-dialog-modal__title' id={titleId}>
{modalTitle ?? (
<FormattedMessage
id='report.comment.title'
defaultMessage='Is there anything else you think we should know?'
/>
)}
</h3>
<textarea
className='report-dialog-modal__textarea'
placeholder={intl.formatMessage(messages.placeholder)}
aria-labelledby={titleId}
value={comment}
onChange={handleChange}
onKeyDown={handleKeyDown}
disabled={isSubmitting}
/>
{isRemote && (
<>
<p className='report-dialog-modal__lead'>
<FormattedMessage
id='report.forward_hint'
defaultMessage='The account is from another server. Send an anonymized copy of the report there as well?'
/>
</p>
{availableDomains.map((domain) => (
<label
className='report-dialog-modal__toggle'
key={`toggle-${domain}`}
htmlFor={`input-${domain}`}
>
<Toggle
checked={selectedDomains.includes(domain)}
disabled={isSubmitting}
onChange={handleToggleDomain}
value={domain}
id={`input-${domain}`}
/>
<FormattedMessage
id='report.forward'
defaultMessage='Forward to {target}'
values={{ target: domain }}
/>
</label>
))}
</>
)}
{submitError}
<div className='flex-spacer' />
<div className='report-dialog-modal__actions'>
<Button onClick={handleSubmit} disabled={isSubmitting}>
<FormattedMessage id='report.submit' defaultMessage='Submit report' />
</Button>
</div>
</>
);
};
// eslint-disable-next-line import/no-default-export
export default Comment;

View File

@@ -10,6 +10,7 @@ import {
BlockModal, BlockModal,
DomainBlockModal, DomainBlockModal,
ReportModal, ReportModal,
ReportCollectionModal,
EmbedModal, EmbedModal,
ListAdder, ListAdder,
CompareHistoryModal, CompareHistoryModal,
@@ -77,6 +78,7 @@ export const MODAL_COMPONENTS = {
'BLOCK': BlockModal, 'BLOCK': BlockModal,
'DOMAIN_BLOCK': DomainBlockModal, 'DOMAIN_BLOCK': DomainBlockModal,
'REPORT': ReportModal, 'REPORT': ReportModal,
'REPORT_COLLECTION': ReportCollectionModal,
'ACTIONS': () => Promise.resolve({ default: ActionsModal }), 'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
'EMBED': EmbedModal, 'EMBED': EmbedModal,
'FOCAL_POINT': () => Promise.resolve({ default: AltTextModal }), 'FOCAL_POINT': () => Promise.resolve({ default: AltTextModal }),
@@ -90,6 +92,7 @@ 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_FIELD_OVERFLOW': () => import('@/mastodon/features/account_timeline/modals/field_modal').then(module => ({ default: module.AccountFieldModal })),
'ACCOUNT_EDIT_NAME': () => import('@/mastodon/features/account_edit/components/name_modal').then(module => ({ default: module.NameModal })), '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 })), 'ACCOUNT_EDIT_BIO': () => import('@/mastodon/features/account_edit/components/bio_modal').then(module => ({ default: module.BioModal })),
}; };

View File

@@ -0,0 +1,173 @@
import { useCallback, useEffect, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Callout } from '@/mastodon/components/callout';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { submitReport } from 'mastodon/actions/reports';
import { fetchServer } from 'mastodon/actions/server';
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
import { Button } from 'mastodon/components/button';
import { IconButton } from 'mastodon/components/icon_button';
import { useAccount } from 'mastodon/hooks/useAccount';
import { useAppDispatch } from 'mastodon/store';
import Comment from '../../report/comment';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
const CollectionThanks: React.FC<{
onClose: () => void;
}> = ({ onClose }) => {
return (
<>
<h3 className='report-dialog-modal__title'>
<FormattedMessage
id='report.thanks.title_actionable'
defaultMessage="Thanks for reporting, we'll look into this."
/>
</h3>
<div className='flex-spacer' />
<div className='report-dialog-modal__actions'>
<Button onClick={onClose}>
<FormattedMessage id='report.close' defaultMessage='Done' />
</Button>
</div>
</>
);
};
export const ReportCollectionModal: React.FC<{
collection: ApiCollectionJSON;
onClose: () => void;
}> = ({ collection, onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { id: collectionId, name, account_id } = collection;
const account = useAccount(account_id);
useEffect(() => {
dispatch(fetchServer());
}, [dispatch]);
const [submitState, setSubmitState] = useState<
'idle' | 'submitting' | 'submitted' | 'error'
>('idle');
const [step, setStep] = useState<'comment' | 'thanks'>('comment');
const [comment, setComment] = useState('');
const [selectedDomains, setSelectedDomains] = useState<string[]>([]);
const handleDomainToggle = useCallback((domain: string, checked: boolean) => {
if (checked) {
setSelectedDomains((domains) => [...domains, domain]);
} else {
setSelectedDomains((domains) => domains.filter((d) => d !== domain));
}
}, []);
const handleSubmit = useCallback(() => {
setSubmitState('submitting');
dispatch(
submitReport(
{
account_id,
status_ids: [],
collection_ids: [collectionId],
forward_to_domains: selectedDomains,
comment,
forward: selectedDomains.length > 0,
category: 'spam',
},
() => {
setSubmitState('submitted');
setStep('thanks');
},
() => {
setSubmitState('error');
},
),
);
}, [account_id, comment, dispatch, collectionId, selectedDomains]);
if (!account) {
return null;
}
const domain = account.get('acct').split('@')[1];
const isRemote = !!domain;
let stepComponent;
switch (step) {
case 'comment':
stepComponent = (
<Comment
modalTitle={
<FormattedMessage
id='report.collection_comment'
defaultMessage='Why do you want to report this collection?'
/>
}
submitError={
submitState === 'error' && (
<Callout
variant='error'
title={
<FormattedMessage
id='report.submission_error'
defaultMessage='Report could not be submitted'
/>
}
>
<FormattedMessage
id='report.submission_error_details'
defaultMessage='Please check your network connection and try again later.'
/>
</Callout>
)
}
onSubmit={handleSubmit}
isSubmitting={submitState === 'submitting'}
isRemote={isRemote}
comment={comment}
domain={domain}
onChangeComment={setComment}
statusIds={[]}
selectedDomains={selectedDomains}
onToggleDomain={handleDomainToggle}
/>
);
break;
case 'thanks':
stepComponent = <CollectionThanks onClose={onClose} />;
}
return (
<div className='modal-root__modal report-dialog-modal'>
<div className='report-modal__target'>
<IconButton
className='report-modal__close'
title={intl.formatMessage(messages.close)}
icon='times'
iconComponent={CloseIcon}
onClick={onClose}
/>
<FormattedMessage
id='report.target'
defaultMessage='Report {target}'
values={{ target: <strong>{name}</strong> }}
/>
</div>
<div className='report-dialog-modal__container'>{stepComponent}</div>
</div>
);
};

View File

@@ -22,7 +22,7 @@ import { identityContextPropShape, withIdentity } from 'mastodon/identity_contex
import { layoutFromWindow } from 'mastodon/is_mobile'; import { layoutFromWindow } from 'mastodon/is_mobile';
import { WithRouterPropTypes } from 'mastodon/utils/react_router'; import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import { checkAnnualReport } from '@/mastodon/reducers/slices/annual_report'; import { checkAnnualReport } from '@/mastodon/reducers/slices/annual_report';
import { isClientFeatureEnabled, isServerFeatureEnabled } from '@/mastodon/utils/environment'; import { isClientFeatureEnabled } from '@/mastodon/utils/environment';
import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose'; import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose';
import { clearHeight } from '../../actions/height_cache'; import { clearHeight } from '../../actions/height_cache';
@@ -80,7 +80,6 @@ import {
PrivacyPolicy, PrivacyPolicy,
TermsOfService, TermsOfService,
AccountFeatured, AccountFeatured,
AccountAbout,
AccountEdit, AccountEdit,
AccountEditFeaturedTags, AccountEditFeaturedTags,
Quotes, Quotes,
@@ -166,36 +165,6 @@ class SwitchingColumnsArea extends PureComponent {
} }
const profileRedesignRoutes = []; const profileRedesignRoutes = [];
if (isServerFeatureEnabled('profile_redesign')) {
profileRedesignRoutes.push(
<WrappedRoute key="posts" path={['/@:acct/posts', '/accounts/:id/posts']} exact component={AccountTimeline} content={children} />,
);
// Check if we're in single-column mode. Confusingly, the singleColumn prop includes mobile.
if (this.props.layout === 'single-column') {
// When in single column mode (desktop w/o advanced view), redirect both the root and about to the posts tab.
profileRedesignRoutes.push(
<Redirect key="acct-redirect" from='/@:acct' to='/@:acct/posts' exact />,
<Redirect key="id-redirect" from='/accounts/:id' to='/accounts/:id/posts' exact />,
<Redirect key="about-acct-redirect" from='/@:acct/about' to='/@:acct/posts' exact />,
<Redirect key="about-id-redirect" from='/accounts/:id/about' to='/accounts/:id/posts' exact />,
);
} else {
// Otherwise, provide and redirect to the /about page.
profileRedesignRoutes.push(
<WrappedRoute key="about" path={['/@:acct/about', '/accounts/:id/about']} component={AccountAbout} content={children} />,
<Redirect key="acct-redirect" from='/@:acct' to='/@:acct/about' exact />,
<Redirect key="id-redirect" from='/accounts/:id' to='/accounts/:id/about' exact />
);
}
} else {
profileRedesignRoutes.push(
<WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />,
// If the redesign is not enabled but someone shares an /about link, redirect to the root.
<Redirect key="about-acct-redirect" from='/@:acct/about' to='/@:acct' exact />,
<Redirect key="about-id-redirect" from='/accounts/:id/about' to='/accounts/:id' exact />
);
}
if (isClientFeatureEnabled('profile_editing')) { if (isClientFeatureEnabled('profile_editing')) {
profileRedesignRoutes.push( profileRedesignRoutes.push(
<WrappedRoute key="edit" path='/profile/edit' component={AccountEdit} content={children} />, <WrappedRoute key="edit" path='/profile/edit' component={AccountEdit} content={children} />,
@@ -257,6 +226,7 @@ class SwitchingColumnsArea extends PureComponent {
{...profileRedesignRoutes} {...profileRedesignRoutes}
<WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />
<WrappedRoute path={['/@:acct/featured', '/accounts/:id/featured']} component={AccountFeatured} content={children} /> <WrappedRoute path={['/@:acct/featured', '/accounts/:id/featured']} component={AccountFeatured} content={children} />
<WrappedRoute path='/@:acct/tagged/:tagged?' exact component={AccountTimeline} content={children} /> <WrappedRoute path='/@:acct/tagged/:tagged?' exact component={AccountTimeline} content={children} />
<WrappedRoute path={['/@:acct/with_replies', '/accounts/:id/with_replies']} component={AccountTimeline} content={children} componentParams={{ withReplies: true }} /> <WrappedRoute path={['/@:acct/with_replies', '/accounts/:id/with_replies']} component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />

View File

@@ -93,11 +93,6 @@ export function AccountFeatured() {
return import('../../account_featured'); return import('../../account_featured');
} }
export function AccountAbout() {
return import('../../account_about')
.then((module) => ({ default: module.AccountAbout }));
}
export function AccountEdit() { export function AccountEdit() {
return import('../../account_edit') return import('../../account_edit')
.then((module) => ({ default: module.AccountEdit })); .then((module) => ({ default: module.AccountEdit }));
@@ -172,6 +167,11 @@ export function ReportModal () {
return import('../components/report_modal'); return import('../components/report_modal');
} }
export function ReportCollectionModal () {
return import('../components/report_collection_modal')
.then((module) => ({ default: module.ReportCollectionModal }));;
}
export function IgnoreNotificationsModal () { export function IgnoreNotificationsModal () {
return import('../components/ignore_notifications_modal'); return import('../components/ignore_notifications_modal');
} }

View File

@@ -0,0 +1,29 @@
import { useEffect, useRef } from 'react';
export function useResizeObserver(callback: ResizeObserverCallback) {
const observerRef = useRef<ResizeObserver | null>(null);
observerRef.current ??= new ResizeObserver(callback);
useEffect(() => {
const observer = observerRef.current;
return () => {
observer?.disconnect();
};
}, []);
return observerRef.current;
}
export function useMutationObserver(callback: MutationCallback) {
const observerRef = useRef<MutationObserver | null>(null);
observerRef.current ??= new MutationObserver(callback);
useEffect(() => {
const observer = observerRef.current;
return () => {
observer?.disconnect();
};
}, []);
return observerRef.current;
}

View File

@@ -1,6 +1,8 @@
import type { MutableRefObject, RefCallback } from 'react'; import type { MutableRefObject, RefCallback } from 'react';
import { useState, useRef, useCallback, useEffect } from 'react'; import { useState, useRef, useCallback, useEffect } from 'react';
import { useMutationObserver, useResizeObserver } from './useObserver';
/** /**
* Hook to manage overflow of items in a container with a "more" button. * Hook to manage overflow of items in a container with a "more" button.
* *
@@ -182,48 +184,30 @@ export function useOverflowObservers({
// This is the item container element. // This is the item container element.
const listRef = useRef<HTMLElement | null>(null); const listRef = useRef<HTMLElement | null>(null);
// Set up observers to watch for size and content changes. const resizeObserver = useResizeObserver(onRecalculate);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const mutationObserverRef = useRef<MutationObserver | null>(null);
// Helper to get or create the resize observer.
const resizeObserver = useCallback(() => {
const observer = (resizeObserverRef.current ??= new ResizeObserver(
onRecalculate,
));
return observer;
}, [onRecalculate]);
// Iterate through children and observe them for size changes. // Iterate through children and observe them for size changes.
const handleChildrenChange = useCallback(() => { const handleChildrenChange = useCallback(() => {
const listEle = listRef.current; const listEle = listRef.current;
const observer = resizeObserver();
if (listEle) { if (listEle) {
for (const child of listEle.children) { for (const child of listEle.children) {
if (child instanceof HTMLElement) { if (child instanceof HTMLElement) {
observer.observe(child); resizeObserver.observe(child);
} }
} }
} }
onRecalculate(); onRecalculate();
}, [onRecalculate, resizeObserver]); }, [onRecalculate, resizeObserver]);
// Helper to get or create the mutation observer. const mutationObserver = useMutationObserver(handleChildrenChange);
const mutationObserver = useCallback(() => {
const observer = (mutationObserverRef.current ??= new MutationObserver(
handleChildrenChange,
));
return observer;
}, [handleChildrenChange]);
// Set up observers. // Set up observers.
const handleObserve = useCallback(() => { const handleObserve = useCallback(() => {
if (wrapperRef.current) { if (wrapperRef.current) {
resizeObserver().observe(wrapperRef.current); resizeObserver.observe(wrapperRef.current);
} }
if (listRef.current) { if (listRef.current) {
mutationObserver().observe(listRef.current, { childList: true }); mutationObserver.observe(listRef.current, { childList: true });
handleChildrenChange(); handleChildrenChange();
} }
}, [handleChildrenChange, mutationObserver, resizeObserver]); }, [handleChildrenChange, mutationObserver, resizeObserver]);
@@ -233,12 +217,12 @@ export function useOverflowObservers({
const wrapperRefCallback = useCallback( const wrapperRefCallback = useCallback(
(node: HTMLElement | null) => { (node: HTMLElement | null) => {
if (node) { if (node) {
wrapperRef.current = node; wrapperRef.current = node; // eslint-disable-line react-hooks/immutability -- https://github.com/facebook/react/issues/34955
handleObserve(); handleObserve();
if (typeof onWrapperRef === 'function') { if (typeof onWrapperRef === 'function') {
onWrapperRef(node); onWrapperRef(node);
} else if (onWrapperRef && 'current' in onWrapperRef) { } else if (onWrapperRef && 'current' in onWrapperRef) {
onWrapperRef.current = node; onWrapperRef.current = node; // eslint-disable-line react-hooks/immutability -- https://github.com/facebook/react/issues/34955
} }
} }
}, },
@@ -254,28 +238,13 @@ export function useOverflowObservers({
if (typeof onListRef === 'function') { if (typeof onListRef === 'function') {
onListRef(node); onListRef(node);
} else if (onListRef && 'current' in onListRef) { } else if (onListRef && 'current' in onListRef) {
onListRef.current = node; onListRef.current = node; // eslint-disable-line react-hooks/immutability -- https://github.com/facebook/react/issues/34955
} }
} }
}, },
[handleObserve, onListRef], [handleObserve, onListRef],
); );
useEffect(() => {
handleObserve();
return () => {
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect();
resizeObserverRef.current = null;
}
if (mutationObserverRef.current) {
mutationObserverRef.current.disconnect();
mutationObserverRef.current = null;
}
};
}, [handleObserve]);
return { return {
wrapperRefCallback, wrapperRefCallback,
listRefCallback, listRefCallback,

View File

@@ -167,7 +167,7 @@
"account_edit_tags.help_text": "Fremhævede hashtags hjælper brugere med at finde og interagere med din profil. De vises som filtre i aktivitetsvisningen på din profilside.", "account_edit_tags.help_text": "Fremhævede hashtags hjælper brugere med at finde og interagere med din profil. De vises som filtre i aktivitetsvisningen på din profilside.",
"account_edit_tags.search_placeholder": "Angiv et hashtag…", "account_edit_tags.search_placeholder": "Angiv et hashtag…",
"account_edit_tags.suggestions": "Forslag:", "account_edit_tags.suggestions": "Forslag:",
"account_edit_tags.tag_status_count": "{count} indlæg", "account_edit_tags.tag_status_count": "{count, plural, one {# indlæg} other {# indlæg}}",
"account_note.placeholder": "Klik for at tilføje notat", "account_note.placeholder": "Klik for at tilføje notat",
"admin.dashboard.daily_retention": "Brugerfastholdelsesrate pr. dag efter tilmelding", "admin.dashboard.daily_retention": "Brugerfastholdelsesrate pr. dag efter tilmelding",
"admin.dashboard.monthly_retention": "Brugerfastholdelsesrate pr. måned efter tilmelding", "admin.dashboard.monthly_retention": "Brugerfastholdelsesrate pr. måned efter tilmelding",
@@ -306,11 +306,13 @@
"collections.no_collections_yet": "Ingen samlinger endnu.", "collections.no_collections_yet": "Ingen samlinger endnu.",
"collections.old_last_post_note": "Seneste indlæg er fra over en uge siden", "collections.old_last_post_note": "Seneste indlæg er fra over en uge siden",
"collections.remove_account": "Fjern denne konto", "collections.remove_account": "Fjern denne konto",
"collections.report_collection": "Anmeld denne samling",
"collections.search_accounts_label": "Søg efter konti for at tilføje…", "collections.search_accounts_label": "Søg efter konti for at tilføje…",
"collections.search_accounts_max_reached": "Du har tilføjet det maksimale antal konti", "collections.search_accounts_max_reached": "Du har tilføjet det maksimale antal konti",
"collections.sensitive": "Sensitivt", "collections.sensitive": "Sensitivt",
"collections.topic_hint": "Tilføj et hashtag, der hjælper andre med at forstå det overordnede emne for denne samling.", "collections.topic_hint": "Tilføj et hashtag, der hjælper andre med at forstå det overordnede emne for denne samling.",
"collections.view_collection": "Vis samling", "collections.view_collection": "Vis samling",
"collections.view_other_collections_by_user": "Se andre samlinger af denne bruger",
"collections.visibility_public": "Offentlig", "collections.visibility_public": "Offentlig",
"collections.visibility_public_hint": "Kan opdages i søgeresultater og andre områder, hvor anbefalinger vises.", "collections.visibility_public_hint": "Kan opdages i søgeresultater og andre områder, hvor anbefalinger vises.",
"collections.visibility_title": "Synlighed", "collections.visibility_title": "Synlighed",
@@ -976,6 +978,7 @@
"report.category.title_account": "profil", "report.category.title_account": "profil",
"report.category.title_status": "indlæg", "report.category.title_status": "indlæg",
"report.close": "Udført", "report.close": "Udført",
"report.collection_comment": "Hvorfor vil du anmelde denne samling?",
"report.comment.title": "Er der andet, som vi bør vide?", "report.comment.title": "Er der andet, som vi bør vide?",
"report.forward": "Videresend til {target}", "report.forward": "Videresend til {target}",
"report.forward_hint": "Kontoen er fra en anden server. Send også en anonymiseret kopi af anmeldelsen dertil?", "report.forward_hint": "Kontoen er fra en anden server. Send også en anonymiseret kopi af anmeldelsen dertil?",
@@ -997,6 +1000,8 @@
"report.rules.title": "Hvilke regler overtrædes?", "report.rules.title": "Hvilke regler overtrædes?",
"report.statuses.subtitle": "Vælg alle relevante", "report.statuses.subtitle": "Vælg alle relevante",
"report.statuses.title": "Er der indlæg, som kan bekræfte denne anmeldelse?", "report.statuses.title": "Er der indlæg, som kan bekræfte denne anmeldelse?",
"report.submission_error": "Anmeldelse kunne ikke indsendes",
"report.submission_error_details": "Tjek din netværksforbindelse eller prøv igen senere.",
"report.submit": "Indsend", "report.submit": "Indsend",
"report.target": "Anmelder {target}", "report.target": "Anmelder {target}",
"report.thanks.take_action": "Her er mulighederne for at styre, hvad du ser på Mastodon:", "report.thanks.take_action": "Her er mulighederne for at styre, hvad du ser på Mastodon:",

View File

@@ -88,7 +88,7 @@
"account.menu.hide_reblogs": "Geteilte Beiträge in der Timeline ausblenden", "account.menu.hide_reblogs": "Geteilte Beiträge in der Timeline ausblenden",
"account.menu.mention": "Erwähnen", "account.menu.mention": "Erwähnen",
"account.menu.mute": "Konto stummschalten", "account.menu.mute": "Konto stummschalten",
"account.menu.note.description": "Nur für Sie sichtbar", "account.menu.note.description": "Nur für dich sichtbar",
"account.menu.open_original_page": "Auf {domain} ansehen", "account.menu.open_original_page": "Auf {domain} ansehen",
"account.menu.remove_follower": "Follower entfernen", "account.menu.remove_follower": "Follower entfernen",
"account.menu.report": "Konto melden", "account.menu.report": "Konto melden",
@@ -104,11 +104,11 @@
"account.muted": "Stummgeschaltet", "account.muted": "Stummgeschaltet",
"account.muting": "Stummgeschaltet", "account.muting": "Stummgeschaltet",
"account.mutual": "Ihr folgt einander", "account.mutual": "Ihr folgt einander",
"account.name.help.domain": "{domain} ist der Server, auf dem das Profil und die Beiträge des Benutzers gespeichert sind.", "account.name.help.domain": "{domain} ist der Server, auf dem das Profil registriert ist und die Beiträge verwaltet werden.",
"account.name.help.domain_self": "{domain} ist Ihr Server, auf dem Ihr Profil und Ihre Beiträge gespeichert sind.", "account.name.help.domain_self": "{domain} ist der Server, auf dem du registriert bist und deine Beiträge verwaltet werden.",
"account.name.help.footer": "Genauso wie Sie E-Mails über verschiedene E-Mail-Anbieter versenden können, Sie können auch mit Personen auf anderen Mastodon Servers interagieren und mit allen auf anderen sozialen Apps, die nach denselben Regeln wie Mastodon funktionieren (dem ActivityPub Protokoll).", "account.name.help.footer": "So wie du E-Mails an andere trotz unterschiedlicher E-Mail-Clients senden kannst, so kannst du auch mit anderen Profilen auf unterschiedlichen Mastodon-Servern interagieren. Wenn andere soziale Apps die gleichen Kommunikationsregeln (das ActivityPub-Protokoll) wie Mastodon verwenden, dann funktioniert die Kommunikation auch dort.",
"account.name.help.header": "Deine Adresse im Fediverse ist wie eine E-Mail-Adresse", "account.name.help.header": "Deine Adresse im Fediverse ist wie eine E-Mail-Adresse",
"account.name.help.username": "{username} ist der Profilname dieses Kontos auf diesem Server. Jemand auf einem anderen Server könnte denselben Profilnamen haben.", "account.name.help.username": "{username} ist der Profilname auf deren Server. Es ist möglich, dass jemand auf einem anderen Server den gleichen Profilnamen hat.",
"account.name.help.username_self": "{username} ist dein Profilname auf diesem Server. Es ist möglich, dass jemand auf einem anderen Server den gleichen Profilnamen hat.", "account.name.help.username_self": "{username} ist dein Profilname auf diesem Server. Es ist möglich, dass jemand auf einem anderen Server den gleichen Profilnamen hat.",
"account.name_info": "Was bedeutet das?", "account.name_info": "Was bedeutet das?",
"account.no_bio": "Keine Beschreibung verfügbar.", "account.no_bio": "Keine Beschreibung verfügbar.",
@@ -153,10 +153,10 @@
"account_edit.column_title": "Profil bearbeiten", "account_edit.column_title": "Profil bearbeiten",
"account_edit.custom_fields.placeholder": "Ergänze deine Pronomen, weiterführenden Links oder etwas anderes, das du teilen möchtest.", "account_edit.custom_fields.placeholder": "Ergänze deine Pronomen, weiterführenden Links oder etwas anderes, das du teilen möchtest.",
"account_edit.custom_fields.title": "Zusatzfelder", "account_edit.custom_fields.title": "Zusatzfelder",
"account_edit.display_name.placeholder": "Ihr Anzeigename ist der Name, der in Ihrem Profil und auf Ihrer \"Timeline\" angezeigt wird.", "account_edit.display_name.placeholder": "Dein Anzeigename wird auf deinem Profil und in Timelines angezeigt.",
"account_edit.display_name.title": "Anzeigename", "account_edit.display_name.title": "Anzeigename",
"account_edit.featured_hashtags.item": "Hashtags", "account_edit.featured_hashtags.item": "Hashtags",
"account_edit.featured_hashtags.placeholder": "Helfen Sie anderen dabei, Ihre Lieblingsthemen zu identifizieren und schnell darauf zuzugreifen.", "account_edit.featured_hashtags.placeholder": "Präsentiere deine Lieblingsthemen und ermögliche anderen einen schnellen Zugriff darauf.",
"account_edit.featured_hashtags.title": "Vorgestellte Hashtags", "account_edit.featured_hashtags.title": "Vorgestellte Hashtags",
"account_edit.name_modal.add_title": "Anzeigenamen hinzufügen", "account_edit.name_modal.add_title": "Anzeigenamen hinzufügen",
"account_edit.name_modal.edit_title": "Anzeigenamen bearbeiten", "account_edit.name_modal.edit_title": "Anzeigenamen bearbeiten",
@@ -164,10 +164,10 @@
"account_edit.profile_tab.title": "Profil-Tab-Einstellungen", "account_edit.profile_tab.title": "Profil-Tab-Einstellungen",
"account_edit.save": "Speichern", "account_edit.save": "Speichern",
"account_edit_tags.column_title": "Vorgestellte Hashtags bearbeiten", "account_edit_tags.column_title": "Vorgestellte Hashtags bearbeiten",
"account_edit_tags.help_text": "Vorgestellte Hashtags können dabei helfen, dein Profil zu entdecken und Kontakt mit dir aufzunehmen. Diese erscheinen als Filter in der Aktivitätenübersicht deines Profils.", "account_edit_tags.help_text": "Vorgestellte Hashtags können dabei helfen, dein Profil zu entdecken und besser mit dir zu interagieren. Sie erscheinen in der Aktivitätenübersicht deines Profils und dienen als Filter.",
"account_edit_tags.search_placeholder": "Gib einen Hashtag ein …", "account_edit_tags.search_placeholder": "Gib einen Hashtag ein …",
"account_edit_tags.suggestions": "Vorschläge:", "account_edit_tags.suggestions": "Vorschläge:",
"account_edit_tags.tag_status_count": "{count} Beiträge", "account_edit_tags.tag_status_count": "{count, plural, one {# Beitrag} other {# Beiträge}}",
"account_note.placeholder": "Klicken, um private Anmerkung hinzuzufügen", "account_note.placeholder": "Klicken, um private Anmerkung hinzuzufügen",
"admin.dashboard.daily_retention": "Verweildauer der Nutzer*innen pro Tag seit der Registrierung", "admin.dashboard.daily_retention": "Verweildauer der Nutzer*innen pro Tag seit der Registrierung",
"admin.dashboard.monthly_retention": "Verweildauer der Nutzer*innen pro Monat seit der Registrierung", "admin.dashboard.monthly_retention": "Verweildauer der Nutzer*innen pro Monat seit der Registrierung",
@@ -306,11 +306,13 @@
"collections.no_collections_yet": "Bisher keine Sammlungen vorhanden.", "collections.no_collections_yet": "Bisher keine Sammlungen vorhanden.",
"collections.old_last_post_note": "Neuester Beitrag mehr als eine Woche alt", "collections.old_last_post_note": "Neuester Beitrag mehr als eine Woche alt",
"collections.remove_account": "Dieses Konto entfernen", "collections.remove_account": "Dieses Konto entfernen",
"collections.report_collection": "Sammlung melden",
"collections.search_accounts_label": "Suche nach Konten, um sie hinzuzufügen …", "collections.search_accounts_label": "Suche nach Konten, um sie hinzuzufügen …",
"collections.search_accounts_max_reached": "Du hast die Höchstzahl an Konten hinzugefügt", "collections.search_accounts_max_reached": "Du hast die Höchstzahl an Konten hinzugefügt",
"collections.sensitive": "Inhaltswarnung", "collections.sensitive": "Inhaltswarnung",
"collections.topic_hint": "Ein Hashtag für diese Sammlung kann anderen dabei helfen, dein Anliegen besser einordnen zu können.", "collections.topic_hint": "Ein Hashtag für diese Sammlung kann anderen dabei helfen, dein Anliegen besser einordnen zu können.",
"collections.view_collection": "Sammlungen anzeigen", "collections.view_collection": "Sammlungen anzeigen",
"collections.view_other_collections_by_user": "Andere Sammlungen dieses Kontos ansehen",
"collections.visibility_public": "Öffentlich", "collections.visibility_public": "Öffentlich",
"collections.visibility_public_hint": "Wird in den Suchergebnissen und anderen Bereichen mit Empfehlungen angezeigt.", "collections.visibility_public_hint": "Wird in den Suchergebnissen und anderen Bereichen mit Empfehlungen angezeigt.",
"collections.visibility_title": "Sichtbarkeit", "collections.visibility_title": "Sichtbarkeit",
@@ -976,6 +978,7 @@
"report.category.title_account": "Profil", "report.category.title_account": "Profil",
"report.category.title_status": "Beitrag", "report.category.title_status": "Beitrag",
"report.close": "Fertig", "report.close": "Fertig",
"report.collection_comment": "Weshalb möchtest du diese Sammlung melden?",
"report.comment.title": "Gibt es noch etwas, das wir wissen sollten?", "report.comment.title": "Gibt es noch etwas, das wir wissen sollten?",
"report.forward": "Meldung auch an den externen Server {target} weiterleiten", "report.forward": "Meldung auch an den externen Server {target} weiterleiten",
"report.forward_hint": "Das gemeldete Konto befindet sich auf einem anderen Server. Soll zusätzlich eine anonymisierte Kopie deiner Meldung an diesen Server geschickt werden?", "report.forward_hint": "Das gemeldete Konto befindet sich auf einem anderen Server. Soll zusätzlich eine anonymisierte Kopie deiner Meldung an diesen Server geschickt werden?",
@@ -997,6 +1000,8 @@
"report.rules.title": "Gegen welche Regeln wurde verstoßen?", "report.rules.title": "Gegen welche Regeln wurde verstoßen?",
"report.statuses.subtitle": "Wähle alle zutreffenden Inhalte aus", "report.statuses.subtitle": "Wähle alle zutreffenden Inhalte aus",
"report.statuses.title": "Gibt es Beiträge, die diese Meldung stützen?", "report.statuses.title": "Gibt es Beiträge, die diese Meldung stützen?",
"report.submission_error": "Meldung konnte nicht übermittelt werden",
"report.submission_error_details": "Bitte prüfe deine Internetverbindung und probiere es später erneut.",
"report.submit": "Senden", "report.submit": "Senden",
"report.target": "{target} melden", "report.target": "{target} melden",
"report.thanks.take_action": "Das sind deine Möglichkeiten zu bestimmen, was du auf Mastodon sehen möchtest:", "report.thanks.take_action": "Das sind deine Möglichkeiten zu bestimmen, was du auf Mastodon sehen möchtest:",

View File

@@ -167,7 +167,7 @@
"account_edit_tags.help_text": "Οι αναδεδειγμένες ετικέτες βοηθούν τους χρήστες να ανακαλύψουν και να αλληλεπιδράσουν με το προφίλ σας. Εμφανίζονται ως φίλτρα στην προβολή Δραστηριότητας της σελίδας προφίλ σας.", "account_edit_tags.help_text": "Οι αναδεδειγμένες ετικέτες βοηθούν τους χρήστες να ανακαλύψουν και να αλληλεπιδράσουν με το προφίλ σας. Εμφανίζονται ως φίλτρα στην προβολή Δραστηριότητας της σελίδας προφίλ σας.",
"account_edit_tags.search_placeholder": "Εισάγετε μια ετικέτα…", "account_edit_tags.search_placeholder": "Εισάγετε μια ετικέτα…",
"account_edit_tags.suggestions": "Προτάσεις:", "account_edit_tags.suggestions": "Προτάσεις:",
"account_edit_tags.tag_status_count": "{count} αναρτήσεις", "account_edit_tags.tag_status_count": "{count, plural, one {# ανάρτηση} other {# αναρτήσεις}}",
"account_note.placeholder": "Κάνε κλικ για να προσθέσεις σημείωση", "account_note.placeholder": "Κάνε κλικ για να προσθέσεις σημείωση",
"admin.dashboard.daily_retention": "Ποσοστό χρηστών που παραμένουν μετά την εγγραφή, ανά ημέρα", "admin.dashboard.daily_retention": "Ποσοστό χρηστών που παραμένουν μετά την εγγραφή, ανά ημέρα",
"admin.dashboard.monthly_retention": "Ποσοστό χρηστών που παραμένουν μετά την εγγραφή, ανά μήνα", "admin.dashboard.monthly_retention": "Ποσοστό χρηστών που παραμένουν μετά την εγγραφή, ανά μήνα",
@@ -306,11 +306,13 @@
"collections.no_collections_yet": "Καμία συλλογή ακόμη.", "collections.no_collections_yet": "Καμία συλλογή ακόμη.",
"collections.old_last_post_note": "Τελευταία ανάρτηση πριν από μια εβδομάδα", "collections.old_last_post_note": "Τελευταία ανάρτηση πριν από μια εβδομάδα",
"collections.remove_account": "Αφαίρεση λογαριασμού", "collections.remove_account": "Αφαίρεση λογαριασμού",
"collections.report_collection": "Αναφορά αυτής της συλλογής",
"collections.search_accounts_label": "Αναζήτηση λογαριασμών για προσθήκη…", "collections.search_accounts_label": "Αναζήτηση λογαριασμών για προσθήκη…",
"collections.search_accounts_max_reached": "Έχετε προσθέσει τον μέγιστο αριθμό λογαριασμών", "collections.search_accounts_max_reached": "Έχετε προσθέσει τον μέγιστο αριθμό λογαριασμών",
"collections.sensitive": "Ευαίσθητο", "collections.sensitive": "Ευαίσθητο",
"collections.topic_hint": "Προσθέστε μια ετικέτα που βοηθά άλλους να κατανοήσουν το κύριο θέμα αυτής της συλλογής.", "collections.topic_hint": "Προσθέστε μια ετικέτα που βοηθά άλλους να κατανοήσουν το κύριο θέμα αυτής της συλλογής.",
"collections.view_collection": "Προβολή συλλογής", "collections.view_collection": "Προβολή συλλογής",
"collections.view_other_collections_by_user": "Δείτε άλλες συλλογές από αυτόν τον χρήστη",
"collections.visibility_public": "Δημόσια", "collections.visibility_public": "Δημόσια",
"collections.visibility_public_hint": "Ανιχνεύσιμη στα αποτελέσματα αναζήτησης και σε άλλα σημεία όπου εμφανίζονται προτάσεις.", "collections.visibility_public_hint": "Ανιχνεύσιμη στα αποτελέσματα αναζήτησης και σε άλλα σημεία όπου εμφανίζονται προτάσεις.",
"collections.visibility_title": "Ορατότητα", "collections.visibility_title": "Ορατότητα",
@@ -976,13 +978,14 @@
"report.category.title_account": "προφίλ", "report.category.title_account": "προφίλ",
"report.category.title_status": "ανάρτηση", "report.category.title_status": "ανάρτηση",
"report.close": "Τέλος", "report.close": "Τέλος",
"report.collection_comment": "Γιατί θέλετε να αναφέρετε αυτήν τη συλλογή;",
"report.comment.title": "Υπάρχει κάτι άλλο που νομίζεις ότι θα πρέπει να γνωρίζουμε;", "report.comment.title": "Υπάρχει κάτι άλλο που νομίζεις ότι θα πρέπει να γνωρίζουμε;",
"report.forward": "Προώθηση προς {target}", "report.forward": "Προώθηση προς {target}",
"report.forward_hint": "Ο λογαριασμός είναι από διαφορετικό διακομιστή. Να σταλεί ανώνυμο αντίγραφο της αναφοράς και εκεί;", "report.forward_hint": "Ο λογαριασμός είναι από διαφορετικό διακομιστή. Να σταλεί ανώνυμο αντίγραφο της αναφοράς και εκεί;",
"report.mute": "Σίγαση", "report.mute": "Σίγαση",
"report.mute_explanation": "Δεν θα βλέπεις τις αναρτήσεις τους. Εκείνοι μπορούν ακόμη να σε ακολουθούν και να βλέπουν τις αναρτήσεις σου χωρίς να γνωρίζουν ότι είναι σε σίγαση.", "report.mute_explanation": "Δεν θα βλέπεις τις αναρτήσεις τους. Εκείνοι μπορούν ακόμη να σε ακολουθούν και να βλέπουν τις αναρτήσεις σου χωρίς να γνωρίζουν ότι είναι σε σίγαση.",
"report.next": "Επόμενο", "report.next": "Επόμενο",
"report.placeholder": "Επιπλέον σχόλια", "report.placeholder": "Επιπρόσθετα σχόλια",
"report.reasons.dislike": "Δεν μου αρέσει", "report.reasons.dislike": "Δεν μου αρέσει",
"report.reasons.dislike_description": "Δεν είναι κάτι που θα ήθελες να δεις", "report.reasons.dislike_description": "Δεν είναι κάτι που θα ήθελες να δεις",
"report.reasons.legal": "Είναι παράνομο", "report.reasons.legal": "Είναι παράνομο",
@@ -997,6 +1000,8 @@
"report.rules.title": "Ποιοι κανόνες παραβιάζονται;", "report.rules.title": "Ποιοι κανόνες παραβιάζονται;",
"report.statuses.subtitle": "Επίλεξε όλα όσα ισχύουν", "report.statuses.subtitle": "Επίλεξε όλα όσα ισχύουν",
"report.statuses.title": "Υπάρχουν αναρτήσεις που τεκμηριώνουν αυτή την αναφορά;", "report.statuses.title": "Υπάρχουν αναρτήσεις που τεκμηριώνουν αυτή την αναφορά;",
"report.submission_error": "Δεν ήταν δυνατή η υποβολή της αναφοράς",
"report.submission_error_details": "Παρακαλούμε ελέγξτε τη σύνδεση δικτύου σας και προσπαθήστε ξανά αργότερα.",
"report.submit": "Υποβολή", "report.submit": "Υποβολή",
"report.target": "Αναφορά {target}", "report.target": "Αναφορά {target}",
"report.thanks.take_action": "Αυτές είναι οι επιλογές σας για να ελέγχετε τι βλέπετε στο Mastodon:", "report.thanks.take_action": "Αυτές είναι οι επιλογές σας για να ελέγχετε τι βλέπετε στο Mastodon:",

View File

@@ -167,7 +167,7 @@
"account_edit_tags.help_text": "Featured hashtags help users discover and interact with your profile. They appear as filters on your Profile pages Activity view.", "account_edit_tags.help_text": "Featured hashtags help users discover and interact with your profile. They appear as filters on your Profile pages Activity view.",
"account_edit_tags.search_placeholder": "Enter a hashtag…", "account_edit_tags.search_placeholder": "Enter a hashtag…",
"account_edit_tags.suggestions": "Suggestions:", "account_edit_tags.suggestions": "Suggestions:",
"account_edit_tags.tag_status_count": "{count} posts", "account_edit_tags.tag_status_count": "{count, plural, one {# post} other {# posts}}",
"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",
@@ -306,11 +306,13 @@
"collections.no_collections_yet": "No collections yet.", "collections.no_collections_yet": "No collections yet.",
"collections.old_last_post_note": "Last posted over a week ago", "collections.old_last_post_note": "Last posted over a week ago",
"collections.remove_account": "Remove this account", "collections.remove_account": "Remove this account",
"collections.report_collection": "Report this collection",
"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.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.view_other_collections_by_user": "View other collections by this user",
"collections.visibility_public": "Public", "collections.visibility_public": "Public",
"collections.visibility_public_hint": "Discoverable in search results and other areas where recommendations appear.", "collections.visibility_public_hint": "Discoverable in search results and other areas where recommendations appear.",
"collections.visibility_title": "Visibility", "collections.visibility_title": "Visibility",
@@ -976,6 +978,7 @@
"report.category.title_account": "profile", "report.category.title_account": "profile",
"report.category.title_status": "post", "report.category.title_status": "post",
"report.close": "Done", "report.close": "Done",
"report.collection_comment": "Why do you want to report this collection?",
"report.comment.title": "Is there anything else you think we should know?", "report.comment.title": "Is there anything else you think we should know?",
"report.forward": "Forward to {target}", "report.forward": "Forward to {target}",
"report.forward_hint": "The account is from another server. Send an anonymised copy of the report there as well?", "report.forward_hint": "The account is from another server. Send an anonymised copy of the report there as well?",
@@ -997,6 +1000,8 @@
"report.rules.title": "Which rules are being violated?", "report.rules.title": "Which rules are being violated?",
"report.statuses.subtitle": "Select all that apply", "report.statuses.subtitle": "Select all that apply",
"report.statuses.title": "Are there any posts that back up this report?", "report.statuses.title": "Are there any posts that back up this report?",
"report.submission_error": "Report could not be submitted",
"report.submission_error_details": "Please check your network connection and try again later.",
"report.submit": "Submit", "report.submit": "Submit",
"report.target": "Reporting {target}", "report.target": "Reporting {target}",
"report.thanks.take_action": "Here are your options for controlling what you see on Mastodon:", "report.thanks.take_action": "Here are your options for controlling what you see on Mastodon:",

View File

@@ -13,7 +13,6 @@
"about.not_available": "This information has not been made available on this server.", "about.not_available": "This information has not been made available on this server.",
"about.powered_by": "Decentralized social media powered by {mastodon}", "about.powered_by": "Decentralized social media powered by {mastodon}",
"about.rules": "Server rules", "about.rules": "Server rules",
"account.about": "About",
"account.account_note_header": "Personal note", "account.account_note_header": "Personal note",
"account.activity": "Activity", "account.activity": "Activity",
"account.add_note": "Add a personal note", "account.add_note": "Add a personal note",
@@ -45,9 +44,11 @@
"account.familiar_followers_two": "Followed by {name1} and {name2}", "account.familiar_followers_two": "Followed by {name1} and {name2}",
"account.featured": "Featured", "account.featured": "Featured",
"account.featured.accounts": "Profiles", "account.featured.accounts": "Profiles",
"account.featured.collections": "Collections",
"account.featured.hashtags": "Hashtags", "account.featured.hashtags": "Hashtags",
"account.featured_tags.last_status_at": "Last post on {date}", "account.featured_tags.last_status_at": "Last post on {date}",
"account.featured_tags.last_status_never": "No posts", "account.featured_tags.last_status_never": "No posts",
"account.field_overflow": "Show full content",
"account.filters.all": "All activity", "account.filters.all": "All activity",
"account.filters.boosts_toggle": "Show boosts", "account.filters.boosts_toggle": "Show boosts",
"account.filters.posts_boosts": "Posts and boosts", "account.filters.posts_boosts": "Posts and boosts",
@@ -306,11 +307,13 @@
"collections.no_collections_yet": "No collections yet.", "collections.no_collections_yet": "No collections yet.",
"collections.old_last_post_note": "Last posted over a week ago", "collections.old_last_post_note": "Last posted over a week ago",
"collections.remove_account": "Remove this account", "collections.remove_account": "Remove this account",
"collections.report_collection": "Report this collection",
"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.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.view_other_collections_by_user": "View other collections by this user",
"collections.visibility_public": "Public", "collections.visibility_public": "Public",
"collections.visibility_public_hint": "Discoverable in search results and other areas where recommendations appear.", "collections.visibility_public_hint": "Discoverable in search results and other areas where recommendations appear.",
"collections.visibility_title": "Visibility", "collections.visibility_title": "Visibility",
@@ -496,8 +499,6 @@
"emoji_button.search_results": "Search results", "emoji_button.search_results": "Search results",
"emoji_button.symbols": "Symbols", "emoji_button.symbols": "Symbols",
"emoji_button.travel": "Travel & Places", "emoji_button.travel": "Travel & Places",
"empty_column.account_about.me": "You have not added any information about yourself yet.",
"empty_column.account_about.other": "{acct} has not added any information about themselves yet.",
"empty_column.account_featured.me": "You have not featured anything yet. Did you know that you can feature your hashtags you use the most, and even your friends accounts on your profile?", "empty_column.account_featured.me": "You have not featured anything yet. Did you know that you can feature your hashtags you use the most, and even your friends accounts on your profile?",
"empty_column.account_featured.other": "{acct} has not featured anything yet. Did you know that you can feature your hashtags you use the most, and even your friends accounts on your profile?", "empty_column.account_featured.other": "{acct} has not featured anything yet. Did you know that you can feature your hashtags you use the most, and even your friends accounts on your profile?",
"empty_column.account_featured_other.unknown": "This account has not featured anything yet.", "empty_column.account_featured_other.unknown": "This account has not featured anything yet.",
@@ -976,6 +977,7 @@
"report.category.title_account": "profile", "report.category.title_account": "profile",
"report.category.title_status": "post", "report.category.title_status": "post",
"report.close": "Done", "report.close": "Done",
"report.collection_comment": "Why do you want to report this collection?",
"report.comment.title": "Is there anything else you think we should know?", "report.comment.title": "Is there anything else you think we should know?",
"report.forward": "Forward to {target}", "report.forward": "Forward to {target}",
"report.forward_hint": "The account is from another server. Send an anonymized copy of the report there as well?", "report.forward_hint": "The account is from another server. Send an anonymized copy of the report there as well?",
@@ -997,6 +999,8 @@
"report.rules.title": "Which rules are being violated?", "report.rules.title": "Which rules are being violated?",
"report.statuses.subtitle": "Select all that apply", "report.statuses.subtitle": "Select all that apply",
"report.statuses.title": "Are there any posts that back up this report?", "report.statuses.title": "Are there any posts that back up this report?",
"report.submission_error": "Report could not be submitted",
"report.submission_error_details": "Please check your network connection and try again later.",
"report.submit": "Submit", "report.submit": "Submit",
"report.target": "Reporting {target}", "report.target": "Reporting {target}",
"report.thanks.take_action": "Here are your options for controlling what you see on Mastodon:", "report.thanks.take_action": "Here are your options for controlling what you see on Mastodon:",

View File

@@ -167,7 +167,7 @@
"account_edit_tags.help_text": "Las etiquetas destacadas ayudan a los usuarios a descubrir e interactuar con tu perfil. Las etiquetas destacadas aparecen como filtros en la vista de actividad de la página de tu perfil.", "account_edit_tags.help_text": "Las etiquetas destacadas ayudan a los usuarios a descubrir e interactuar con tu perfil. Las etiquetas destacadas aparecen como filtros en la vista de actividad de la página de tu perfil.",
"account_edit_tags.search_placeholder": "Ingresá una etiqueta…", "account_edit_tags.search_placeholder": "Ingresá una etiqueta…",
"account_edit_tags.suggestions": "Sugerencias:", "account_edit_tags.suggestions": "Sugerencias:",
"account_edit_tags.tag_status_count": "{count} mensajes", "account_edit_tags.tag_status_count": "{count, plural, one {voto} other {votos}}",
"account_note.placeholder": "Hacé clic par agregar una nota", "account_note.placeholder": "Hacé clic par agregar una 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",
@@ -306,11 +306,13 @@
"collections.no_collections_yet": "No hay colecciones aún.", "collections.no_collections_yet": "No hay colecciones aún.",
"collections.old_last_post_note": "Último mensaje hace más de una semana", "collections.old_last_post_note": "Último mensaje hace más de una semana",
"collections.remove_account": "Eliminar esta cuenta", "collections.remove_account": "Eliminar esta cuenta",
"collections.report_collection": "Denunciar esta colección",
"collections.search_accounts_label": "Buscar cuentas para agregar…", "collections.search_accounts_label": "Buscar cuentas para agregar…",
"collections.search_accounts_max_reached": "Agregaste el número máximo de cuentas", "collections.search_accounts_max_reached": "Agregaste el número máximo de cuentas",
"collections.sensitive": "Sensible", "collections.sensitive": "Sensible",
"collections.topic_hint": "Agregá una etiqueta que ayude a otros usuarios a entender el tema principal de esta colección.", "collections.topic_hint": "Agregá una etiqueta que ayude a otros usuarios a entender el tema principal de esta colección.",
"collections.view_collection": "Abrir colección", "collections.view_collection": "Abrir colección",
"collections.view_other_collections_by_user": "Ver otras colecciones de este usuario",
"collections.visibility_public": "Pública", "collections.visibility_public": "Pública",
"collections.visibility_public_hint": "Puede ser descubierta en los resultados de búsqueda y en otras áreas donde aparezcan recomendaciones.", "collections.visibility_public_hint": "Puede ser descubierta en los resultados de búsqueda y en otras áreas donde aparezcan recomendaciones.",
"collections.visibility_title": "Visibilidad", "collections.visibility_title": "Visibilidad",
@@ -976,6 +978,7 @@
"report.category.title_account": "perfil", "report.category.title_account": "perfil",
"report.category.title_status": "mensaje", "report.category.title_status": "mensaje",
"report.close": "Listo", "report.close": "Listo",
"report.collection_comment": "¿Por qué querés denunciar esta colección?",
"report.comment.title": "¿Hay algo más que creés que deberíamos saber?", "report.comment.title": "¿Hay algo más que creés que deberíamos saber?",
"report.forward": "Reenviar a {target}", "report.forward": "Reenviar a {target}",
"report.forward_hint": "La cuenta es de otro servidor. ¿Querés enviar una copia anonimizada del informe también ahí?", "report.forward_hint": "La cuenta es de otro servidor. ¿Querés enviar una copia anonimizada del informe también ahí?",
@@ -997,6 +1000,8 @@
"report.rules.title": "¿Qué reglas se están violando?", "report.rules.title": "¿Qué reglas se están violando?",
"report.statuses.subtitle": "Seleccioná todo lo que corresponda", "report.statuses.subtitle": "Seleccioná todo lo que corresponda",
"report.statuses.title": "¿Hay algún mensaje que respalde esta denuncia?", "report.statuses.title": "¿Hay algún mensaje que respalde esta denuncia?",
"report.submission_error": "No se pudo enviar la denuncia",
"report.submission_error_details": "Por favor, revisá tu conexión a Internet e intentá de nuevo más tarde.",
"report.submit": "Enviar", "report.submit": "Enviar",
"report.target": "Denunciando a {target}", "report.target": "Denunciando a {target}",
"report.thanks.take_action": "Acá están tus opciones para controlar lo que ves en Mastodon:", "report.thanks.take_action": "Acá están tus opciones para controlar lo que ves en Mastodon:",

View File

@@ -167,7 +167,7 @@
"account_edit_tags.help_text": "Las etiquetas destacadas ayudan a los usuarios a descubrir tu perfil e interactuar con él. Aparecen como filtros en la vista Actividad de tu página de perfil.", "account_edit_tags.help_text": "Las etiquetas destacadas ayudan a los usuarios a descubrir tu perfil e interactuar con él. Aparecen como filtros en la vista Actividad de tu página de perfil.",
"account_edit_tags.search_placeholder": "Introduce una etiqueta…", "account_edit_tags.search_placeholder": "Introduce una etiqueta…",
"account_edit_tags.suggestions": "Sugerencias:", "account_edit_tags.suggestions": "Sugerencias:",
"account_edit_tags.tag_status_count": "{count} publicaciones", "account_edit_tags.tag_status_count": "{count, plural,one {# publicación} other {# publicaciones}}",
"account_note.placeholder": "Haz clic para añadir una nota", "account_note.placeholder": "Haz clic para añadir una nota",
"admin.dashboard.daily_retention": "Tasa de retención de usuarios por día después de unirse", "admin.dashboard.daily_retention": "Tasa de retención de usuarios por día después de unirse",
"admin.dashboard.monthly_retention": "Tasa de retención de usuarios por mes después de unirse", "admin.dashboard.monthly_retention": "Tasa de retención de usuarios por mes después de unirse",
@@ -306,11 +306,13 @@
"collections.no_collections_yet": "No hay colecciones todavía.", "collections.no_collections_yet": "No hay colecciones todavía.",
"collections.old_last_post_note": "Última publicación hace más de una semana", "collections.old_last_post_note": "Última publicación hace más de una semana",
"collections.remove_account": "Eliminar esta cuenta", "collections.remove_account": "Eliminar esta cuenta",
"collections.report_collection": "Reportar esta colección",
"collections.search_accounts_label": "Buscar cuentas para añadir…", "collections.search_accounts_label": "Buscar cuentas para añadir…",
"collections.search_accounts_max_reached": "Has añadido el número máximo de cuentas", "collections.search_accounts_max_reached": "Has añadido el número máximo de cuentas",
"collections.sensitive": "Sensible", "collections.sensitive": "Sensible",
"collections.topic_hint": "Añade una etiqueta que ayude a los demás a comprender el tema principal de esta colección.", "collections.topic_hint": "Añade una etiqueta que ayude a los demás a comprender el tema principal de esta colección.",
"collections.view_collection": "Ver colección", "collections.view_collection": "Ver colección",
"collections.view_other_collections_by_user": "Ver otras colecciones de este usuario",
"collections.visibility_public": "Pública", "collections.visibility_public": "Pública",
"collections.visibility_public_hint": "Visible en los resultados de búsqueda y otras áreas donde aparecen recomendaciones.", "collections.visibility_public_hint": "Visible en los resultados de búsqueda y otras áreas donde aparecen recomendaciones.",
"collections.visibility_title": "Visibilidad", "collections.visibility_title": "Visibilidad",
@@ -976,6 +978,7 @@
"report.category.title_account": "perfil", "report.category.title_account": "perfil",
"report.category.title_status": "publicación", "report.category.title_status": "publicación",
"report.close": "Realizado", "report.close": "Realizado",
"report.collection_comment": "¿Por qué quieres reportar esta colección?",
"report.comment.title": "¿Hay algo más que creas que deberíamos saber?", "report.comment.title": "¿Hay algo más que creas que deberíamos saber?",
"report.forward": "Reenviar a {target}", "report.forward": "Reenviar a {target}",
"report.forward_hint": "La cuenta es de otro servidor. ¿Enviar también una copia anónima del informe allí?", "report.forward_hint": "La cuenta es de otro servidor. ¿Enviar también una copia anónima del informe allí?",
@@ -997,6 +1000,8 @@
"report.rules.title": "¿Cuáles reglas se están infringiendo?", "report.rules.title": "¿Cuáles reglas se están infringiendo?",
"report.statuses.subtitle": "Seleccione todas las que apliquen", "report.statuses.subtitle": "Seleccione todas las que apliquen",
"report.statuses.title": "¿Hay alguna publicación que respalde esta denuncia?", "report.statuses.title": "¿Hay alguna publicación que respalde esta denuncia?",
"report.submission_error": "No se pudo enviar el informe",
"report.submission_error_details": "Por favor, comprueba tu conexión a internet y vuelve a intentarlo más tarde.",
"report.submit": "Enviar", "report.submit": "Enviar",
"report.target": "Denunciando a {target}", "report.target": "Denunciando a {target}",
"report.thanks.take_action": "Aquí están tus opciones para controlar lo que ves en Mastodon:", "report.thanks.take_action": "Aquí están tus opciones para controlar lo que ves en Mastodon:",

View File

@@ -167,7 +167,7 @@
"account_edit_tags.help_text": "Las etiquetas destacadas ayudan a los usuarios a descubrir e interactuar con tu perfil. Aparecen como filtros en la vista de actividad de tu página de perfil.", "account_edit_tags.help_text": "Las etiquetas destacadas ayudan a los usuarios a descubrir e interactuar con tu perfil. Aparecen como filtros en la vista de actividad de tu página de perfil.",
"account_edit_tags.search_placeholder": "Introduce una etiqueta…", "account_edit_tags.search_placeholder": "Introduce una etiqueta…",
"account_edit_tags.suggestions": "Sugerencias:", "account_edit_tags.suggestions": "Sugerencias:",
"account_edit_tags.tag_status_count": "{count} publicaciones", "account_edit_tags.tag_status_count": "{count, plural, one {# publicación} other {# publicaciones}}",
"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",
@@ -306,11 +306,13 @@
"collections.no_collections_yet": "Aún no hay colecciones.", "collections.no_collections_yet": "Aún no hay colecciones.",
"collections.old_last_post_note": "Última publicación hace más de una semana", "collections.old_last_post_note": "Última publicación hace más de una semana",
"collections.remove_account": "Borrar esta cuenta", "collections.remove_account": "Borrar esta cuenta",
"collections.report_collection": "Reportar esta colección",
"collections.search_accounts_label": "Buscar cuentas para añadir…", "collections.search_accounts_label": "Buscar cuentas para añadir…",
"collections.search_accounts_max_reached": "Has añadido el número máximo de cuentas", "collections.search_accounts_max_reached": "Has añadido el número máximo de cuentas",
"collections.sensitive": "Sensible", "collections.sensitive": "Sensible",
"collections.topic_hint": "Añadir una etiqueta que ayude a otros a entender el tema principal de esta colección.", "collections.topic_hint": "Añadir una etiqueta que ayude a otros a entender el tema principal de esta colección.",
"collections.view_collection": "Ver colección", "collections.view_collection": "Ver colección",
"collections.view_other_collections_by_user": "Ver otras colecciones de este usuario",
"collections.visibility_public": "Pública", "collections.visibility_public": "Pública",
"collections.visibility_public_hint": "Puede mostrarse en los resultados de búsqueda y en otros lugares donde aparezcan recomendaciones.", "collections.visibility_public_hint": "Puede mostrarse en los resultados de búsqueda y en otros lugares donde aparezcan recomendaciones.",
"collections.visibility_title": "Visibilidad", "collections.visibility_title": "Visibilidad",
@@ -976,6 +978,7 @@
"report.category.title_account": "perfil", "report.category.title_account": "perfil",
"report.category.title_status": "publicación", "report.category.title_status": "publicación",
"report.close": "Hecho", "report.close": "Hecho",
"report.collection_comment": "¿Por qué quieres reportar esta colección?",
"report.comment.title": "¿Hay algo más que creas que deberíamos saber?", "report.comment.title": "¿Hay algo más que creas que deberíamos saber?",
"report.forward": "Reenviar a {target}", "report.forward": "Reenviar a {target}",
"report.forward_hint": "Esta cuenta es de otro servidor. ¿Enviar una copia anonimizada del informe allí también?", "report.forward_hint": "Esta cuenta es de otro servidor. ¿Enviar una copia anonimizada del informe allí también?",
@@ -997,6 +1000,8 @@
"report.rules.title": "¿Qué normas se están violando?", "report.rules.title": "¿Qué normas se están violando?",
"report.statuses.subtitle": "Selecciona todos los que correspondan", "report.statuses.subtitle": "Selecciona todos los que correspondan",
"report.statuses.title": "¿Hay alguna publicación que respalde este informe?", "report.statuses.title": "¿Hay alguna publicación que respalde este informe?",
"report.submission_error": "No se pudo enviar el reporte",
"report.submission_error_details": "Comprueba tu conexión de red e intenta volver a intentarlo más tarde.",
"report.submit": "Enviar", "report.submit": "Enviar",
"report.target": "Reportando {target}", "report.target": "Reportando {target}",
"report.thanks.take_action": "Aquí están tus opciones para controlar lo que ves en Mastodon:", "report.thanks.take_action": "Aquí están tus opciones para controlar lo que ves en Mastodon:",

View File

@@ -167,7 +167,7 @@
"account_edit_tags.help_text": "Esiteltävät aihetunnisteet auttavat käyttäjiä löytämään profiilisi ja olemaan vuorovaikutuksessa sen kanssa. Ne näkyvät suodattimina profiilisivusi Toiminta-näkymässä.", "account_edit_tags.help_text": "Esiteltävät aihetunnisteet auttavat käyttäjiä löytämään profiilisi ja olemaan vuorovaikutuksessa sen kanssa. Ne näkyvät suodattimina profiilisivusi Toiminta-näkymässä.",
"account_edit_tags.search_placeholder": "Syötä aihetunniste…", "account_edit_tags.search_placeholder": "Syötä aihetunniste…",
"account_edit_tags.suggestions": "Ehdotuksia:", "account_edit_tags.suggestions": "Ehdotuksia:",
"account_edit_tags.tag_status_count": "{count} julkaisua", "account_edit_tags.tag_status_count": "{count, plural, one {# julkaisu} other {# julkaisua}}",
"account_note.placeholder": "Lisää muistiinpano napsauttamalla", "account_note.placeholder": "Lisää muistiinpano napsauttamalla",
"admin.dashboard.daily_retention": "Käyttäjien pysyvyys päivittäin rekisteröitymisen jälkeen", "admin.dashboard.daily_retention": "Käyttäjien pysyvyys päivittäin rekisteröitymisen jälkeen",
"admin.dashboard.monthly_retention": "Käyttäjien pysyvyys kuukausittain rekisteröitymisen jälkeen", "admin.dashboard.monthly_retention": "Käyttäjien pysyvyys kuukausittain rekisteröitymisen jälkeen",
@@ -306,11 +306,13 @@
"collections.no_collections_yet": "Ei vielä kokoelmia.", "collections.no_collections_yet": "Ei vielä kokoelmia.",
"collections.old_last_post_note": "Julkaissut viimeksi yli viikko sitten", "collections.old_last_post_note": "Julkaissut viimeksi yli viikko sitten",
"collections.remove_account": "Poista tämä tili", "collections.remove_account": "Poista tämä tili",
"collections.report_collection": "Raportoi tämä kokoelma",
"collections.search_accounts_label": "Hae lisättäviä tilejä…", "collections.search_accounts_label": "Hae lisättäviä tilejä…",
"collections.search_accounts_max_reached": "Olet lisännyt enimmäismäärän tilejä", "collections.search_accounts_max_reached": "Olet lisännyt enimmäismäärän tilejä",
"collections.sensitive": "Arkaluonteinen", "collections.sensitive": "Arkaluonteinen",
"collections.topic_hint": "Lisää aihetunniste, joka auttaa muita ymmärtämään tämän kokoelman pääaiheen.", "collections.topic_hint": "Lisää aihetunniste, joka auttaa muita ymmärtämään tämän kokoelman pääaiheen.",
"collections.view_collection": "Näytä kokoelma", "collections.view_collection": "Näytä kokoelma",
"collections.view_other_collections_by_user": "Näytä muut tämän käyttäjän kokoelmat",
"collections.visibility_public": "Julkinen", "collections.visibility_public": "Julkinen",
"collections.visibility_public_hint": "Löydettävissä hakutuloksista ja muualta, jossa ilmenee suosituksia.", "collections.visibility_public_hint": "Löydettävissä hakutuloksista ja muualta, jossa ilmenee suosituksia.",
"collections.visibility_title": "Näkyvyys", "collections.visibility_title": "Näkyvyys",
@@ -976,6 +978,7 @@
"report.category.title_account": "profiili", "report.category.title_account": "profiili",
"report.category.title_status": "julkaisu", "report.category.title_status": "julkaisu",
"report.close": "Valmis", "report.close": "Valmis",
"report.collection_comment": "Miksi haluat raportoida tämän kokoelman?",
"report.comment.title": "Onko vielä jotain muuta, mitä meidän pitäisi tietää?", "report.comment.title": "Onko vielä jotain muuta, mitä meidän pitäisi tietää?",
"report.forward": "Välitä palvelimelle {target}", "report.forward": "Välitä palvelimelle {target}",
"report.forward_hint": "Tämä tili on toisella palvelimella. Haluatko lähettää nimettömän raportin myös sinne?", "report.forward_hint": "Tämä tili on toisella palvelimella. Haluatko lähettää nimettömän raportin myös sinne?",
@@ -997,6 +1000,8 @@
"report.rules.title": "Mitä sääntöjä rikotaan?", "report.rules.title": "Mitä sääntöjä rikotaan?",
"report.statuses.subtitle": "Valitse kaikki sopivat", "report.statuses.subtitle": "Valitse kaikki sopivat",
"report.statuses.title": "Onko julkaisuja, jotka tukevat tätä raporttia?", "report.statuses.title": "Onko julkaisuja, jotka tukevat tätä raporttia?",
"report.submission_error": "Raporttia ei voitu lähettää",
"report.submission_error_details": "Tarkista verkkoyhteytesi ja yritä uudelleen myöhemmin.",
"report.submit": "Lähetä", "report.submit": "Lähetä",
"report.target": "Raportoidaan {target}", "report.target": "Raportoidaan {target}",
"report.thanks.take_action": "Tässä on vaihtoehtosi hallita näkemääsi Mastodonissa:", "report.thanks.take_action": "Tässä on vaihtoehtosi hallita näkemääsi Mastodonissa:",

View File

@@ -167,7 +167,6 @@
"account_edit_tags.help_text": "Sermerkt frámerki hjálpa brúkarum at varnast og virka saman við vanga tínum. Tey síggjast sum filtur á virksemisvísingini av vanga tínum.", "account_edit_tags.help_text": "Sermerkt frámerki hjálpa brúkarum at varnast og virka saman við vanga tínum. Tey síggjast sum filtur á virksemisvísingini av vanga tínum.",
"account_edit_tags.search_placeholder": "Áset eitt frámerki…", "account_edit_tags.search_placeholder": "Áset eitt frámerki…",
"account_edit_tags.suggestions": "Uppskot:", "account_edit_tags.suggestions": "Uppskot:",
"account_edit_tags.tag_status_count": "{count} postar",
"account_note.placeholder": "Klikka fyri at leggja viðmerking afturat", "account_note.placeholder": "Klikka fyri at leggja viðmerking afturat",
"admin.dashboard.daily_retention": "Hvussu nógvir brúkarar eru eftir, síðani tey skrásettu seg, roknað í døgum", "admin.dashboard.daily_retention": "Hvussu nógvir brúkarar eru eftir, síðani tey skrásettu seg, roknað í døgum",
"admin.dashboard.monthly_retention": "Hvussu nógvir brúkarar eru eftir síðani tey skrásettu seg, roknað í mánaðum", "admin.dashboard.monthly_retention": "Hvussu nógvir brúkarar eru eftir síðani tey skrásettu seg, roknað í mánaðum",

View File

@@ -15,7 +15,7 @@
"about.rules": "Règles du serveur", "about.rules": "Règles du serveur",
"account.about": "À propos", "account.about": "À propos",
"account.account_note_header": "Note personnelle", "account.account_note_header": "Note personnelle",
"account.activity": "Activités", "account.activity": "Activité",
"account.add_note": "Ajouter une note personnelle", "account.add_note": "Ajouter une note personnelle",
"account.add_or_remove_from_list": "Ajouter ou enlever de listes", "account.add_or_remove_from_list": "Ajouter ou enlever de listes",
"account.badges.admin": "Admin", "account.badges.admin": "Admin",
@@ -145,6 +145,9 @@
"account_edit.bio.title": "Présentation", "account_edit.bio.title": "Présentation",
"account_edit.bio_modal.add_title": "Ajouter une présentation", "account_edit.bio_modal.add_title": "Ajouter une présentation",
"account_edit.bio_modal.edit_title": "Modifier la présentation", "account_edit.bio_modal.edit_title": "Modifier la présentation",
"account_edit.button.add": "Ajouter {item}",
"account_edit.button.delete": "Supprimer {item}",
"account_edit.button.edit": "Modifier {item}",
"account_edit.char_counter": "{currentLength}/{maxLength} caractères", "account_edit.char_counter": "{currentLength}/{maxLength} caractères",
"account_edit.column_button": "Terminé", "account_edit.column_button": "Terminé",
"account_edit.column_title": "Modifier le profil", "account_edit.column_title": "Modifier le profil",
@@ -152,6 +155,7 @@
"account_edit.custom_fields.title": "Champs personnalisés", "account_edit.custom_fields.title": "Champs personnalisés",
"account_edit.display_name.placeholder": "Votre nom public est le nom qui apparaît sur votre profil et dans les fils d'actualités.", "account_edit.display_name.placeholder": "Votre nom public est le nom qui apparaît sur votre profil et dans les fils d'actualités.",
"account_edit.display_name.title": "Nom public", "account_edit.display_name.title": "Nom public",
"account_edit.featured_hashtags.item": "hashtags",
"account_edit.featured_hashtags.placeholder": "Aider les autres à identifier et à accéder rapidement à vos sujets préférés.", "account_edit.featured_hashtags.placeholder": "Aider les autres à identifier et à accéder rapidement à vos sujets préférés.",
"account_edit.featured_hashtags.title": "Hashtags mis en avant", "account_edit.featured_hashtags.title": "Hashtags mis en avant",
"account_edit.name_modal.add_title": "Ajouter un nom public", "account_edit.name_modal.add_title": "Ajouter un nom public",
@@ -159,6 +163,11 @@
"account_edit.profile_tab.subtitle": "Personnaliser les onglets de votre profil et leur contenu.", "account_edit.profile_tab.subtitle": "Personnaliser les onglets de votre profil et leur contenu.",
"account_edit.profile_tab.title": "Paramètres de l'onglet du profil", "account_edit.profile_tab.title": "Paramètres de l'onglet du profil",
"account_edit.save": "Enregistrer", "account_edit.save": "Enregistrer",
"account_edit_tags.column_title": "Modifier les hashtags mis en avant",
"account_edit_tags.help_text": "Les hashtags mis en avant aident les personnes à découvrir et interagir avec votre profil. Ils apparaissent comme des filtres dans la vue « Activité » de votre profil.",
"account_edit_tags.search_placeholder": "Saisir un hashtag…",
"account_edit_tags.suggestions": "Suggestions :",
"account_edit_tags.tag_status_count": "{count, plural, one {# message} other {# messages}}",
"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",
@@ -297,6 +306,7 @@
"collections.no_collections_yet": "Aucune collection pour le moment.", "collections.no_collections_yet": "Aucune collection pour le moment.",
"collections.old_last_post_note": "Dernière publication il y a plus d'une semaine", "collections.old_last_post_note": "Dernière publication il y a plus d'une semaine",
"collections.remove_account": "Supprimer ce compte", "collections.remove_account": "Supprimer ce compte",
"collections.report_collection": "Signaler cette collection",
"collections.search_accounts_label": "Chercher des comptes à ajouter…", "collections.search_accounts_label": "Chercher des comptes à ajouter…",
"collections.search_accounts_max_reached": "Vous avez ajouté le nombre maximum de comptes", "collections.search_accounts_max_reached": "Vous avez ajouté le nombre maximum de comptes",
"collections.sensitive": "Sensible", "collections.sensitive": "Sensible",
@@ -768,7 +778,7 @@
"not_signed_in_indicator.not_signed_in": "Vous devez vous connecter pour accéder à cette ressource.", "not_signed_in_indicator.not_signed_in": "Vous devez vous connecter pour accéder à cette ressource.",
"notification.admin.report": "{name} a signalé {target}", "notification.admin.report": "{name} a signalé {target}",
"notification.admin.report_account": "{name} a signalé {count, plural, one {un message} other {# messages}} de {target} pour {category}", "notification.admin.report_account": "{name} a signalé {count, plural, one {un message} other {# messages}} de {target} pour {category}",
"notification.admin.report_account_other": "{name} a signalé {count, plural, one {un message} other {# messages}} depuis {target}", "notification.admin.report_account_other": "{name} a signalé {count, plural, one {un message} other {# messages}} de {target}",
"notification.admin.report_statuses": "{name} a signalé {target} pour {category}", "notification.admin.report_statuses": "{name} a signalé {target} pour {category}",
"notification.admin.report_statuses_other": "{name} a signalé {target}", "notification.admin.report_statuses_other": "{name} a signalé {target}",
"notification.admin.sign_up": "{name} s'est inscrit·e", "notification.admin.sign_up": "{name} s'est inscrit·e",
@@ -967,6 +977,7 @@
"report.category.title_account": "ce profil", "report.category.title_account": "ce profil",
"report.category.title_status": "ce message", "report.category.title_status": "ce message",
"report.close": "Terminé", "report.close": "Terminé",
"report.collection_comment": "Pourquoi souhaitez-vous signaler cette collection ?",
"report.comment.title": "Y a-t-il autre chose que nous devrions savoir?", "report.comment.title": "Y a-t-il autre chose que nous devrions savoir?",
"report.forward": "Transférer à {target}", "report.forward": "Transférer à {target}",
"report.forward_hint": "Le compte provient dun autre serveur. Envoyer une copie anonyme du rapport là-bas également?", "report.forward_hint": "Le compte provient dun autre serveur. Envoyer une copie anonyme du rapport là-bas également?",
@@ -988,6 +999,8 @@
"report.rules.title": "Quelles règles sont enfreintes?", "report.rules.title": "Quelles règles sont enfreintes?",
"report.statuses.subtitle": "Sélectionnez toutes les réponses appropriées", "report.statuses.subtitle": "Sélectionnez toutes les réponses appropriées",
"report.statuses.title": "Existe-t-il des messages pour étayer ce rapport?", "report.statuses.title": "Existe-t-il des messages pour étayer ce rapport?",
"report.submission_error": "Le signalement na pas pu être envoyé",
"report.submission_error_details": "Veuillez vérifier votre connexion réseau et réessayer plus tard.",
"report.submit": "Envoyer", "report.submit": "Envoyer",
"report.target": "Signalement de {target}", "report.target": "Signalement de {target}",
"report.thanks.take_action": "Voici les possibilités que vous avez pour contrôler ce que vous voyez sur Mastodon:", "report.thanks.take_action": "Voici les possibilités que vous avez pour contrôler ce que vous voyez sur Mastodon:",

View File

@@ -15,7 +15,7 @@
"about.rules": "Règles du serveur", "about.rules": "Règles du serveur",
"account.about": "À propos", "account.about": "À propos",
"account.account_note_header": "Note personnelle", "account.account_note_header": "Note personnelle",
"account.activity": "Activités", "account.activity": "Activité",
"account.add_note": "Ajouter une note personnelle", "account.add_note": "Ajouter une note personnelle",
"account.add_or_remove_from_list": "Ajouter ou retirer des listes", "account.add_or_remove_from_list": "Ajouter ou retirer des listes",
"account.badges.admin": "Admin", "account.badges.admin": "Admin",
@@ -145,6 +145,9 @@
"account_edit.bio.title": "Présentation", "account_edit.bio.title": "Présentation",
"account_edit.bio_modal.add_title": "Ajouter une présentation", "account_edit.bio_modal.add_title": "Ajouter une présentation",
"account_edit.bio_modal.edit_title": "Modifier la présentation", "account_edit.bio_modal.edit_title": "Modifier la présentation",
"account_edit.button.add": "Ajouter {item}",
"account_edit.button.delete": "Supprimer {item}",
"account_edit.button.edit": "Modifier {item}",
"account_edit.char_counter": "{currentLength}/{maxLength} caractères", "account_edit.char_counter": "{currentLength}/{maxLength} caractères",
"account_edit.column_button": "Terminé", "account_edit.column_button": "Terminé",
"account_edit.column_title": "Modifier le profil", "account_edit.column_title": "Modifier le profil",
@@ -152,6 +155,7 @@
"account_edit.custom_fields.title": "Champs personnalisés", "account_edit.custom_fields.title": "Champs personnalisés",
"account_edit.display_name.placeholder": "Votre nom public est le nom qui apparaît sur votre profil et dans les fils d'actualités.", "account_edit.display_name.placeholder": "Votre nom public est le nom qui apparaît sur votre profil et dans les fils d'actualités.",
"account_edit.display_name.title": "Nom public", "account_edit.display_name.title": "Nom public",
"account_edit.featured_hashtags.item": "hashtags",
"account_edit.featured_hashtags.placeholder": "Aider les autres à identifier et à accéder rapidement à vos sujets préférés.", "account_edit.featured_hashtags.placeholder": "Aider les autres à identifier et à accéder rapidement à vos sujets préférés.",
"account_edit.featured_hashtags.title": "Hashtags mis en avant", "account_edit.featured_hashtags.title": "Hashtags mis en avant",
"account_edit.name_modal.add_title": "Ajouter un nom public", "account_edit.name_modal.add_title": "Ajouter un nom public",
@@ -159,6 +163,11 @@
"account_edit.profile_tab.subtitle": "Personnaliser les onglets de votre profil et leur contenu.", "account_edit.profile_tab.subtitle": "Personnaliser les onglets de votre profil et leur contenu.",
"account_edit.profile_tab.title": "Paramètres de l'onglet du profil", "account_edit.profile_tab.title": "Paramètres de l'onglet du profil",
"account_edit.save": "Enregistrer", "account_edit.save": "Enregistrer",
"account_edit_tags.column_title": "Modifier les hashtags mis en avant",
"account_edit_tags.help_text": "Les hashtags mis en avant aident les personnes à découvrir et interagir avec votre profil. Ils apparaissent comme des filtres dans la vue « Activité » de votre profil.",
"account_edit_tags.search_placeholder": "Saisir un hashtag…",
"account_edit_tags.suggestions": "Suggestions :",
"account_edit_tags.tag_status_count": "{count, plural, one {# message} other {# messages}}",
"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",
@@ -297,6 +306,7 @@
"collections.no_collections_yet": "Aucune collection pour le moment.", "collections.no_collections_yet": "Aucune collection pour le moment.",
"collections.old_last_post_note": "Dernière publication il y a plus d'une semaine", "collections.old_last_post_note": "Dernière publication il y a plus d'une semaine",
"collections.remove_account": "Supprimer ce compte", "collections.remove_account": "Supprimer ce compte",
"collections.report_collection": "Signaler cette collection",
"collections.search_accounts_label": "Chercher des comptes à ajouter…", "collections.search_accounts_label": "Chercher des comptes à ajouter…",
"collections.search_accounts_max_reached": "Vous avez ajouté le nombre maximum de comptes", "collections.search_accounts_max_reached": "Vous avez ajouté le nombre maximum de comptes",
"collections.sensitive": "Sensible", "collections.sensitive": "Sensible",
@@ -768,7 +778,7 @@
"not_signed_in_indicator.not_signed_in": "Vous devez vous connecter pour accéder à cette ressource.", "not_signed_in_indicator.not_signed_in": "Vous devez vous connecter pour accéder à cette ressource.",
"notification.admin.report": "{name} a signalé {target}", "notification.admin.report": "{name} a signalé {target}",
"notification.admin.report_account": "{name} a signalé {count, plural, one {un message} other {# messages}} de {target} pour {category}", "notification.admin.report_account": "{name} a signalé {count, plural, one {un message} other {# messages}} de {target} pour {category}",
"notification.admin.report_account_other": "{name} a signalé {count, plural, one {un message} other {# messages}} depuis {target}", "notification.admin.report_account_other": "{name} a signalé {count, plural, one {un message} other {# messages}} de {target}",
"notification.admin.report_statuses": "{name} a signalé {target} pour {category}", "notification.admin.report_statuses": "{name} a signalé {target} pour {category}",
"notification.admin.report_statuses_other": "{name} a signalé {target}", "notification.admin.report_statuses_other": "{name} a signalé {target}",
"notification.admin.sign_up": "{name} s'est inscrit·e", "notification.admin.sign_up": "{name} s'est inscrit·e",
@@ -967,6 +977,7 @@
"report.category.title_account": "ce profil", "report.category.title_account": "ce profil",
"report.category.title_status": "ce message", "report.category.title_status": "ce message",
"report.close": "Terminé", "report.close": "Terminé",
"report.collection_comment": "Pourquoi souhaitez-vous signaler cette collection ?",
"report.comment.title": "Y a-t-il autre chose que nous devrions savoir ?", "report.comment.title": "Y a-t-il autre chose que nous devrions savoir ?",
"report.forward": "Transférer à {target}", "report.forward": "Transférer à {target}",
"report.forward_hint": "Le compte provient dun autre serveur. Envoyer également une copie anonyme du rapport?", "report.forward_hint": "Le compte provient dun autre serveur. Envoyer également une copie anonyme du rapport?",
@@ -988,6 +999,8 @@
"report.rules.title": "Quelles règles sont enfreintes ?", "report.rules.title": "Quelles règles sont enfreintes ?",
"report.statuses.subtitle": "Sélectionnez toutes les réponses appropriées", "report.statuses.subtitle": "Sélectionnez toutes les réponses appropriées",
"report.statuses.title": "Existe-t-il des messages pour étayer ce rapport ?", "report.statuses.title": "Existe-t-il des messages pour étayer ce rapport ?",
"report.submission_error": "Le signalement na pas pu être envoyé",
"report.submission_error_details": "Veuillez vérifier votre connexion réseau et réessayer plus tard.",
"report.submit": "Envoyer", "report.submit": "Envoyer",
"report.target": "Signalement de {target}", "report.target": "Signalement de {target}",
"report.thanks.take_action": "Voici les possibilités que vous avez pour contrôler ce que vous voyez sur Mastodon :", "report.thanks.take_action": "Voici les possibilités que vous avez pour contrôler ce que vous voyez sur Mastodon :",
@@ -996,7 +1009,7 @@
"report.thanks.title_actionable": "Merci pour votre signalement, nous allons investiguer.", "report.thanks.title_actionable": "Merci pour votre signalement, nous allons investiguer.",
"report.unfollow": "Ne plus suivre @{name}", "report.unfollow": "Ne plus suivre @{name}",
"report.unfollow_explanation": "Vous êtes abonné à ce compte. Pour ne plus voir ses messages dans votre fil principal, retirez-le de votre liste d'abonnements.", "report.unfollow_explanation": "Vous êtes abonné à ce compte. Pour ne plus voir ses messages dans votre fil principal, retirez-le de votre liste d'abonnements.",
"report_notification.attached_statuses": "{count, plural, one {{count} message lié} other {{count} messages liés}}", "report_notification.attached_statuses": "{count, plural, one {{count} message joint} other {{count} messages joints}}",
"report_notification.categories.legal": "Légal", "report_notification.categories.legal": "Légal",
"report_notification.categories.legal_sentence": "contenu illégal", "report_notification.categories.legal_sentence": "contenu illégal",
"report_notification.categories.other": "Autre", "report_notification.categories.other": "Autre",

View File

@@ -145,6 +145,9 @@
"account_edit.bio.title": "Beathaisnéis", "account_edit.bio.title": "Beathaisnéis",
"account_edit.bio_modal.add_title": "Cuir beathaisnéis leis", "account_edit.bio_modal.add_title": "Cuir beathaisnéis leis",
"account_edit.bio_modal.edit_title": "Cuir beathaisnéis in eagar", "account_edit.bio_modal.edit_title": "Cuir beathaisnéis in eagar",
"account_edit.button.add": "Cuir {item} leis",
"account_edit.button.delete": "Scrios {item}",
"account_edit.button.edit": "Cuir {item} in eagar",
"account_edit.char_counter": "{currentLength}/{maxLength} carachtair", "account_edit.char_counter": "{currentLength}/{maxLength} carachtair",
"account_edit.column_button": "Déanta", "account_edit.column_button": "Déanta",
"account_edit.column_title": "Cuir Próifíl in Eagar", "account_edit.column_title": "Cuir Próifíl in Eagar",
@@ -152,6 +155,7 @@
"account_edit.custom_fields.title": "Réimsí saincheaptha", "account_edit.custom_fields.title": "Réimsí saincheaptha",
"account_edit.display_name.placeholder": "Is é dainm taispeána an chaoi a bhfeictear dainm ar do phróifíl agus in amlínte.", "account_edit.display_name.placeholder": "Is é dainm taispeána an chaoi a bhfeictear dainm ar do phróifíl agus in amlínte.",
"account_edit.display_name.title": "Ainm taispeána", "account_edit.display_name.title": "Ainm taispeána",
"account_edit.featured_hashtags.item": "haischlibeanna",
"account_edit.featured_hashtags.placeholder": "Cabhraigh le daoine eile do thopaicí is fearr leat a aithint, agus rochtain thapa a bheith acu orthu.", "account_edit.featured_hashtags.placeholder": "Cabhraigh le daoine eile do thopaicí is fearr leat a aithint, agus rochtain thapa a bheith acu orthu.",
"account_edit.featured_hashtags.title": "Haischlibeanna Réadmhaoine", "account_edit.featured_hashtags.title": "Haischlibeanna Réadmhaoine",
"account_edit.name_modal.add_title": "Cuir ainm taispeána leis", "account_edit.name_modal.add_title": "Cuir ainm taispeána leis",
@@ -159,6 +163,11 @@
"account_edit.profile_tab.subtitle": "Saincheap na cluaisíní ar do phróifíl agus a bhfuil á thaispeáint iontu.", "account_edit.profile_tab.subtitle": "Saincheap na cluaisíní ar do phróifíl agus a bhfuil á thaispeáint iontu.",
"account_edit.profile_tab.title": "Socruithe an chluaisín próifíle", "account_edit.profile_tab.title": "Socruithe an chluaisín próifíle",
"account_edit.save": "Sábháil", "account_edit.save": "Sábháil",
"account_edit_tags.column_title": "Cuir haischlibeanna le feiceáil in eagar",
"account_edit_tags.help_text": "Cuidíonn haischlibeanna le húsáideoirí do phróifíl a aimsiú agus idirghníomhú léi. Feictear iad mar scagairí ar radharc Gníomhaíochta do leathanaigh Phróifíle.",
"account_edit_tags.search_placeholder": "Cuir isteach haischlib…",
"account_edit_tags.suggestions": "Moltaí:",
"account_edit_tags.tag_status_count": "{count, plural, one {# post} two {# poist} few {# poist} many {# poist} other {# poist}}",
"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ú",
@@ -283,6 +292,7 @@
"collections.detail.curated_by_you": "Curtha i dtoll a chéile agatsa", "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.loading": "Ag lódáil an bhailiúcháin…",
"collections.detail.share": "Comhroinn an bailiúchán seo", "collections.detail.share": "Comhroinn an bailiúchán seo",
"collections.edit_details": "Cuir sonraí 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ú.",
"collections.hints.accounts_counter": "{count} / {max} cuntais", "collections.hints.accounts_counter": "{count} / {max} cuntais",
"collections.hints.add_more_accounts": "Cuir ar a laghad {count, plural, one {# cuntas} two {# cuntais} few {# cuntais} many {# cuntais} other {# cuntais}} leis chun leanúint ar aghaidh", "collections.hints.add_more_accounts": "Cuir ar a laghad {count, plural, one {# cuntas} two {# cuntais} few {# cuntais} many {# cuntais} other {# cuntais}} leis chun leanúint ar aghaidh",
@@ -291,13 +301,18 @@
"collections.manage_accounts": "Bainistigh cuntais", "collections.manage_accounts": "Bainistigh cuntais",
"collections.mark_as_sensitive": "Marcáil mar íogair", "collections.mark_as_sensitive": "Marcáil mar íogair",
"collections.mark_as_sensitive_hint": "Folaíonn sé cur síos agus cuntais an bhailiúcháin taobh thiar de rabhadh ábhair. Beidh ainm an bhailiúcháin le feiceáil fós.", "collections.mark_as_sensitive_hint": "Folaíonn sé cur síos agus cuntais an bhailiúcháin taobh thiar de rabhadh ábhair. Beidh ainm an bhailiúcháin le feiceáil fós.",
"collections.name_length_hint": "Teorainn 40 carachtar",
"collections.new_collection": "Bailiúchán nua", "collections.new_collection": "Bailiúchán nua",
"collections.no_collections_yet": "Gan aon bhailiúcháin fós.", "collections.no_collections_yet": "Gan aon bhailiúcháin fós.",
"collections.old_last_post_note": "Postáilte go deireanach breis agus seachtain ó shin",
"collections.remove_account": "Bain an cuntas seo", "collections.remove_account": "Bain an cuntas seo",
"collections.report_collection": "Tuairiscigh an bailiúchán seo",
"collections.search_accounts_label": "Cuardaigh cuntais le cur leis…", "collections.search_accounts_label": "Cuardaigh cuntais le cur leis…",
"collections.search_accounts_max_reached": "Tá an líon uasta cuntas curtha leis agat", "collections.search_accounts_max_reached": "Tá an líon uasta cuntas curtha leis agat",
"collections.sensitive": "Íogair",
"collections.topic_hint": "Cuir haischlib leis a chabhraíonn le daoine eile príomhábhar an bhailiúcháin seo a thuiscint.", "collections.topic_hint": "Cuir haischlib leis a chabhraíonn le daoine eile príomhábhar an bhailiúcháin seo a thuiscint.",
"collections.view_collection": "Féach ar bhailiúchán", "collections.view_collection": "Féach ar bhailiúchán",
"collections.view_other_collections_by_user": "Féach ar bhailiúcháin eile ón úsáideoir seo",
"collections.visibility_public": "Poiblí", "collections.visibility_public": "Poiblí",
"collections.visibility_public_hint": "Infheicthe i dtorthaí cuardaigh agus i réimsí eile ina bhfuil moltaí le feiceáil.", "collections.visibility_public_hint": "Infheicthe i dtorthaí cuardaigh agus i réimsí eile ina bhfuil moltaí le feiceáil.",
"collections.visibility_title": "Infheictheacht", "collections.visibility_title": "Infheictheacht",
@@ -386,6 +401,9 @@
"confirmations.discard_draft.post.title": "An bhfuil tú ag iarraidh do dhréachtphost a chaitheamh amach?", "confirmations.discard_draft.post.title": "An bhfuil tú ag iarraidh do dhréachtphost a chaitheamh amach?",
"confirmations.discard_edit_media.confirm": "Faigh réidh de", "confirmations.discard_edit_media.confirm": "Faigh réidh de",
"confirmations.discard_edit_media.message": "Tá athruithe neamhshlánaithe don tuarascáil gné nó réamhamharc agat, faigh réidh dóibh ar aon nós?", "confirmations.discard_edit_media.message": "Tá athruithe neamhshlánaithe don tuarascáil gné nó réamhamharc agat, faigh réidh dóibh ar aon nós?",
"confirmations.follow_to_collection.confirm": "Lean agus cuir leis an mbailiúchán",
"confirmations.follow_to_collection.message": "Ní mór duit a bheith ag leanúint {name} le go gcuirfidh tú iad le bailiúchán.",
"confirmations.follow_to_collection.title": "Lean an cuntas?",
"confirmations.follow_to_list.confirm": "Lean agus cuir leis an liosta", "confirmations.follow_to_list.confirm": "Lean agus cuir leis an liosta",
"confirmations.follow_to_list.message": "Ní mór duit {name} a leanúint chun iad a chur le liosta.", "confirmations.follow_to_list.message": "Ní mór duit {name} a leanúint chun iad a chur le liosta.",
"confirmations.follow_to_list.title": "Lean an t-úsáideoir?", "confirmations.follow_to_list.title": "Lean an t-úsáideoir?",
@@ -960,6 +978,7 @@
"report.category.title_account": "próifíl", "report.category.title_account": "próifíl",
"report.category.title_status": "postáil", "report.category.title_status": "postáil",
"report.close": "Déanta", "report.close": "Déanta",
"report.collection_comment": "Cén fáth ar mhaith leat an bailiúchán seo a thuairisciú?",
"report.comment.title": "An bhfuil aon rud eile ba chóir dúinn a fhios agat, dar leat?", "report.comment.title": "An bhfuil aon rud eile ba chóir dúinn a fhios agat, dar leat?",
"report.forward": "Seol ar aghaidh chun {target}", "report.forward": "Seol ar aghaidh chun {target}",
"report.forward_hint": "Is ó fhreastalaí eile an cuntas. Cuir cóip gan ainm den tuarascáil ansin freisin?", "report.forward_hint": "Is ó fhreastalaí eile an cuntas. Cuir cóip gan ainm den tuarascáil ansin freisin?",
@@ -981,6 +1000,8 @@
"report.rules.title": "Cén rialacha atá á sárú?", "report.rules.title": "Cén rialacha atá á sárú?",
"report.statuses.subtitle": "Roghnaigh gach atá i bhfeidhm", "report.statuses.subtitle": "Roghnaigh gach atá i bhfeidhm",
"report.statuses.title": "An bhfuil aon phoist a thacaíonn leis an tuarascáil seo?", "report.statuses.title": "An bhfuil aon phoist a thacaíonn leis an tuarascáil seo?",
"report.submission_error": "Níorbh fhéidir an tuarascáil a chur isteach",
"report.submission_error_details": "Seiceáil do nasc líonra agus déan iarracht arís ar ball.",
"report.submit": "Cuir isteach", "report.submit": "Cuir isteach",
"report.target": "Ag tuairisciú {target}", "report.target": "Ag tuairisciú {target}",
"report.thanks.take_action": "Seo do roghanna chun an méid a fheiceann tú ar Mastodon a rialú:", "report.thanks.take_action": "Seo do roghanna chun an méid a fheiceann tú ar Mastodon a rialú:",

View File

@@ -145,6 +145,9 @@
"account_edit.bio.title": "Sobre ti", "account_edit.bio.title": "Sobre ti",
"account_edit.bio_modal.add_title": "Engadir biografía", "account_edit.bio_modal.add_title": "Engadir biografía",
"account_edit.bio_modal.edit_title": "Editar biografía", "account_edit.bio_modal.edit_title": "Editar biografía",
"account_edit.button.add": "Engadir {item}",
"account_edit.button.delete": "Eliminar {item}",
"account_edit.button.edit": "Editar {item}",
"account_edit.char_counter": "{currentLength}/{maxLength} caracteres", "account_edit.char_counter": "{currentLength}/{maxLength} caracteres",
"account_edit.column_button": "Feito", "account_edit.column_button": "Feito",
"account_edit.column_title": "Editar perfil", "account_edit.column_title": "Editar perfil",
@@ -152,6 +155,7 @@
"account_edit.custom_fields.title": "Campos personalizados", "account_edit.custom_fields.title": "Campos personalizados",
"account_edit.display_name.placeholder": "O nome público é o nome que aparece no perfil e nas cronoloxías.", "account_edit.display_name.placeholder": "O nome público é o nome que aparece no perfil e nas cronoloxías.",
"account_edit.display_name.title": "Nome público", "account_edit.display_name.title": "Nome público",
"account_edit.featured_hashtags.item": "cancelos",
"account_edit.featured_hashtags.placeholder": "Facilita que te identifiquen, e da acceso rápido aos teus intereses favoritos.", "account_edit.featured_hashtags.placeholder": "Facilita que te identifiquen, e da acceso rápido aos teus intereses favoritos.",
"account_edit.featured_hashtags.title": "Cancelos destacados", "account_edit.featured_hashtags.title": "Cancelos destacados",
"account_edit.name_modal.add_title": "Engadir nome público", "account_edit.name_modal.add_title": "Engadir nome público",
@@ -159,6 +163,11 @@
"account_edit.profile_tab.subtitle": "Personaliza as pestanas e o seu contido no teu perfil.", "account_edit.profile_tab.subtitle": "Personaliza as pestanas e o seu contido no teu perfil.",
"account_edit.profile_tab.title": "Perfil e axustes das pestanas", "account_edit.profile_tab.title": "Perfil e axustes das pestanas",
"account_edit.save": "Gardar", "account_edit.save": "Gardar",
"account_edit_tags.column_title": "Editar cancelos destacados",
"account_edit_tags.help_text": "Os cancelos destacados axúdanlle ás usuarias a atopar e interactuar co teu perfil. Aparecen como filtros na túa páxina de perfil na vista Actividade.",
"account_edit_tags.search_placeholder": "Escribe un cancelo…",
"account_edit_tags.suggestions": "Suxestións:",
"account_edit_tags.tag_status_count": "{count, plural, one {# publicación} other {# publicacións}}",
"account_note.placeholder": "Preme para engadir nota", "account_note.placeholder": "Preme para engadir nota",
"admin.dashboard.daily_retention": "Ratio de retención de usuarias diaria após rexistrarse", "admin.dashboard.daily_retention": "Ratio de retención de usuarias diaria após rexistrarse",
"admin.dashboard.monthly_retention": "Ratio de retención de usuarias mensual após o rexistro", "admin.dashboard.monthly_retention": "Ratio de retención de usuarias mensual após o rexistro",
@@ -297,11 +306,13 @@
"collections.no_collections_yet": "Aínda non tes coleccións.", "collections.no_collections_yet": "Aínda non tes coleccións.",
"collections.old_last_post_note": "Hai máis dunha semana da última publicación", "collections.old_last_post_note": "Hai máis dunha semana da última publicación",
"collections.remove_account": "Retirar esta conta", "collections.remove_account": "Retirar esta conta",
"collections.report_collection": "Denunciar esta colección",
"collections.search_accounts_label": "Buscar contas para engadir…", "collections.search_accounts_label": "Buscar contas para engadir…",
"collections.search_accounts_max_reached": "Acadaches o máximo de contas permitidas", "collections.search_accounts_max_reached": "Acadaches o máximo de contas permitidas",
"collections.sensitive": "Sensible", "collections.sensitive": "Sensible",
"collections.topic_hint": "Engadir un cancelo para que axudar a que outras persoas coñezan a temática desta colección.", "collections.topic_hint": "Engadir un cancelo para que axudar a que outras persoas coñezan a temática desta colección.",
"collections.view_collection": "Ver colección", "collections.view_collection": "Ver colección",
"collections.view_other_collections_by_user": "Ver outras coleccións desta usuaria",
"collections.visibility_public": "Pública", "collections.visibility_public": "Pública",
"collections.visibility_public_hint": "Pódese atopar nos resultados das buscas e noutras áreas onde se mostran recomendacións.", "collections.visibility_public_hint": "Pódese atopar nos resultados das buscas e noutras áreas onde se mostran recomendacións.",
"collections.visibility_title": "Visibilidade", "collections.visibility_title": "Visibilidade",
@@ -967,6 +978,7 @@
"report.category.title_account": "perfil", "report.category.title_account": "perfil",
"report.category.title_status": "publicación", "report.category.title_status": "publicación",
"report.close": "Feito", "report.close": "Feito",
"report.collection_comment": "Por que queres denunciar esta colección?",
"report.comment.title": "Hai algo máis que creas debamos saber?", "report.comment.title": "Hai algo máis que creas debamos saber?",
"report.forward": "Reenviar a {target}", "report.forward": "Reenviar a {target}",
"report.forward_hint": "A conta é doutro servidor. Enviar unha copia anónima da denuncia aló tamén?", "report.forward_hint": "A conta é doutro servidor. Enviar unha copia anónima da denuncia aló tamén?",
@@ -988,6 +1000,8 @@
"report.rules.title": "Que regras foron incumpridas?", "report.rules.title": "Que regras foron incumpridas?",
"report.statuses.subtitle": "Elixe todo o que corresponda", "report.statuses.subtitle": "Elixe todo o que corresponda",
"report.statuses.title": "Hai algunha publicación que apoie esta denuncia?", "report.statuses.title": "Hai algunha publicación que apoie esta denuncia?",
"report.submission_error": "Non se puido enviar a denuncia",
"report.submission_error_details": "Comproba a conexión á rede e volve a intentalo máis tarde.",
"report.submit": "Enviar", "report.submit": "Enviar",
"report.target": "Denunciar a {target}", "report.target": "Denunciar a {target}",
"report.thanks.take_action": "Aquí tes unhas opcións para controlar o que ves en Mastodon:", "report.thanks.take_action": "Aquí tes unhas opcións para controlar o que ves en Mastodon:",

View File

@@ -167,7 +167,7 @@
"account_edit_tags.help_text": "תגיות נבחרות עוזרות למשתמשים לגלות ולהשתמש בפרופיל שלך. הן יופיעו כסננים במבט הפעילויות על עמוד הפרופיל שלך.", "account_edit_tags.help_text": "תגיות נבחרות עוזרות למשתמשים לגלות ולהשתמש בפרופיל שלך. הן יופיעו כסננים במבט הפעילויות על עמוד הפרופיל שלך.",
"account_edit_tags.search_placeholder": "הזנת תגית…", "account_edit_tags.search_placeholder": "הזנת תגית…",
"account_edit_tags.suggestions": "הצעות:", "account_edit_tags.suggestions": "הצעות:",
"account_edit_tags.tag_status_count": "{count} הודעות", "account_edit_tags.tag_status_count": "{count, plural, one {הודעה אחת} two {הודעותיים} other {# הודעות}}",
"account_note.placeholder": "יש ללחוץ כדי להוסיף הערות", "account_note.placeholder": "יש ללחוץ כדי להוסיף הערות",
"admin.dashboard.daily_retention": "קצב שימור משתמשים יומי אחרי ההרשמה", "admin.dashboard.daily_retention": "קצב שימור משתמשים יומי אחרי ההרשמה",
"admin.dashboard.monthly_retention": "קצב שימור משתמשים (פר חודש) אחרי ההרשמה", "admin.dashboard.monthly_retention": "קצב שימור משתמשים (פר חודש) אחרי ההרשמה",
@@ -306,11 +306,13 @@
"collections.no_collections_yet": "עוד אין אוספים.", "collections.no_collections_yet": "עוד אין אוספים.",
"collections.old_last_post_note": "פרסמו לאחרונה לפני יותר משבוע", "collections.old_last_post_note": "פרסמו לאחרונה לפני יותר משבוע",
"collections.remove_account": "הסר חשבון זה", "collections.remove_account": "הסר חשבון זה",
"collections.report_collection": "דיווח על אוסף זה",
"collections.search_accounts_label": "לחפש חשבונות להוספה…", "collections.search_accounts_label": "לחפש חשבונות להוספה…",
"collections.search_accounts_max_reached": "הגעת למספר החשבונות המירבי", "collections.search_accounts_max_reached": "הגעת למספר החשבונות המירבי",
"collections.sensitive": "רגיש", "collections.sensitive": "רגיש",
"collections.topic_hint": "הוספת תגית שמסייעת לאחרים להבין את הנושא הראשי של האוסף.", "collections.topic_hint": "הוספת תגית שמסייעת לאחרים להבין את הנושא הראשי של האוסף.",
"collections.view_collection": "צפיה באוסף", "collections.view_collection": "צפיה באוסף",
"collections.view_other_collections_by_user": "צפייה באוספים אחרים של משתמש.ת אלו",
"collections.visibility_public": "פומבי", "collections.visibility_public": "פומבי",
"collections.visibility_public_hint": "זמין לגילוי בתוצאות חיפוש ושאר אזורים בהם מופיעות המלצות.", "collections.visibility_public_hint": "זמין לגילוי בתוצאות חיפוש ושאר אזורים בהם מופיעות המלצות.",
"collections.visibility_title": "ניראות", "collections.visibility_title": "ניראות",
@@ -976,6 +978,7 @@
"report.category.title_account": "פרופיל", "report.category.title_account": "פרופיל",
"report.category.title_status": "הודעה", "report.category.title_status": "הודעה",
"report.close": "בוצע", "report.close": "בוצע",
"report.collection_comment": "מדוע ברצונכם לדווח על האוסף הזה?",
"report.comment.title": "האם יש דבר נוסף שלדעתך חשוב שנדע?", "report.comment.title": "האם יש דבר נוסף שלדעתך חשוב שנדע?",
"report.forward": "קדם ל-{target}", "report.forward": "קדם ל-{target}",
"report.forward_hint": "חשבון זה הוא משרת אחר. האם לשלוח בנוסף עותק אנונימי לשם?", "report.forward_hint": "חשבון זה הוא משרת אחר. האם לשלוח בנוסף עותק אנונימי לשם?",
@@ -997,6 +1000,8 @@
"report.rules.title": "אילו חוקים מופרים?", "report.rules.title": "אילו חוקים מופרים?",
"report.statuses.subtitle": "בחר/י את כל המתאימים", "report.statuses.subtitle": "בחר/י את כל המתאימים",
"report.statuses.title": "האם ישנן הודעות התומכות בדיווח זה?", "report.statuses.title": "האם ישנן הודעות התומכות בדיווח זה?",
"report.submission_error": "לא ניתן לבצע את הדיווח",
"report.submission_error_details": "נא לבדוק את חיבור הרשת ולנסות שוב מאוחר יותר.",
"report.submit": "שליחה", "report.submit": "שליחה",
"report.target": "דיווח על {target}", "report.target": "דיווח על {target}",
"report.thanks.take_action": "הנה כמה אפשרויות לשליטה בתצוגת מסטודון:", "report.thanks.take_action": "הנה כמה אפשרויות לשליטה בתצוגת מסטודון:",

View File

@@ -167,7 +167,7 @@
"account_edit_tags.help_text": "Myllumerki með aukið vægi hjálpa lesendum að finna og eiga við notandasíðuna þína. Þau birtast sem síur í virkniflipa notandasíðunnar þinnar.", "account_edit_tags.help_text": "Myllumerki með aukið vægi hjálpa lesendum að finna og eiga við notandasíðuna þína. Þau birtast sem síur í virkniflipa notandasíðunnar þinnar.",
"account_edit_tags.search_placeholder": "Settu inn myllumerki…", "account_edit_tags.search_placeholder": "Settu inn myllumerki…",
"account_edit_tags.suggestions": "Tillögur:", "account_edit_tags.suggestions": "Tillögur:",
"account_edit_tags.tag_status_count": "{count} færslur", "account_edit_tags.tag_status_count": "{count, plural, one {# færsla} other {# færslur}}",
"account_note.placeholder": "Smelltu til að bæta við minnispunkti", "account_note.placeholder": "Smelltu til að bæta við minnispunkti",
"admin.dashboard.daily_retention": "Hlutfall virkra notenda eftir nýskráningu eftir dögum", "admin.dashboard.daily_retention": "Hlutfall virkra notenda eftir nýskráningu eftir dögum",
"admin.dashboard.monthly_retention": "Hlutfall virkra notenda eftir nýskráningu eftir mánuðum", "admin.dashboard.monthly_retention": "Hlutfall virkra notenda eftir nýskráningu eftir mánuðum",
@@ -306,11 +306,13 @@
"collections.no_collections_yet": "Engin söfn ennþá.", "collections.no_collections_yet": "Engin söfn ennþá.",
"collections.old_last_post_note": "Birti síðast fyrir meira en viku síðan", "collections.old_last_post_note": "Birti síðast fyrir meira en viku síðan",
"collections.remove_account": "Fjarlægja þennan aðgang", "collections.remove_account": "Fjarlægja þennan aðgang",
"collections.report_collection": "Kæra þetta safn",
"collections.search_accounts_label": "Leita að aðgöngum til að bæta við…", "collections.search_accounts_label": "Leita að aðgöngum til að bæta við…",
"collections.search_accounts_max_reached": "Þú hefur þegar bætt við leyfilegum hámarksfjölda aðganga", "collections.search_accounts_max_reached": "Þú hefur þegar bætt við leyfilegum hámarksfjölda aðganga",
"collections.sensitive": "Viðkvæmt", "collections.sensitive": "Viðkvæmt",
"collections.topic_hint": "Bættu við myllumerki sem hjálpar öðrum að skilja aðalefni þessa safns.", "collections.topic_hint": "Bættu við myllumerki sem hjálpar öðrum að skilja aðalefni þessa safns.",
"collections.view_collection": "Skoða safn", "collections.view_collection": "Skoða safn",
"collections.view_other_collections_by_user": "Skoða önnur söfn frá þessum notanda",
"collections.visibility_public": "Opinbert", "collections.visibility_public": "Opinbert",
"collections.visibility_public_hint": "Hægt að finna í leitarniðurstöðum og öðrum þeim þáttum þar sem meðmæli birtast.", "collections.visibility_public_hint": "Hægt að finna í leitarniðurstöðum og öðrum þeim þáttum þar sem meðmæli birtast.",
"collections.visibility_title": "Sýnileiki", "collections.visibility_title": "Sýnileiki",
@@ -976,6 +978,7 @@
"report.category.title_account": "notandasnið", "report.category.title_account": "notandasnið",
"report.category.title_status": "færsla", "report.category.title_status": "færsla",
"report.close": "Lokið", "report.close": "Lokið",
"report.collection_comment": "Hvers vegna viltu kæra þetta safn?",
"report.comment.title": "Er eitthvað annað sem þú heldur að við ættum að vita?", "report.comment.title": "Er eitthvað annað sem þú heldur að við ættum að vita?",
"report.forward": "Áframsenda til {target}", "report.forward": "Áframsenda til {target}",
"report.forward_hint": "Notandaaðgangurinn er af öðrum vefþjóni. Á einnig að senda nafnlaust afrit af kærunni þangað?", "report.forward_hint": "Notandaaðgangurinn er af öðrum vefþjóni. Á einnig að senda nafnlaust afrit af kærunni þangað?",
@@ -997,6 +1000,8 @@
"report.rules.title": "Hvaða reglur eru brotnar?", "report.rules.title": "Hvaða reglur eru brotnar?",
"report.statuses.subtitle": "Veldu allt sem á við", "report.statuses.subtitle": "Veldu allt sem á við",
"report.statuses.title": "Eru einhverjar færslur sem styðja þessa kæru?", "report.statuses.title": "Eru einhverjar færslur sem styðja þessa kæru?",
"report.submission_error": "Ekki var hægt að senda inn kæruna",
"report.submission_error_details": "Athugaðu nettenginguna þína og prófaðu aftur síðar.",
"report.submit": "Senda inn", "report.submit": "Senda inn",
"report.target": "Kæri {target}", "report.target": "Kæri {target}",
"report.thanks.take_action": "Hér eru nokkrir valkostir til að stýra hvað þú sérð á Mastodon:", "report.thanks.take_action": "Hér eru nokkrir valkostir til að stýra hvað þú sérð á Mastodon:",

View File

@@ -167,7 +167,7 @@
"account_edit_tags.help_text": "Gli hashtag in evidenza aiutano gli utenti a scoprire e interagire con il tuo profilo. Appaiono come filtri nella visualizzazione Attività della tua pagina del profilo.", "account_edit_tags.help_text": "Gli hashtag in evidenza aiutano gli utenti a scoprire e interagire con il tuo profilo. Appaiono come filtri nella visualizzazione Attività della tua pagina del profilo.",
"account_edit_tags.search_placeholder": "Inserisci un hashtag…", "account_edit_tags.search_placeholder": "Inserisci un hashtag…",
"account_edit_tags.suggestions": "Suggerimenti:", "account_edit_tags.suggestions": "Suggerimenti:",
"account_edit_tags.tag_status_count": "{count} post", "account_edit_tags.tag_status_count": "{count, plural, one {# post} other {# post}}",
"account_note.placeholder": "Clicca per aggiungere una nota", "account_note.placeholder": "Clicca per aggiungere una nota",
"admin.dashboard.daily_retention": "Tasso di ritenzione dell'utente per giorno, dopo la registrazione", "admin.dashboard.daily_retention": "Tasso di ritenzione dell'utente per giorno, dopo la registrazione",
"admin.dashboard.monthly_retention": "Tasso di ritenzione dell'utente per mese, dopo la registrazione", "admin.dashboard.monthly_retention": "Tasso di ritenzione dell'utente per mese, dopo la registrazione",
@@ -306,11 +306,13 @@
"collections.no_collections_yet": "Nessuna collezione ancora.", "collections.no_collections_yet": "Nessuna collezione ancora.",
"collections.old_last_post_note": "Ultimo post più di una settimana fa", "collections.old_last_post_note": "Ultimo post più di una settimana fa",
"collections.remove_account": "Rimuovi questo account", "collections.remove_account": "Rimuovi questo account",
"collections.report_collection": "Segnala questa collezione",
"collections.search_accounts_label": "Cerca account da aggiungere…", "collections.search_accounts_label": "Cerca account da aggiungere…",
"collections.search_accounts_max_reached": "Hai aggiunto il numero massimo di account", "collections.search_accounts_max_reached": "Hai aggiunto il numero massimo di account",
"collections.sensitive": "Sensibile", "collections.sensitive": "Sensibile",
"collections.topic_hint": "Aggiungi un hashtag che aiuti gli altri a comprendere l'argomento principale di questa collezione.", "collections.topic_hint": "Aggiungi un hashtag che aiuti gli altri a comprendere l'argomento principale di questa collezione.",
"collections.view_collection": "Visualizza la collezione", "collections.view_collection": "Visualizza la collezione",
"collections.view_other_collections_by_user": "Visualizza altre collezioni da questo utente",
"collections.visibility_public": "Pubblica", "collections.visibility_public": "Pubblica",
"collections.visibility_public_hint": "Scopribile nei risultati di ricerca e in altre aree in cui compaiono i suggerimenti.", "collections.visibility_public_hint": "Scopribile nei risultati di ricerca e in altre aree in cui compaiono i suggerimenti.",
"collections.visibility_title": "Visibilità", "collections.visibility_title": "Visibilità",
@@ -976,6 +978,7 @@
"report.category.title_account": "profilo", "report.category.title_account": "profilo",
"report.category.title_status": "post", "report.category.title_status": "post",
"report.close": "Fatto", "report.close": "Fatto",
"report.collection_comment": "Perché vuoi segnalare questa collezione?",
"report.comment.title": "C'è altro che pensi che dovremmo sapere?", "report.comment.title": "C'è altro che pensi che dovremmo sapere?",
"report.forward": "Inoltra a {target}", "report.forward": "Inoltra a {target}",
"report.forward_hint": "Il profilo proviene da un altro server. Inviare anche lì una copia anonima del rapporto?", "report.forward_hint": "Il profilo proviene da un altro server. Inviare anche lì una copia anonima del rapporto?",
@@ -997,6 +1000,8 @@
"report.rules.title": "Quali regole sono violate?", "report.rules.title": "Quali regole sono violate?",
"report.statuses.subtitle": "Seleziona tutte le risposte pertinenti", "report.statuses.subtitle": "Seleziona tutte le risposte pertinenti",
"report.statuses.title": "Ci sono dei post a sostegno di questa segnalazione?", "report.statuses.title": "Ci sono dei post a sostegno di questa segnalazione?",
"report.submission_error": "Non è stato possibile inviare la segnalazione",
"report.submission_error_details": "Si prega di controllare la tua connessione di rete e riprovare più tardi.",
"report.submit": "Invia", "report.submit": "Invia",
"report.target": "Segnalando {target}", "report.target": "Segnalando {target}",
"report.thanks.take_action": "Ecco le tue opzioni per controllare cosa vedi su Mastodon:", "report.thanks.take_action": "Ecco le tue opzioni per controllare cosa vedi su Mastodon:",

View File

@@ -167,7 +167,6 @@
"account_edit_tags.help_text": "Aanbevolen hashtags helpen gebruikers je profiel te ontdekken en te communiceren. Ze verschijnen als filters op de activiteitenweergave van je pagina.", "account_edit_tags.help_text": "Aanbevolen hashtags helpen gebruikers je profiel te ontdekken en te communiceren. Ze verschijnen als filters op de activiteitenweergave van je pagina.",
"account_edit_tags.search_placeholder": "Voer een hashtag in…", "account_edit_tags.search_placeholder": "Voer een hashtag in…",
"account_edit_tags.suggestions": "Suggesties:", "account_edit_tags.suggestions": "Suggesties:",
"account_edit_tags.tag_status_count": "{count} berichten",
"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",

View File

@@ -145,6 +145,9 @@
"account_edit.bio.title": "Om meg", "account_edit.bio.title": "Om meg",
"account_edit.bio_modal.add_title": "Skriv om deg sjølv", "account_edit.bio_modal.add_title": "Skriv om deg sjølv",
"account_edit.bio_modal.edit_title": "Endre bio", "account_edit.bio_modal.edit_title": "Endre bio",
"account_edit.button.add": "Legg til {item}",
"account_edit.button.delete": "Slett {item}",
"account_edit.button.edit": "Rediger {item}",
"account_edit.char_counter": "{currentLength}/{maxLength} teikn", "account_edit.char_counter": "{currentLength}/{maxLength} teikn",
"account_edit.column_button": "Ferdig", "account_edit.column_button": "Ferdig",
"account_edit.column_title": "Rediger profil", "account_edit.column_title": "Rediger profil",
@@ -152,6 +155,7 @@
"account_edit.custom_fields.title": "Eigne felt", "account_edit.custom_fields.title": "Eigne felt",
"account_edit.display_name.placeholder": "Det synlege namnet ditt er det som syner på profilen din og i tidsliner.", "account_edit.display_name.placeholder": "Det synlege namnet ditt er det som syner på profilen din og i tidsliner.",
"account_edit.display_name.title": "Synleg namn", "account_edit.display_name.title": "Synleg namn",
"account_edit.featured_hashtags.item": "emneknaggar",
"account_edit.featured_hashtags.placeholder": "Hjelp andre å finna og få rask tilgang til favorittemna dine.", "account_edit.featured_hashtags.placeholder": "Hjelp andre å finna og få rask tilgang til favorittemna dine.",
"account_edit.featured_hashtags.title": "Utvalde emneknaggar", "account_edit.featured_hashtags.title": "Utvalde emneknaggar",
"account_edit.name_modal.add_title": "Legg til synleg namn", "account_edit.name_modal.add_title": "Legg til synleg namn",
@@ -159,6 +163,11 @@
"account_edit.profile_tab.subtitle": "Tilpass fanene på profilen din og kva dei syner.", "account_edit.profile_tab.subtitle": "Tilpass fanene på profilen din og kva dei syner.",
"account_edit.profile_tab.title": "Innstillingar for profilfane", "account_edit.profile_tab.title": "Innstillingar for profilfane",
"account_edit.save": "Lagre", "account_edit.save": "Lagre",
"account_edit_tags.column_title": "Rediger utvalde emneknaggar",
"account_edit_tags.help_text": "Utvalde emneknaggar hjelper folk å oppdaga og samhandla med profilen din. Dei blir viste som filter på aktivitetsoversikta på profilsida di.",
"account_edit_tags.search_placeholder": "Skriv ein emneknagg…",
"account_edit_tags.suggestions": "Framlegg:",
"account_edit_tags.tag_status_count": "{count, plural, one {# innlegg} other {# innlegg}}",
"account_note.placeholder": "Klikk for å leggja til merknad", "account_note.placeholder": "Klikk for å leggja til merknad",
"admin.dashboard.daily_retention": "Mengda brukarar aktive ved dagar etter registrering", "admin.dashboard.daily_retention": "Mengda brukarar aktive ved dagar etter registrering",
"admin.dashboard.monthly_retention": "Mengda brukarar aktive ved månader etter registrering", "admin.dashboard.monthly_retention": "Mengda brukarar aktive ved månader etter registrering",

View File

@@ -106,9 +106,9 @@
"account.mutual": "Vocês se seguem", "account.mutual": "Vocês se seguem",
"account.name.help.domain": "{domain} é o servidor que hospeda o perfil e publicações do usuário.", "account.name.help.domain": "{domain} é o servidor que hospeda o perfil e publicações do usuário.",
"account.name.help.domain_self": "{domain} é o seu servidor que hospeda seu perfil e publicações.", "account.name.help.domain_self": "{domain} é o seu servidor que hospeda seu perfil e publicações.",
"account.name.help.footer": "Da mesma forma que você pode enviar emails para pessoas utilizando diferentes clientes de email, você pode interagir com pessoas em outros servidores do Mastodon e com qualquer um em outros aplicativos sociais regidos pelo mesmo conjunto de regras que o Mastodon (chamadas de Protocolo ActivityPub).", "account.name.help.footer": "Assim como pode enviar mensagens eletrônicas de serviços diferentes, você pode interagir com pessoas de outros servidores Mastodon e qualquer pessoa em um aplicativo alimentado com as regras utilizadas pelo Mastodon (protocolo ActivityPub).",
"account.name.help.header": "Um nome de usuário é como um endereço de email", "account.name.help.header": "Um identificador é como um endereço de endereço eletrônico",
"account.name.help.username": "{username} é o nome de usuário desta conta no servidor dela. Alguém em outro servidor pode ter o mesmo nome de usuário.", "account.name.help.username": "{username} é o nome de usuário da conta neste servidor. Alguém em outro servidor pode ter o mesmo nome de usuário.",
"account.name.help.username_self": "{username} é seu nome de usuário neste servidor. Alguém em outro servidor pode ter o mesmo nome de usuário.", "account.name.help.username_self": "{username} é seu nome de usuário neste servidor. Alguém em outro servidor pode ter o mesmo nome de usuário.",
"account.name_info": "O que isto significa?", "account.name_info": "O que isto significa?",
"account.no_bio": "Nenhuma descrição fornecida.", "account.no_bio": "Nenhuma descrição fornecida.",
@@ -167,7 +167,7 @@
"account_edit_tags.help_text": "Hashtags em destaque ajudam os usuários a descobrir e interagir com seu perfil. Elas aparecem como filtros na visualização de Atividade da sua página de Perfil.", "account_edit_tags.help_text": "Hashtags em destaque ajudam os usuários a descobrir e interagir com seu perfil. Elas aparecem como filtros na visualização de Atividade da sua página de Perfil.",
"account_edit_tags.search_placeholder": "Insira uma hashtag…", "account_edit_tags.search_placeholder": "Insira uma hashtag…",
"account_edit_tags.suggestions": "Sugestões:", "account_edit_tags.suggestions": "Sugestões:",
"account_edit_tags.tag_status_count": "{count} publicações", "account_edit_tags.tag_status_count": "{count, plural, one {# publicação} other {# publicações}}",
"account_note.placeholder": "Nota pessoal sobre este perfil aqui", "account_note.placeholder": "Nota pessoal sobre este perfil aqui",
"admin.dashboard.daily_retention": "Taxa de retenção de usuários por dia, após a inscrição", "admin.dashboard.daily_retention": "Taxa de retenção de usuários por dia, após a inscrição",
"admin.dashboard.monthly_retention": "Taxa de retenção de usuários por mês, após a inscrição", "admin.dashboard.monthly_retention": "Taxa de retenção de usuários por mês, após a inscrição",

View File

@@ -167,7 +167,7 @@
"account_edit_tags.help_text": "Hashtag-ët e zgjedhur i ndihmojnë përdoruesit të zbulojnë dhe ndërveprojnë me profilin tuaj. Ata duken si filtra te pamja Veprimtari e faqes tuaj të Profilit.", "account_edit_tags.help_text": "Hashtag-ët e zgjedhur i ndihmojnë përdoruesit të zbulojnë dhe ndërveprojnë me profilin tuaj. Ata duken si filtra te pamja Veprimtari e faqes tuaj të Profilit.",
"account_edit_tags.search_placeholder": "Jepni një hashtag…", "account_edit_tags.search_placeholder": "Jepni një hashtag…",
"account_edit_tags.suggestions": "Sugjerime:", "account_edit_tags.suggestions": "Sugjerime:",
"account_edit_tags.tag_status_count": "{count} postime", "account_edit_tags.tag_status_count": "{count, plural, one {# postim} other {# postime}}",
"account_note.placeholder": "Klikoni për të shtuar shënim", "account_note.placeholder": "Klikoni për të shtuar shënim",
"admin.dashboard.daily_retention": "Shkallë mbajtjeje përdoruesi, në ditë, pas regjistrimit", "admin.dashboard.daily_retention": "Shkallë mbajtjeje përdoruesi, në ditë, pas regjistrimit",
"admin.dashboard.monthly_retention": "Shkallë mbajtjeje përdoruesi, në muaj, pas regjistrimit", "admin.dashboard.monthly_retention": "Shkallë mbajtjeje përdoruesi, në muaj, pas regjistrimit",
@@ -303,11 +303,13 @@
"collections.no_collections_yet": "Ende pa koleksione.", "collections.no_collections_yet": "Ende pa koleksione.",
"collections.old_last_post_note": "Të postuarat e fundit gjatë një jave më parë", "collections.old_last_post_note": "Të postuarat e fundit gjatë një jave më parë",
"collections.remove_account": "Hiqe këtë llogari", "collections.remove_account": "Hiqe këtë llogari",
"collections.report_collection": "Raportojeni këtë koleksion",
"collections.search_accounts_label": "Kërkoni për llogari për shtim…", "collections.search_accounts_label": "Kërkoni për llogari për shtim…",
"collections.search_accounts_max_reached": "Keni shtuar numrin maksimum të llogarive", "collections.search_accounts_max_reached": "Keni shtuar numrin maksimum të llogarive",
"collections.sensitive": "Rezervat", "collections.sensitive": "Rezervat",
"collections.topic_hint": "Shtoni një hashtag që ndihmon të tjerët të kuptojnë temën kryesore të këtij koleksion.", "collections.topic_hint": "Shtoni një hashtag që ndihmon të tjerët të kuptojnë temën kryesore të këtij koleksion.",
"collections.view_collection": "Shiheni koleksionin", "collections.view_collection": "Shiheni koleksionin",
"collections.view_other_collections_by_user": "Shihni koleksione të tjera nga ky përdorues",
"collections.visibility_public": "Publik", "collections.visibility_public": "Publik",
"collections.visibility_public_hint": "I zbulueshëm në përfundime kërkimi dhe fusha të tjera ku shfaqen rekomandime.", "collections.visibility_public_hint": "I zbulueshëm në përfundime kërkimi dhe fusha të tjera ku shfaqen rekomandime.",
"collections.visibility_title": "Dukshmëri", "collections.visibility_title": "Dukshmëri",
@@ -973,6 +975,7 @@
"report.category.title_account": "profil", "report.category.title_account": "profil",
"report.category.title_status": "postim", "report.category.title_status": "postim",
"report.close": "U bë", "report.close": "U bë",
"report.collection_comment": "Pse doni ta raportoni këtë koleksion?",
"report.comment.title": "Ka ndonjë gjë tjetër që do të duhej ta dinim?", "report.comment.title": "Ka ndonjë gjë tjetër që do të duhej ta dinim?",
"report.forward": "Përcillja {target}", "report.forward": "Përcillja {target}",
"report.forward_hint": "Llogaria është nga një shërbyes tjetër. Të dërgohet edhe një kopje e anonimizuar e raportimit?", "report.forward_hint": "Llogaria është nga një shërbyes tjetër. Të dërgohet edhe një kopje e anonimizuar e raportimit?",
@@ -994,6 +997,8 @@
"report.rules.title": "Cilat rregulla po cenohen?", "report.rules.title": "Cilat rregulla po cenohen?",
"report.statuses.subtitle": "Përzgjidhni gjithçka që ka vend", "report.statuses.subtitle": "Përzgjidhni gjithçka që ka vend",
"report.statuses.title": "A ka postime që dëshmojnë problemet e këtij raporti?", "report.statuses.title": "A ka postime që dëshmojnë problemet e këtij raporti?",
"report.submission_error": "Raportimi su parashtrua dot",
"report.submission_error_details": "Ju lutemi, kontrolloni lidhjen tuaj në rrjet dhe riprovoni më vonë.",
"report.submit": "Parashtroje", "report.submit": "Parashtroje",
"report.target": "Raportim i {target}", "report.target": "Raportim i {target}",
"report.thanks.take_action": "Ja mundësitë tuaja për të kontrolluar çshihni në Mastodon:", "report.thanks.take_action": "Ja mundësitë tuaja për të kontrolluar çshihni në Mastodon:",

View File

@@ -874,6 +874,7 @@
"report.rules.title": "Vilka regler överträds?", "report.rules.title": "Vilka regler överträds?",
"report.statuses.subtitle": "Välj alla som stämmer", "report.statuses.subtitle": "Välj alla som stämmer",
"report.statuses.title": "Finns det några inlägg som stöder denna rapport?", "report.statuses.title": "Finns det några inlägg som stöder denna rapport?",
"report.submission_error_details": "Kontrollera din nätverksanslutning och försök igen senare.",
"report.submit": "Skicka", "report.submit": "Skicka",
"report.target": "Rapporterar {target}", "report.target": "Rapporterar {target}",
"report.thanks.take_action": "Här är dina alternativ för att bestämma vad du ser på Mastodon:", "report.thanks.take_action": "Här är dina alternativ för att bestämma vad du ser på Mastodon:",

View File

@@ -145,6 +145,9 @@
"account_edit.bio.title": "Kişisel bilgiler", "account_edit.bio.title": "Kişisel bilgiler",
"account_edit.bio_modal.add_title": "Kişisel bilgi ekle", "account_edit.bio_modal.add_title": "Kişisel bilgi ekle",
"account_edit.bio_modal.edit_title": "Kişisel bilgiyi düzenle", "account_edit.bio_modal.edit_title": "Kişisel bilgiyi düzenle",
"account_edit.button.add": "{item} ekle",
"account_edit.button.delete": "{item} sil",
"account_edit.button.edit": "{item} düzenle",
"account_edit.char_counter": "{currentLength}/{maxLength} karakter", "account_edit.char_counter": "{currentLength}/{maxLength} karakter",
"account_edit.column_button": "Tamamlandı", "account_edit.column_button": "Tamamlandı",
"account_edit.column_title": "Profili Düzenle", "account_edit.column_title": "Profili Düzenle",
@@ -152,6 +155,7 @@
"account_edit.custom_fields.title": "Özel alanlar", "account_edit.custom_fields.title": "Özel alanlar",
"account_edit.display_name.placeholder": "Görünen adınız profilinizde ve zaman akışlarında adınızın nasıl göründüğüdür.", "account_edit.display_name.placeholder": "Görünen adınız profilinizde ve zaman akışlarında adınızın nasıl göründüğüdür.",
"account_edit.display_name.title": "Görünen ad", "account_edit.display_name.title": "Görünen ad",
"account_edit.featured_hashtags.item": "etiketler",
"account_edit.featured_hashtags.placeholder": "Başkalarının favori konularınızı tanımlamasına ve bunlara hızlı bir şekilde erişmesine yardımcı olun.", "account_edit.featured_hashtags.placeholder": "Başkalarının favori konularınızı tanımlamasına ve bunlara hızlı bir şekilde erişmesine yardımcı olun.",
"account_edit.featured_hashtags.title": "Öne çıkan etiketler", "account_edit.featured_hashtags.title": "Öne çıkan etiketler",
"account_edit.name_modal.add_title": "Görünen ad ekle", "account_edit.name_modal.add_title": "Görünen ad ekle",
@@ -159,6 +163,11 @@
"account_edit.profile_tab.subtitle": "Profilinizdeki sekmeleri ve bunların görüntülediği bilgileri özelleştirin.", "account_edit.profile_tab.subtitle": "Profilinizdeki sekmeleri ve bunların görüntülediği bilgileri özelleştirin.",
"account_edit.profile_tab.title": "Profil sekme ayarları", "account_edit.profile_tab.title": "Profil sekme ayarları",
"account_edit.save": "Kaydet", "account_edit.save": "Kaydet",
"account_edit_tags.column_title": "Öne çıkarılmış etiketleri düzenle",
"account_edit_tags.help_text": "Öne çıkan etiketler kullanıcıların profilinizi keşfetmesine ve etkileşim kurmasına yardımcı olur. Profil sayfanızın Etkinlik görünümünde filtreler olarak görünürler.",
"account_edit_tags.search_placeholder": "Bir etiket girin…",
"account_edit_tags.suggestions": "Öneriler:",
"account_edit_tags.tag_status_count": "{count, plural, one {# gönderi} other {# gönderi}}",
"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ı",

View File

@@ -167,7 +167,7 @@
"account_edit_tags.help_text": "Hashtag thường dùng giúp bạn mọi người khám phá và tương tác với hồ sơ của bạn. Chúng xuất hiện như những bộ lọc trên phần Hoạt động hồ sơ.", "account_edit_tags.help_text": "Hashtag thường dùng giúp bạn mọi người khám phá và tương tác với hồ sơ của bạn. Chúng xuất hiện như những bộ lọc trên phần Hoạt động hồ sơ.",
"account_edit_tags.search_placeholder": "Nhập một hashtag…", "account_edit_tags.search_placeholder": "Nhập một hashtag…",
"account_edit_tags.suggestions": "Được đề xuất:", "account_edit_tags.suggestions": "Được đề xuất:",
"account_edit_tags.tag_status_count": "{count} tút", "account_edit_tags.tag_status_count": "{count, plural, other {# tút}}",
"account_note.placeholder": "Nhấn để thêm", "account_note.placeholder": "Nhấn để thêm",
"admin.dashboard.daily_retention": "Tỉ lệ người dùng sau đăng ký ở lại theo ngày", "admin.dashboard.daily_retention": "Tỉ lệ người dùng sau đăng ký ở lại theo ngày",
"admin.dashboard.monthly_retention": "Tỉ lệ người dùng ở lại sau khi đăng ký", "admin.dashboard.monthly_retention": "Tỉ lệ người dùng ở lại sau khi đăng ký",
@@ -306,11 +306,13 @@
"collections.no_collections_yet": "Chưa có collection.", "collections.no_collections_yet": "Chưa có collection.",
"collections.old_last_post_note": "Đăng lần cuối hơn một tuần trước", "collections.old_last_post_note": "Đăng lần cuối hơn một tuần trước",
"collections.remove_account": "Gỡ tài khoản này", "collections.remove_account": "Gỡ tài khoản này",
"collections.report_collection": "Báo cáo collection này",
"collections.search_accounts_label": "Tìm tài khoản để thêm…", "collections.search_accounts_label": "Tìm tài khoản để thêm…",
"collections.search_accounts_max_reached": "Bạn đã đạt đến số lượng tài khoản tối đa", "collections.search_accounts_max_reached": "Bạn đã đạt đến số lượng tài khoản tối đa",
"collections.sensitive": "Nhạy cảm", "collections.sensitive": "Nhạy cảm",
"collections.topic_hint": "Thêm hashtag giúp người khác hiểu chủ đề chính của collection này.", "collections.topic_hint": "Thêm hashtag giúp người khác hiểu chủ đề chính của collection này.",
"collections.view_collection": "Xem collection", "collections.view_collection": "Xem collection",
"collections.view_other_collections_by_user": "View những collection khác từ tài khoản này",
"collections.visibility_public": "Công khai", "collections.visibility_public": "Công khai",
"collections.visibility_public_hint": "Có thể tìm thấy trong kết quả tìm kiếm và các khu vực khác nơi xuất hiện đề xuất.", "collections.visibility_public_hint": "Có thể tìm thấy trong kết quả tìm kiếm và các khu vực khác nơi xuất hiện đề xuất.",
"collections.visibility_title": "Hiển thị", "collections.visibility_title": "Hiển thị",
@@ -976,6 +978,7 @@
"report.category.title_account": "Người", "report.category.title_account": "Người",
"report.category.title_status": "Tút", "report.category.title_status": "Tút",
"report.close": "Xong", "report.close": "Xong",
"report.collection_comment": "Vì sao bạn muốn báo cáo collection này?",
"report.comment.title": "Có điều gì mà chúng tôi cần biết không?", "report.comment.title": "Có điều gì mà chúng tôi cần biết không?",
"report.forward": "Chuyển đến {target}", "report.forward": "Chuyển đến {target}",
"report.forward_hint": "Người này thuộc máy chủ khác. Gửi một báo cáo ẩn danh tới máy chủ đó?", "report.forward_hint": "Người này thuộc máy chủ khác. Gửi một báo cáo ẩn danh tới máy chủ đó?",
@@ -997,6 +1000,8 @@
"report.rules.title": "Vi phạm nội quy nào?", "report.rules.title": "Vi phạm nội quy nào?",
"report.statuses.subtitle": "Chọn tất cả những gì phù hợp", "report.statuses.subtitle": "Chọn tất cả những gì phù hợp",
"report.statuses.title": "Bạn muốn báo cáo tút nào?", "report.statuses.title": "Bạn muốn báo cáo tút nào?",
"report.submission_error": "Không thể gửi báo cáo",
"report.submission_error_details": "Kiểm tra kết nối mạng và thử lại sau.",
"report.submit": "Gửi đi", "report.submit": "Gửi đi",
"report.target": "Báo cáo {target}", "report.target": "Báo cáo {target}",
"report.thanks.take_action": "Đây là cách kiểm soát những thứ mà bạn thấy:", "report.thanks.take_action": "Đây là cách kiểm soát những thứ mà bạn thấy:",

View File

@@ -167,7 +167,7 @@
"account_edit_tags.help_text": "精选话题标签可以帮助他人发现并与你的个人资料互动。这些标签会作为过滤器条件出现在你个人资料页面的活动视图中。", "account_edit_tags.help_text": "精选话题标签可以帮助他人发现并与你的个人资料互动。这些标签会作为过滤器条件出现在你个人资料页面的活动视图中。",
"account_edit_tags.search_placeholder": "输入话题标签…", "account_edit_tags.search_placeholder": "输入话题标签…",
"account_edit_tags.suggestions": "建议:", "account_edit_tags.suggestions": "建议:",
"account_edit_tags.tag_status_count": "{count} 条嘟文", "account_edit_tags.tag_status_count": "{count, plural, other {# 条嘟文}}",
"account_note.placeholder": "点击添加备注", "account_note.placeholder": "点击添加备注",
"admin.dashboard.daily_retention": "注册后用户留存率(按日计算)", "admin.dashboard.daily_retention": "注册后用户留存率(按日计算)",
"admin.dashboard.monthly_retention": "注册后用户留存率(按月计算)", "admin.dashboard.monthly_retention": "注册后用户留存率(按月计算)",
@@ -306,11 +306,13 @@
"collections.no_collections_yet": "尚无收藏列表。", "collections.no_collections_yet": "尚无收藏列表。",
"collections.old_last_post_note": "上次发言于一周多以前", "collections.old_last_post_note": "上次发言于一周多以前",
"collections.remove_account": "移除此账号", "collections.remove_account": "移除此账号",
"collections.report_collection": "举报此收藏列表",
"collections.search_accounts_label": "搜索要添加的账号…", "collections.search_accounts_label": "搜索要添加的账号…",
"collections.search_accounts_max_reached": "你添加的账号数量已达上限", "collections.search_accounts_max_reached": "你添加的账号数量已达上限",
"collections.sensitive": "敏感内容", "collections.sensitive": "敏感内容",
"collections.topic_hint": "添加话题标签,帮助他人了解此收藏列表的主题。", "collections.topic_hint": "添加话题标签,帮助他人了解此收藏列表的主题。",
"collections.view_collection": "查看收藏列表", "collections.view_collection": "查看收藏列表",
"collections.view_other_collections_by_user": "查看此用户的其他收藏列表",
"collections.visibility_public": "公开", "collections.visibility_public": "公开",
"collections.visibility_public_hint": "可在搜索结果及其他推荐功能可用的区域被发现。", "collections.visibility_public_hint": "可在搜索结果及其他推荐功能可用的区域被发现。",
"collections.visibility_title": "可见性", "collections.visibility_title": "可见性",
@@ -976,6 +978,7 @@
"report.category.title_account": "账号", "report.category.title_account": "账号",
"report.category.title_status": "嘟文", "report.category.title_status": "嘟文",
"report.close": "完成", "report.close": "完成",
"report.collection_comment": "举报此收藏列表的原因是什么?",
"report.comment.title": "还有什么你认为我们应该知道的吗?", "report.comment.title": "还有什么你认为我们应该知道的吗?",
"report.forward": "转发举报至 {target}", "report.forward": "转发举报至 {target}",
"report.forward_hint": "这名用户来自另一个服务器。是否要向那个服务器发送一条匿名的举报?", "report.forward_hint": "这名用户来自另一个服务器。是否要向那个服务器发送一条匿名的举报?",
@@ -997,6 +1000,8 @@
"report.rules.title": "违反了哪些规则?", "report.rules.title": "违反了哪些规则?",
"report.statuses.subtitle": "选择全部适用选项", "report.statuses.subtitle": "选择全部适用选项",
"report.statuses.title": "是否有任何嘟文可以支持这一报告?", "report.statuses.title": "是否有任何嘟文可以支持这一报告?",
"report.submission_error": "无法提交举报",
"report.submission_error_details": "请检查网络连接,然后再试一次。",
"report.submit": "提交", "report.submit": "提交",
"report.target": "举报 {target}", "report.target": "举报 {target}",
"report.thanks.take_action": "以下是你控制你在 Mastodon 上能看到哪些内容的选项:", "report.thanks.take_action": "以下是你控制你在 Mastodon 上能看到哪些内容的选项:",

View File

@@ -167,7 +167,7 @@
"account_edit_tags.help_text": "推薦主題標籤幫助其他人發現並與您的個人檔案互動。它們將作為過濾器出現於您個人檔案頁面之動態中。", "account_edit_tags.help_text": "推薦主題標籤幫助其他人發現並與您的個人檔案互動。它們將作為過濾器出現於您個人檔案頁面之動態中。",
"account_edit_tags.search_placeholder": "請輸入主題標籤…", "account_edit_tags.search_placeholder": "請輸入主題標籤…",
"account_edit_tags.suggestions": "建議:", "account_edit_tags.suggestions": "建議:",
"account_edit_tags.tag_status_count": "{count} 則嘟文", "account_edit_tags.tag_status_count": "{count, plural, other {# 則嘟文}}",
"account_note.placeholder": "點擊以新增備註", "account_note.placeholder": "點擊以新增備註",
"admin.dashboard.daily_retention": "註冊後使用者存留率(日)", "admin.dashboard.daily_retention": "註冊後使用者存留率(日)",
"admin.dashboard.monthly_retention": "註冊後使用者存留率(月)", "admin.dashboard.monthly_retention": "註冊後使用者存留率(月)",
@@ -306,11 +306,13 @@
"collections.no_collections_yet": "您沒有任何收藏名單。", "collections.no_collections_yet": "您沒有任何收藏名單。",
"collections.old_last_post_note": "上次發表嘟文已超過一週", "collections.old_last_post_note": "上次發表嘟文已超過一週",
"collections.remove_account": "移除此帳號", "collections.remove_account": "移除此帳號",
"collections.report_collection": "檢舉此收藏名單",
"collections.search_accounts_label": "搜尋帳號以加入...", "collections.search_accounts_label": "搜尋帳號以加入...",
"collections.search_accounts_max_reached": "您新增之帳號數已達上限", "collections.search_accounts_max_reached": "您新增之帳號數已達上限",
"collections.sensitive": "敏感內容", "collections.sensitive": "敏感內容",
"collections.topic_hint": "新增主題標籤以協助其他人瞭解此收藏名單之主題。", "collections.topic_hint": "新增主題標籤以協助其他人瞭解此收藏名單之主題。",
"collections.view_collection": "檢視收藏名單", "collections.view_collection": "檢視收藏名單",
"collections.view_other_collections_by_user": "檢視此使用者之其他收藏名單",
"collections.visibility_public": "公開", "collections.visibility_public": "公開",
"collections.visibility_public_hint": "可於搜尋結果與其他推薦處可見。", "collections.visibility_public_hint": "可於搜尋結果與其他推薦處可見。",
"collections.visibility_title": "可見性", "collections.visibility_title": "可見性",
@@ -767,7 +769,7 @@
"navigation_bar.mutes": "已靜音的使用者", "navigation_bar.mutes": "已靜音的使用者",
"navigation_bar.opened_in_classic_interface": "預設於經典網頁介面中開啟嘟文、帳號與其他特定頁面。", "navigation_bar.opened_in_classic_interface": "預設於經典網頁介面中開啟嘟文、帳號與其他特定頁面。",
"navigation_bar.preferences": "偏好設定", "navigation_bar.preferences": "偏好設定",
"navigation_bar.privacy_and_reach": "隱私權觸及", "navigation_bar.privacy_and_reach": "隱私權觸及",
"navigation_bar.search": "搜尋", "navigation_bar.search": "搜尋",
"navigation_bar.search_trends": "搜尋 / 熱門趨勢", "navigation_bar.search_trends": "搜尋 / 熱門趨勢",
"navigation_panel.collapse_followed_tags": "收合已跟隨主題標籤選單", "navigation_panel.collapse_followed_tags": "收合已跟隨主題標籤選單",
@@ -976,6 +978,7 @@
"report.category.title_account": "個人檔案", "report.category.title_account": "個人檔案",
"report.category.title_status": "嘟文", "report.category.title_status": "嘟文",
"report.close": "已完成", "report.close": "已完成",
"report.collection_comment": "您檢舉此收藏名單的原因是?",
"report.comment.title": "有什麼其他您想讓我們知道的嗎?", "report.comment.title": "有什麼其他您想讓我們知道的嗎?",
"report.forward": "轉寄到 {target}", "report.forward": "轉寄到 {target}",
"report.forward_hint": "這個帳號屬於其他伺服器。要向該伺服器發送匿名的檢舉訊息嗎?", "report.forward_hint": "這個帳號屬於其他伺服器。要向該伺服器發送匿名的檢舉訊息嗎?",
@@ -997,6 +1000,8 @@
"report.rules.title": "違反了哪些規則?", "report.rules.title": "違反了哪些規則?",
"report.statuses.subtitle": "請選擇所有適用的選項", "report.statuses.subtitle": "請選擇所有適用的選項",
"report.statuses.title": "是否有能佐證這份檢舉之嘟文?", "report.statuses.title": "是否有能佐證這份檢舉之嘟文?",
"report.submission_error": "無法送出檢舉",
"report.submission_error_details": "請檢查您的網路連線並稍候重試。",
"report.submit": "送出", "report.submit": "送出",
"report.target": "檢舉 {target}", "report.target": "檢舉 {target}",
"report.thanks.take_action": "以下是控制您想於 Mastodon 看到什麼內容之選項:", "report.thanks.take_action": "以下是控制您想於 Mastodon 看到什麼內容之選項:",

View File

@@ -229,14 +229,16 @@ interface AccountCollectionQuery {
collections: ApiCollectionJSON[]; collections: ApiCollectionJSON[];
} }
export const selectMyCollections = createAppSelector( export const selectAccountCollections = createAppSelector(
[ [
(state) => state.meta.get('me') as string, (_, accountId: string | null) => accountId,
(state) => state.collections.accountCollections, (state) => state.collections.accountCollections,
(state) => state.collections.collections, (state) => state.collections.collections,
], ],
(me, collectionsByAccountId, collectionsMap) => { (accountId, collectionsByAccountId, collectionsMap) => {
const myCollectionsQuery = collectionsByAccountId[me]; const myCollectionsQuery = accountId
? collectionsByAccountId[accountId]
: null;
if (!myCollectionsQuery) { if (!myCollectionsQuery) {
return { return {

View File

@@ -1,11 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class Admin::AccountAction class Admin::AccountAction < Admin::BaseAction
include ActiveModel::Model
include ActiveModel::Attributes
include AccountableConcern
include Authorization
TYPES = %w( TYPES = %w(
none none
disable disable
@@ -15,49 +10,13 @@ class Admin::AccountAction
).freeze ).freeze
attr_accessor :target_account, attr_accessor :target_account,
:current_account,
:type,
:text,
:report_id,
:warning_preset_id :warning_preset_id
attr_reader :warning
attribute :include_statuses, :boolean, default: true attribute :include_statuses, :boolean, default: true
attribute :send_email_notification, :boolean, default: true
alias send_email_notification? send_email_notification
alias include_statuses? include_statuses alias include_statuses? include_statuses
validates :type, :target_account, :current_account, presence: true validates :target_account, presence: true
validates :type, inclusion: { in: TYPES }
def save
return false unless valid?
ApplicationRecord.transaction do
process_action!
process_strike!
process_reports!
end
process_notification!
process_queue!
true
end
def save!
raise ActiveRecord::RecordInvalid, self unless save
end
def report
@report ||= Report.find(report_id) if report_id.present?
end
def with_report?
!report.nil?
end
class << self class << self
def types_for_account(account) def types_for_account(account)
@@ -84,6 +43,17 @@ class Admin::AccountAction
private private
def process_action! def process_action!
ApplicationRecord.transaction do
handle_type!
process_strike!
process_reports!
end
process_notification!
process_queue!
end
def handle_type!
case type case type
when 'disable' when 'disable'
handle_disable! handle_disable!
@@ -96,20 +66,6 @@ class Admin::AccountAction
end end
end end
def process_strike!
@warning = target_account.strikes.create!(
account: current_account,
report: report,
action: type,
text: text_for_warning,
status_ids: status_ids
)
# A log entry is only interesting if the warning contains
# custom text from someone. Otherwise it's just noise.
log_action(:create, @warning) if @warning.text.present? && type == 'none'
end
def process_reports! def process_reports!
# If we're doing "mark as resolved" on a single report, # If we're doing "mark as resolved" on a single report,
# then we want to keep other reports open in case they # then we want to keep other reports open in case they
@@ -161,17 +117,6 @@ class Admin::AccountAction
queue_suspension_worker! if type == 'suspend' queue_suspension_worker! if type == 'suspend'
end end
def process_notification!
return unless warnable?
UserMailer.warning(target_account.user, warning).deliver_later!
LocalNotificationWorker.perform_async(target_account.id, warning.id, 'AccountWarning', 'moderation_warning')
end
def warnable?
send_email_notification? && target_account.local?
end
def status_ids def status_ids
report.status_ids if with_report? && include_statuses? report.status_ids if with_report? && include_statuses?
end end

View File

@@ -0,0 +1,65 @@
# frozen_string_literal: true
class Admin::BaseAction
include ActiveModel::Model
include ActiveModel::Attributes
include AccountableConcern
include Authorization
attr_accessor :current_account,
:type,
:text,
:report_id
attr_reader :warning
attribute :send_email_notification, :boolean, default: true
alias send_email_notification? send_email_notification
validates :type, :current_account, presence: true
validates :type, inclusion: { in: ->(a) { a.class::TYPES } }
def save
return false unless valid?
process_action!
true
end
def save!
raise ActiveRecord::RecordInvalid, self unless save
end
def report
@report ||= Report.find(report_id) if report_id.present?
end
def with_report?
!report.nil?
end
private
def process_strike!(action = type)
@warning = target_account.strikes.create!(
account: current_account,
report: report,
action:,
text: text_for_warning,
status_ids: status_ids
)
end
def process_notification!
return unless warnable?
UserMailer.warning(target_account.user, warning).deliver_later!
LocalNotificationWorker.perform_async(target_account.id, warning.id, 'AccountWarning', 'moderation_warning')
end
def warnable?
send_email_notification? && target_account.local?
end
end

View File

@@ -0,0 +1,120 @@
# frozen_string_literal: true
class Admin::ModerationAction < Admin::BaseAction
TYPES = %w(
delete
mark_as_sensitive
).freeze
validates :report_id, presence: true
private
def status_ids
report.status_ids
end
def statuses
@statuses ||= Status.with_discarded.where(id: status_ids).reorder(nil)
end
def collections
report.collections
end
def process_action!
case type
when 'delete'
handle_delete!
when 'mark_as_sensitive'
handle_mark_as_sensitive!
end
end
def handle_delete!
statuses.each { |status| authorize([:admin, status], :destroy?) }
collections.each { |collection| authorize([:admin, collection], :destroy?) }
ApplicationRecord.transaction do
delete_statuses!
delete_collections!
resolve_report!
process_strike!(:delete_statuses)
create_tombstones! unless target_account.local?
end
process_notification!
RemovalWorker.push_bulk(status_ids) { |status_id| [status_id, { 'preserve' => target_account.local?, 'immediate' => !target_account.local? }] }
end
def handle_mark_as_sensitive!
collections.each { |collection| authorize([:admin, collection], :update?) }
# Can't use a transaction here because UpdateStatusService queues
# Sidekiq jobs
mark_statuses_as_sensitive!
mark_collections_as_sensitive!
resolve_report!
process_strike!(:mark_statuses_as_sensitive)
process_notification!
end
def delete_statuses!
statuses.each do |status|
status.discard_with_reblogs
log_action(:destroy, status)
end
end
def delete_collections!
collections.each do |collection|
collection.destroy!
log_action(:destroy, collection)
end
end
def create_tombstones!
(statuses + collections).each { |record| Tombstone.find_or_create_by(uri: record.uri, account: target_account, by_moderator: true) }
end
def mark_statuses_as_sensitive!
representative_account = Account.representative
statuses.includes(:media_attachments, preview_cards_status: :preview_card).find_each do |status|
next if status.discarded? || !(status.with_media? || status.with_preview_card?)
authorize([:admin, status], :update?)
if target_account.local?
UpdateStatusService.new.call(status, representative_account.id, sensitive: true)
else
status.update(sensitive: true)
end
log_action(:update, status)
end
end
def mark_collections_as_sensitive!
collections.each do |collection|
UpdateCollectionService.new.call(collection, sensitive: true)
log_action(:update, collection)
end
end
def resolve_report!
report.resolve!(current_account)
log_action(:resolve, report)
end
def target_account
report.target_account
end
def text_for_warning = text
end

View File

@@ -1,20 +1,12 @@
# frozen_string_literal: true # frozen_string_literal: true
class Admin::StatusBatchAction class Admin::StatusBatchAction < Admin::BaseAction
include ActiveModel::Model TYPES = %w(
include ActiveModel::Attributes report
include AccountableConcern remove_from_report
include Authorization ).freeze
attr_accessor :current_account, :type, attr_accessor :status_ids
:status_ids, :report_id,
:text
attribute :send_email_notification, :boolean
def save!
process_action!
end
private private
@@ -26,10 +18,6 @@ class Admin::StatusBatchAction
return if status_ids.empty? return if status_ids.empty?
case type case type
when 'delete'
handle_delete!
when 'mark_as_sensitive'
handle_mark_as_sensitive!
when 'report' when 'report'
handle_report! handle_report!
when 'remove_from_report' when 'remove_from_report'
@@ -37,71 +25,6 @@ class Admin::StatusBatchAction
end end
end end
def handle_delete!
statuses.each { |status| authorize([:admin, status], :destroy?) }
ApplicationRecord.transaction do
statuses.each do |status|
status.discard_with_reblogs
log_action(:destroy, status)
end
if with_report?
report.resolve!(current_account)
log_action(:resolve, report)
end
@warning = target_account.strikes.create!(
action: :delete_statuses,
account: current_account,
report: report,
status_ids: status_ids,
text: text
)
statuses.each { |status| Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true) } unless target_account.local?
end
process_notification!
RemovalWorker.push_bulk(status_ids) { |status_id| [status_id, { 'preserve' => target_account.local?, 'immediate' => !target_account.local? }] }
end
def handle_mark_as_sensitive!
representative_account = Account.representative
# Can't use a transaction here because UpdateStatusService queues
# Sidekiq jobs
statuses.includes(:media_attachments, preview_cards_status: :preview_card).find_each do |status|
next if status.discarded? || !(status.with_media? || status.with_preview_card?)
authorize([:admin, status], :update?)
if target_account.local?
UpdateStatusService.new.call(status, representative_account.id, sensitive: true)
else
status.update(sensitive: true)
end
log_action(:update, status)
if with_report?
report.resolve!(current_account)
log_action(:resolve, report)
end
end
@warning = target_account.strikes.create!(
action: :mark_statuses_as_sensitive,
account: current_account,
report: report,
status_ids: status_ids,
text: text
)
process_notification!
end
def handle_report! def handle_report!
@report = Report.new(report_params) unless with_report? @report = Report.new(report_params) unless with_report?
@report.status_ids = (@report.status_ids + allowed_status_ids).uniq @report.status_ids = (@report.status_ids + allowed_status_ids).uniq
@@ -117,25 +40,6 @@ class Admin::StatusBatchAction
report.save! report.save!
end end
def report
@report ||= Report.find(report_id) if report_id.present?
end
def with_report?
!report.nil?
end
def process_notification!
return unless warnable?
UserMailer.warning(target_account.user, @warning).deliver_later!
LocalNotificationWorker.perform_async(target_account.id, @warning.id, 'AccountWarning', 'moderation_warning')
end
def warnable?
send_email_notification && target_account.local?
end
def target_account def target_account
@target_account ||= statuses.first.account @target_account ||= statuses.first.account
end end

View File

@@ -69,6 +69,14 @@ class Collection < ApplicationRecord
:featured_collection :featured_collection
end end
def to_log_human_identifier
account.acct
end
def to_log_permalink
ActivityPub::TagManager.instance.uri_for(self)
end
private private
def tag_is_usable def tag_is_usable

View File

@@ -0,0 +1,19 @@
# frozen_string_literal: true
class Admin::CollectionPolicy < ApplicationPolicy
def index?
role.can?(:manage_reports, :manage_users)
end
def show?
role.can?(:manage_reports, :manage_users)
end
def destroy?
role.can?(:manage_reports)
end
def update?
role.can?(:manage_reports)
end
end

View File

@@ -5,7 +5,7 @@
= link_to t('admin.reports.mark_as_resolved'), resolve_admin_report_path(report), method: :post, class: 'button' = link_to t('admin.reports.mark_as_resolved'), resolve_admin_report_path(report), method: :post, class: 'button'
.report-actions__item__description .report-actions__item__description
= t('admin.reports.actions.resolve_description_html') = t('admin.reports.actions.resolve_description_html')
- if statuses.any? { |status| (status.with_media? || status.with_preview_card?) && !status.discarded? } - if report.collections.any? || statuses.any? { |status| (status.with_media? || status.with_preview_card?) && !status.discarded? }
.report-actions__item .report-actions__item
.report-actions__item__button .report-actions__item__button
= form.button t('admin.reports.mark_as_sensitive'), = form.button t('admin.reports.mark_as_sensitive'),
@@ -18,8 +18,8 @@
= form.button t('admin.reports.delete_and_resolve'), = form.button t('admin.reports.delete_and_resolve'),
name: :delete, name: :delete,
class: 'button button--destructive', class: 'button button--destructive',
disabled: statuses.empty?, disabled: (report.collections + statuses).empty?,
title: statuses.empty? ? t('admin.reports.actions_no_posts') : '' title: (report.collections + statuses).empty? ? t('admin.reports.actions_no_posts') : ''
.report-actions__item__description .report-actions__item__description
= t('admin.reports.actions.delete_description_html') = t('admin.reports.actions.delete_description_html')
.report-actions__item .report-actions__item

View File

@@ -1952,7 +1952,7 @@ da:
content_warnings: content_warnings:
hide: Skjul indlæg hide: Skjul indlæg
show: Vis mere show: Vis mere
default_language: Samme som UI-sproget default_language: Samme som grænsefladesproget
disallowed_hashtags: disallowed_hashtags:
one: 'indeholdte et ikke tilladt hashtag: %{tags}' one: 'indeholdte et ikke tilladt hashtag: %{tags}'
other: 'indeholdte de ikke tilladte etiketter: %{tags}' other: 'indeholdte de ikke tilladte etiketter: %{tags}'

View File

@@ -83,6 +83,10 @@ de:
access_denied: Diese Anfrage wurde von den Inhaber*innen oder durch den Autorisierungsserver abgelehnt. access_denied: Diese Anfrage wurde von den Inhaber*innen oder durch den Autorisierungsserver abgelehnt.
credential_flow_not_configured: Das Konto konnte nicht gefunden werden, da Doorkeeper.configure.resource_owner_from_credentials nicht konfiguriert ist. credential_flow_not_configured: Das Konto konnte nicht gefunden werden, da Doorkeeper.configure.resource_owner_from_credentials nicht konfiguriert ist.
invalid_client: 'Client-Authentisierung ist fehlgeschlagen: Client unbekannt, keine Authentisierung mitgeliefert oder Authentisierungsmethode wird nicht unterstützt.' invalid_client: 'Client-Authentisierung ist fehlgeschlagen: Client unbekannt, keine Authentisierung mitgeliefert oder Authentisierungsmethode wird nicht unterstützt.'
invalid_code_challenge_method:
one: Die code_challenge_method muss %{challenge_methods} sein.
other: Die code_challenge_method muss eine von %{challenge_methods} sein.
zero: Der Berechtigungsserver unterstützt PKCE nicht, da keine akzeptierten code_challenge_method Werte vorhanden sind.
invalid_grant: Die beigefügte Autorisierung ist ungültig, abgelaufen, wurde widerrufen oder einem anderen Client ausgestellt, oder der Weiterleitungs-URI stimmt nicht mit der Autorisierungs-Anfrage überein. invalid_grant: Die beigefügte Autorisierung ist ungültig, abgelaufen, wurde widerrufen oder einem anderen Client ausgestellt, oder der Weiterleitungs-URI stimmt nicht mit der Autorisierungs-Anfrage überein.
invalid_redirect_uri: Der beigefügte Weiterleitungs-URI ist ungültig. invalid_redirect_uri: Der beigefügte Weiterleitungs-URI ist ungültig.
invalid_request: invalid_request:

View File

@@ -267,6 +267,7 @@ en:
demote_user_html: "%{name} demoted user %{target}" demote_user_html: "%{name} demoted user %{target}"
destroy_announcement_html: "%{name} deleted announcement %{target}" destroy_announcement_html: "%{name} deleted announcement %{target}"
destroy_canonical_email_block_html: "%{name} unblocked email with the hash %{target}" destroy_canonical_email_block_html: "%{name} unblocked email with the hash %{target}"
destroy_collection_html: "%{name} removed collection by %{target}"
destroy_custom_emoji_html: "%{name} deleted emoji %{target}" destroy_custom_emoji_html: "%{name} deleted emoji %{target}"
destroy_domain_allow_html: "%{name} disallowed federation with domain %{target}" destroy_domain_allow_html: "%{name} disallowed federation with domain %{target}"
destroy_domain_block_html: "%{name} unblocked domain %{target}" destroy_domain_block_html: "%{name} unblocked domain %{target}"
@@ -306,6 +307,7 @@ en:
unsilence_account_html: "%{name} undid limit of %{target}'s account" unsilence_account_html: "%{name} undid limit of %{target}'s account"
unsuspend_account_html: "%{name} unsuspended %{target}'s account" unsuspend_account_html: "%{name} unsuspended %{target}'s account"
update_announcement_html: "%{name} updated announcement %{target}" update_announcement_html: "%{name} updated announcement %{target}"
update_collection_html: "%{name} updated collection by %{target}"
update_custom_emoji_html: "%{name} updated emoji %{target}" update_custom_emoji_html: "%{name} updated emoji %{target}"
update_domain_block_html: "%{name} updated domain block for %{target}" update_domain_block_html: "%{name} updated domain block for %{target}"
update_ip_block_html: "%{name} changed rule for IP %{target}" update_ip_block_html: "%{name} changed rule for IP %{target}"

View File

@@ -692,6 +692,7 @@ fr-CA:
cancel: Annuler cancel: Annuler
category: Catégorie category: Catégorie
category_description_html: La raison pour laquelle ce compte et/ou ce contenu a été signalé sera citée dans la communication avec le compte signalé category_description_html: La raison pour laquelle ce compte et/ou ce contenu a été signalé sera citée dans la communication avec le compte signalé
collections: Collections (%{count})
comment: comment:
none: Aucun none: Aucun
comment_description_html: 'Pour fournir plus d''informations, %{name} a écrit :' comment_description_html: 'Pour fournir plus d''informations, %{name} a écrit :'
@@ -727,6 +728,7 @@ fr-CA:
resolved_msg: Signalement résolu avec succès! resolved_msg: Signalement résolu avec succès!
skip_to_actions: Passer aux actions skip_to_actions: Passer aux actions
status: Statut status: Statut
statuses: Messages (%{count})
statuses_description_html: Le contenu offensant sera cité dans la communication avec le compte signalé statuses_description_html: Le contenu offensant sera cité dans la communication avec le compte signalé
summary: summary:
action_preambles: action_preambles:

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