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

Merge upstream changes up to 3d8d5f6dc7625d9638cc2e3387247442225d4e3f
This commit is contained in:
Claire 2026-03-06 14:27:16 +01:00 committed by GitHub
commit 7e7a8cea52
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
121 changed files with 2578 additions and 1056 deletions

View File

@ -129,9 +129,6 @@ group :test do
# Adds RSpec Error/Warning annotations to GitHub PRs on the Files tab # Adds RSpec Error/Warning annotations to GitHub PRs on the Files tab
gem 'rspec-github', '~> 3.0', require: false gem 'rspec-github', '~> 3.0', require: false
# RSpec helpers for email specs
gem 'email_spec'
# Extra RSpec extension methods and helpers for sidekiq # Extra RSpec extension methods and helpers for sidekiq
gem 'rspec-sidekiq', '~> 5.0' gem 'rspec-sidekiq', '~> 5.0'

View File

@ -224,10 +224,6 @@ GEM
base64 base64
faraday (>= 1, < 3) faraday (>= 1, < 3)
multi_json multi_json
email_spec (2.3.0)
htmlentities (~> 4.3.3)
launchy (>= 2.1, < 4.0)
mail (~> 2.7)
email_validator (2.2.4) email_validator (2.2.4)
activemodel activemodel
erb (6.0.2) erb (6.0.2)
@ -311,7 +307,7 @@ GEM
hiredis-client (0.26.4) hiredis-client (0.26.4)
redis-client (= 0.26.4) redis-client (= 0.26.4)
hkdf (0.3.0) hkdf (0.3.0)
htmlentities (4.3.4) htmlentities (4.4.2)
http (5.3.1) http (5.3.1)
addressable (~> 2.8) addressable (~> 2.8)
http-cookie (~> 1.0) http-cookie (~> 1.0)
@ -414,12 +410,12 @@ GEM
rexml rexml
link_header (0.0.8) link_header (0.0.8)
lint_roller (1.1.0) lint_roller (1.1.0)
linzer (0.7.7) linzer (0.7.8)
cgi (~> 0.4.2) cgi (>= 0.4.2, < 0.6.0)
forwardable (~> 1.3, >= 1.3.3) forwardable (~> 1.3, >= 1.3.3)
logger (~> 1.7, >= 1.7.0) logger (~> 1.7, >= 1.7.0)
net-http (~> 0.6.0) net-http (>= 0.6, < 0.10)
openssl (~> 3.0, >= 3.0.0) openssl (>= 3, < 5)
rack (>= 2.2, < 4.0) rack (>= 2.2, < 4.0)
starry (~> 0.2) starry (~> 0.2)
stringio (~> 3.1, >= 3.1.2) stringio (~> 3.1, >= 3.1.2)
@ -977,7 +973,6 @@ DEPENDENCIES
discard (~> 1.2) discard (~> 1.2)
doorkeeper (~> 5.6) doorkeeper (~> 5.6)
dotenv dotenv
email_spec
fabrication fabrication
faker (~> 3.2) faker (~> 3.2)
faraday-httpclient faraday-httpclient

View File

@ -18,6 +18,8 @@ class AccountsController < ApplicationController
respond_to do |format| respond_to do |format|
format.html do format.html do
expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.hour) unless user_signed_in? expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.hour) unless user_signed_in?
redirect_to short_account_path(@account) if account_id_param.present? && username_param.blank?
end end
format.rss do format.rss do

View File

@ -26,6 +26,8 @@ class StatusesController < ApplicationController
respond_to do |format| respond_to do |format|
format.html do format.html do
expires_in 10.seconds, public: true if current_account.nil? expires_in 10.seconds, public: true if current_account.nil?
redirect_to short_account_status_path(@account, @status) if account_id_param.present? && username_param.blank?
end end
format.json do format.json do

View File

@ -74,6 +74,7 @@ interface AccountProps {
defaultAction?: 'block' | 'mute'; defaultAction?: 'block' | 'mute';
withBio?: boolean; withBio?: boolean;
withMenu?: boolean; withMenu?: boolean;
withBorder?: boolean;
extraAccountInfo?: React.ReactNode; extraAccountInfo?: React.ReactNode;
children?: React.ReactNode; children?: React.ReactNode;
} }
@ -86,6 +87,7 @@ export const Account: React.FC<AccountProps> = ({
defaultAction, defaultAction,
withBio, withBio,
withMenu = true, withMenu = true,
withBorder = true,
extraAccountInfo, extraAccountInfo,
children, children,
}) => { }) => {
@ -291,6 +293,7 @@ export const Account: React.FC<AccountProps> = ({
<div <div
className={classNames('account', { className={classNames('account', {
'account--minimal': minimal, 'account--minimal': minimal,
'account--without-border': !withBorder,
})} })}
> >
<div <div

View File

@ -138,6 +138,8 @@ export const FollowButton: React.FC<{
: messages.follow; : messages.follow;
let label; let label;
let disabled =
relationship?.blocked_by || account?.suspended || !!account?.moved;
if (!signedIn) { if (!signedIn) {
label = intl.formatMessage(followMessage); label = intl.formatMessage(followMessage);
@ -147,12 +149,16 @@ export const FollowButton: React.FC<{
label = <LoadingIndicator />; label = <LoadingIndicator />;
} else if (relationship.muting && withUnmute) { } else if (relationship.muting && withUnmute) {
label = intl.formatMessage(messages.unmute); label = intl.formatMessage(messages.unmute);
disabled = false;
} else if (relationship.following) { } else if (relationship.following) {
label = intl.formatMessage(messages.unfollow); label = intl.formatMessage(messages.unfollow);
disabled = false;
} else if (relationship.blocking) { } else if (relationship.blocking) {
label = intl.formatMessage(messages.unblock); label = intl.formatMessage(messages.unblock);
disabled = false;
} else if (relationship.requested) { } else if (relationship.requested) {
label = intl.formatMessage(messages.followRequestCancel); label = intl.formatMessage(messages.followRequestCancel);
disabled = false;
} else if (relationship.followed_by && !account?.locked) { } else if (relationship.followed_by && !account?.locked) {
label = intl.formatMessage(messages.followBack); label = intl.formatMessage(messages.followBack);
} else { } else {
@ -187,11 +193,7 @@ export const FollowButton: React.FC<{
return ( return (
<Button <Button
onClick={handleClick} onClick={handleClick}
disabled={ disabled={disabled}
relationship?.blocked_by ||
(!(relationship?.following || relationship?.requested) &&
(account?.suspended || !!account?.moved))
}
secondary={following || relationship?.blocking} secondary={following || relationship?.blocking}
compact={compact} compact={compact}
className={classNames(className, { 'button--destructive': following })} className={classNames(className, { 'button--destructive': following })}

View File

@ -10,6 +10,7 @@ export {
type ComboboxItemState, type ComboboxItemState,
} from './combobox_field'; } from './combobox_field';
export { CopyLinkField } from './copy_link_field'; export { CopyLinkField } from './copy_link_field';
export { EmojiTextInputField, EmojiTextAreaField } from './emoji_text_field';
export { RadioButtonField, RadioButton } from './radio_button_field'; export { RadioButtonField, RadioButton } from './radio_button_field';
export { ToggleField, Toggle } from './toggle_field'; export { ToggleField, Toggle } from './toggle_field';
export { SelectField, Select } from './select_field'; export { SelectField, Select } from './select_field';

View File

@ -36,7 +36,7 @@ export const ItemList = forwardRef<
emptyMessage?: React.ReactNode; emptyMessage?: React.ReactNode;
} }
>(({ isLoading, emptyMessage, className, children, ...otherProps }, ref) => { >(({ isLoading, emptyMessage, className, children, ...otherProps }, ref) => {
if (Children.count(children) === 0 && emptyMessage) { if (!isLoading && Children.count(children) === 0 && emptyMessage) {
return <div className='empty-column-indicator'>{emptyMessage}</div>; return <div className='empty-column-indicator'>{emptyMessage}</div>;
} }

View File

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

View File

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

View File

@ -0,0 +1,37 @@
import type { FC } from 'react';
import { useCallback } from 'react';
import { openModal } from '@/flavours/glitch/actions/modal';
import { useAppDispatch } from '@/flavours/glitch/store';
import { EditButton, DeleteIconButton } from './edit_button';
export const AccountFieldActions: FC<{ item: string; id: string }> = ({
item,
id,
}) => {
const dispatch = useAppDispatch();
const handleEdit = useCallback(() => {
dispatch(
openModal({
modalType: 'ACCOUNT_EDIT_FIELD_EDIT',
modalProps: { fieldKey: id },
}),
);
}, [dispatch, id]);
const handleDelete = useCallback(() => {
dispatch(
openModal({
modalType: 'ACCOUNT_EDIT_FIELD_DELETE',
modalProps: { fieldKey: id },
}),
);
}, [dispatch, id]);
return (
<>
<EditButton item={item} edit onClick={handleEdit} />
<DeleteIconButton item={item} onClick={handleDelete} />
</>
);
};

View File

@ -9,6 +9,7 @@ import type { ModalType } from '@/flavours/glitch/actions/modal';
import { openModal } from '@/flavours/glitch/actions/modal'; import { openModal } from '@/flavours/glitch/actions/modal';
import { Avatar } from '@/flavours/glitch/components/avatar'; import { Avatar } from '@/flavours/glitch/components/avatar';
import { Button } from '@/flavours/glitch/components/button'; import { Button } from '@/flavours/glitch/components/button';
import { DismissibleCallout } from '@/flavours/glitch/components/callout/dismissible';
import { CustomEmojiProvider } from '@/flavours/glitch/components/emoji/context'; import { CustomEmojiProvider } from '@/flavours/glitch/components/emoji/context';
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html'; import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
import { useElementHandledLink } from '@/flavours/glitch/components/status/handled_link'; import { useElementHandledLink } from '@/flavours/glitch/components/status/handled_link';
@ -20,6 +21,7 @@ import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
import { AccountEditColumn, AccountEditEmptyColumn } from './components/column'; import { AccountEditColumn, AccountEditEmptyColumn } from './components/column';
import { EditButton } from './components/edit_button'; import { EditButton } from './components/edit_button';
import { AccountFieldActions } from './components/field_actions';
import { AccountEditSection } from './components/section'; import { AccountEditSection } from './components/section';
import classes from './styles.module.scss'; import classes from './styles.module.scss';
@ -54,6 +56,14 @@ export const messages = defineMessages({
defaultMessage: defaultMessage:
'Add your pronouns, external links, or anything else youd like to share.', 'Add your pronouns, external links, or anything else youd like to share.',
}, },
customFieldsName: {
id: 'account_edit.custom_fields.name',
defaultMessage: 'field',
},
customFieldsTipTitle: {
id: 'account_edit.custom_fields.tip_title',
defaultMessage: 'Tip: Adding verified links',
},
featuredHashtagsTitle: { featuredHashtagsTitle: {
id: 'account_edit.featured_hashtags.title', id: 'account_edit.featured_hashtags.title',
defaultMessage: 'Featured hashtags', defaultMessage: 'Featured hashtags',
@ -101,6 +111,9 @@ export const AccountEdit: FC = () => {
const handleBioEdit = useCallback(() => { const handleBioEdit = useCallback(() => {
handleOpenModal('ACCOUNT_EDIT_BIO'); handleOpenModal('ACCOUNT_EDIT_BIO');
}, [handleOpenModal]); }, [handleOpenModal]);
const handleCustomFieldsVerifiedHelp = useCallback(() => {
handleOpenModal('ACCOUNT_EDIT_VERIFY_LINKS');
}, [handleOpenModal]);
const handleProfileDisplayEdit = useCallback(() => { const handleProfileDisplayEdit = useCallback(() => {
handleOpenModal('ACCOUNT_EDIT_PROFILE_DISPLAY'); handleOpenModal('ACCOUNT_EDIT_PROFILE_DISPLAY');
}, [handleOpenModal]); }, [handleOpenModal]);
@ -123,6 +136,7 @@ export const AccountEdit: FC = () => {
const headerSrc = autoPlayGif ? profile.header : profile.headerStatic; const headerSrc = autoPlayGif ? profile.header : profile.headerStatic;
const hasName = !!profile.displayName; const hasName = !!profile.displayName;
const hasBio = !!profile.bio; const hasBio = !!profile.bio;
const hasFields = profile.fields.length > 0;
const hasTags = profile.featuredTags.length > 0; const hasTags = profile.featuredTags.length > 0;
return ( return (
@ -171,8 +185,48 @@ export const AccountEdit: FC = () => {
<AccountEditSection <AccountEditSection
title={messages.customFieldsTitle} title={messages.customFieldsTitle}
description={messages.customFieldsPlaceholder} description={messages.customFieldsPlaceholder}
showDescription showDescription={!hasFields}
/> >
<ol>
{profile.fields.map((field) => (
<li key={field.id} className={classes.field}>
<div>
<EmojiHTML
htmlString={field.name}
className={classes.fieldName}
{...htmlHandlers}
/>
<EmojiHTML htmlString={field.value} {...htmlHandlers} />
</div>
<AccountFieldActions
item={intl.formatMessage(messages.customFieldsName)}
id={field.id}
/>
</li>
))}
</ol>
<Button
onClick={handleCustomFieldsVerifiedHelp}
className={classes.verifiedLinkHelpButton}
plain
>
<FormattedMessage
id='account_edit.custom_fields.verified_hint'
defaultMessage='How do I add a verified link?'
/>
</Button>
{!hasFields && (
<DismissibleCallout
id='profile_edit_fields_tip'
title={intl.formatMessage(messages.customFieldsTipTitle)}
>
<FormattedMessage
id='account_edit.custom_fields.tip_content'
defaultMessage='You can easily add credibility to your Mastodon account by verifying links to any websites you own.'
/>
</DismissibleCallout>
)}
</AccountEditSection>
<AccountEditSection <AccountEditSection
title={messages.featuredHashtagsTitle} title={messages.featuredHashtagsTitle}

View File

@ -1,20 +1,14 @@
import { useCallback, useId, useRef, useState } from 'react'; import { useCallback, useId, useState } from 'react';
import type { ChangeEventHandler, FC } from 'react'; import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { TextArea } from '@/flavours/glitch/components/form_fields'; import { EmojiTextAreaField } from '@/flavours/glitch/components/form_fields';
import { insertEmojiAtPosition } from '@/flavours/glitch/features/emoji/utils';
import type { BaseConfirmationModalProps } from '@/flavours/glitch/features/ui/components/confirmation_modals'; import type { BaseConfirmationModalProps } from '@/flavours/glitch/features/ui/components/confirmation_modals';
import { ConfirmationModal } from '@/flavours/glitch/features/ui/components/confirmation_modals'; import { ConfirmationModal } from '@/flavours/glitch/features/ui/components/confirmation_modals';
import { patchProfile } from '@/flavours/glitch/reducers/slices/profile_edit'; import { patchProfile } from '@/flavours/glitch/reducers/slices/profile_edit';
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store'; import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
import classes from '../styles.module.scss';
import { CharCounter } from './char_counter';
import { EmojiPicker } from './emoji_picker';
const messages = defineMessages({ const messages = defineMessages({
addTitle: { addTitle: {
id: 'account_edit.bio_modal.add_title', id: 'account_edit.bio_modal.add_title',
@ -30,30 +24,23 @@ const messages = defineMessages({
}, },
}); });
const MAX_BIO_LENGTH = 500;
export const BioModal: FC<BaseConfirmationModalProps> = ({ onClose }) => { export const BioModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
const intl = useIntl(); const intl = useIntl();
const titleId = useId(); const titleId = useId();
const counterId = useId();
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const { profile: { bio } = {}, isPending } = useAppSelector( const { profile: { bio } = {}, isPending } = useAppSelector(
(state) => state.profileEdit, (state) => state.profileEdit,
); );
const [newBio, setNewBio] = useState(bio ?? ''); const [newBio, setNewBio] = useState(bio ?? '');
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback( const maxLength = useAppSelector(
(event) => { (state) =>
setNewBio(event.currentTarget.value); state.server.getIn([
}, 'server',
[], 'configuration',
'accounts',
'max_note_length',
]) as number | undefined,
); );
const handlePickEmoji = useCallback((emoji: string) => {
setNewBio((prev) => {
const position = textAreaRef.current?.selectionStart ?? prev.length;
return insertEmojiAtPosition(prev, emoji, position);
});
}, []);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
@ -70,27 +57,18 @@ export const BioModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
onConfirm={handleSave} onConfirm={handleSave}
onClose={onClose} onClose={onClose}
updating={isPending} updating={isPending}
disabled={newBio.length > MAX_BIO_LENGTH} disabled={!!maxLength && newBio.length > maxLength}
noFocusButton noFocusButton
> >
<div className={classes.inputWrapper}> <EmojiTextAreaField
<TextArea label=''
value={newBio} value={newBio}
ref={textAreaRef} onChange={setNewBio}
onChange={handleChange} aria-labelledby={titleId}
className={classes.inputText} maxLength={maxLength}
aria-labelledby={titleId} // eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
aria-describedby={counterId} autoFocus
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine. autoSize
autoFocus
autoSize
/>
<EmojiPicker onPick={handlePickEmoji} />
</div>
<CharCounter
currentLength={newBio.length}
maxLength={MAX_BIO_LENGTH}
id={counterId}
/> />
</ConfirmationModal> </ConfirmationModal>
); );

View File

@ -0,0 +1,175 @@
import { useCallback, useState } from 'react';
import type { FC } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import type { Map as ImmutableMap } from 'immutable';
import { Button } from '@/flavours/glitch/components/button';
import { EmojiTextInputField } from '@/flavours/glitch/components/form_fields';
import {
removeField,
selectFieldById,
updateField,
} from '@/flavours/glitch/reducers/slices/profile_edit';
import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from '@/flavours/glitch/store';
import { ConfirmationModal } from '../../ui/components/confirmation_modals';
import type { DialogModalProps } from '../../ui/components/dialog_modal';
import { DialogModal } from '../../ui/components/dialog_modal';
import classes from './styles.module.scss';
const messages = defineMessages({
editTitle: {
id: 'account_edit.field_edit_modal.edit_title',
defaultMessage: 'Edit custom field',
},
addTitle: {
id: 'account_edit.field_edit_modal.add_title',
defaultMessage: 'Add custom field',
},
editLabelField: {
id: 'account_edit.field_edit_modal.name_label',
defaultMessage: 'Label',
},
editLabelHint: {
id: 'account_edit.field_edit_modal.name_hint',
defaultMessage: 'E.g. “Personal website”',
},
editValueField: {
id: 'account_edit.field_edit_modal.value_label',
defaultMessage: 'Value',
},
editValueHint: {
id: 'account_edit.field_edit_modal.value_hint',
defaultMessage: 'E.g. “example.me”',
},
save: {
id: 'account_edit.save',
defaultMessage: 'Save',
},
});
const selectFieldLimits = createAppSelector(
[
(state) =>
state.server.getIn(['server', 'configuration', 'accounts']) as
| ImmutableMap<string, number>
| undefined,
],
(accounts) => ({
nameLimit: accounts?.get('profile_field_name_limit'),
valueLimit: accounts?.get('profile_field_value_limit'),
}),
);
export const EditFieldModal: FC<DialogModalProps & { fieldKey?: string }> = ({
onClose,
fieldKey,
}) => {
const intl = useIntl();
const field = useAppSelector((state) => selectFieldById(state, fieldKey));
const [newLabel, setNewLabel] = useState(field?.name ?? '');
const [newValue, setNewValue] = useState(field?.value ?? '');
const { nameLimit, valueLimit } = useAppSelector(selectFieldLimits);
const isPending = useAppSelector((state) => state.profileEdit.isPending);
const disabled =
!nameLimit ||
!valueLimit ||
newLabel.length > nameLimit ||
newValue.length > valueLimit;
const dispatch = useAppDispatch();
const handleSave = useCallback(() => {
if (disabled || isPending) {
return;
}
void dispatch(
updateField({ id: fieldKey, name: newLabel, value: newValue }),
).then(onClose);
}, [disabled, dispatch, fieldKey, isPending, newLabel, newValue, onClose]);
return (
<ConfirmationModal
onClose={onClose}
title={
field
? intl.formatMessage(messages.editTitle)
: intl.formatMessage(messages.addTitle)
}
confirm={intl.formatMessage(messages.save)}
onConfirm={handleSave}
updating={isPending}
disabled={disabled}
className={classes.wrapper}
>
<EmojiTextInputField
value={newLabel}
onChange={setNewLabel}
label={intl.formatMessage(messages.editLabelField)}
hint={intl.formatMessage(messages.editLabelHint)}
maxLength={nameLimit}
/>
<EmojiTextInputField
value={newValue}
onChange={setNewValue}
label={intl.formatMessage(messages.editValueField)}
hint={intl.formatMessage(messages.editValueHint)}
maxLength={valueLimit}
/>
</ConfirmationModal>
);
};
export const DeleteFieldModal: FC<DialogModalProps & { fieldKey: string }> = ({
onClose,
fieldKey,
}) => {
const isPending = useAppSelector((state) => state.profileEdit.isPending);
const dispatch = useAppDispatch();
const handleDelete = useCallback(() => {
void dispatch(removeField({ key: fieldKey })).then(onClose);
}, [dispatch, fieldKey, onClose]);
return (
<DialogModal
onClose={onClose}
title={
<FormattedMessage
id='account_edit.field_delete_modal.title'
defaultMessage='Delete custom field?'
/>
}
buttons={
<Button dangerous onClick={handleDelete} disabled={isPending}>
<FormattedMessage
id='account_edit.field_delete_modal.delete_button'
defaultMessage='Delete'
/>
</Button>
}
>
<FormattedMessage
id='account_edit.field_delete_modal.confirm'
defaultMessage='Are you sure you want to delete this custom field? This action cant be undone.'
tagName='p'
/>
</DialogModal>
);
};
export const RearrangeFieldsModal: FC<DialogModalProps> = ({ onClose }) => {
return (
<DialogModal onClose={onClose} title='Not implemented yet'>
<p>Not implemented yet</p>
</DialogModal>
);
};

View File

@ -0,0 +1,5 @@
export * from './bio_modal';
export * from './fields_modals';
export * from './name_modal';
export * from './profile_display_modal';
export * from './verified_modal';

View File

@ -1,20 +1,14 @@
import { useCallback, useId, useRef, useState } from 'react'; import { useCallback, useId, useState } from 'react';
import type { ChangeEventHandler, FC } from 'react'; import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { TextInput } from '@/flavours/glitch/components/form_fields'; import { EmojiTextInputField } from '@/flavours/glitch/components/form_fields';
import { insertEmojiAtPosition } from '@/flavours/glitch/features/emoji/utils';
import type { BaseConfirmationModalProps } from '@/flavours/glitch/features/ui/components/confirmation_modals'; import type { BaseConfirmationModalProps } from '@/flavours/glitch/features/ui/components/confirmation_modals';
import { ConfirmationModal } from '@/flavours/glitch/features/ui/components/confirmation_modals'; import { ConfirmationModal } from '@/flavours/glitch/features/ui/components/confirmation_modals';
import { patchProfile } from '@/flavours/glitch/reducers/slices/profile_edit'; import { patchProfile } from '@/flavours/glitch/reducers/slices/profile_edit';
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store'; import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
import classes from '../styles.module.scss';
import { CharCounter } from './char_counter';
import { EmojiPicker } from './emoji_picker';
const messages = defineMessages({ const messages = defineMessages({
addTitle: { addTitle: {
id: 'account_edit.name_modal.add_title', id: 'account_edit.name_modal.add_title',
@ -30,30 +24,24 @@ const messages = defineMessages({
}, },
}); });
const MAX_NAME_LENGTH = 30;
export const NameModal: FC<BaseConfirmationModalProps> = ({ onClose }) => { export const NameModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
const intl = useIntl(); const intl = useIntl();
const titleId = useId(); const titleId = useId();
const counterId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const { profile: { displayName } = {}, isPending } = useAppSelector( const { profile: { displayName } = {}, isPending } = useAppSelector(
(state) => state.profileEdit, (state) => state.profileEdit,
); );
const [newName, setNewName] = useState(displayName ?? ''); const maxLength = useAppSelector(
const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback( (state) =>
(event) => { state.server.getIn([
setNewName(event.currentTarget.value); 'server',
}, 'configuration',
[], 'accounts',
'max_display_name_length',
]) as number | undefined,
); );
const handlePickEmoji = useCallback((emoji: string) => {
setNewName((prev) => { const [newName, setNewName] = useState(displayName ?? '');
const position = inputRef.current?.selectionStart ?? prev.length;
return insertEmojiAtPosition(prev, emoji, position);
});
}, []);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
@ -70,27 +58,18 @@ export const NameModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
onConfirm={handleSave} onConfirm={handleSave}
onClose={onClose} onClose={onClose}
updating={isPending} updating={isPending}
disabled={newName.length > MAX_NAME_LENGTH} disabled={!!maxLength && newName.length > maxLength}
noCloseOnConfirm noCloseOnConfirm
noFocusButton noFocusButton
> >
<div className={classes.inputWrapper}> <EmojiTextInputField
<TextInput value={newName}
value={newName} onChange={setNewName}
ref={inputRef} aria-labelledby={titleId}
onChange={handleChange} maxLength={maxLength}
className={classes.inputText} label=''
aria-labelledby={titleId} // eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
aria-describedby={counterId} autoFocus
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
autoFocus
/>
<EmojiPicker onPick={handlePickEmoji} />
</div>
<CharCounter
currentLength={newName.length}
maxLength={MAX_NAME_LENGTH}
id={counterId}
/> />
</ConfirmationModal> </ConfirmationModal>
); );

View File

@ -12,7 +12,8 @@ import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
import type { DialogModalProps } from '../../ui/components/dialog_modal'; import type { DialogModalProps } from '../../ui/components/dialog_modal';
import { DialogModal } from '../../ui/components/dialog_modal'; import { DialogModal } from '../../ui/components/dialog_modal';
import { messages } from '../index'; import { messages } from '../index';
import classes from '../styles.module.scss';
import classes from './styles.module.scss';
export const ProfileDisplayModal: FC<DialogModalProps> = ({ onClose }) => { export const ProfileDisplayModal: FC<DialogModalProps> = ({ onClose }) => {
const intl = useIntl(); const intl = useIntl();

View File

@ -0,0 +1,70 @@
.wrapper {
display: flex;
gap: 16px;
flex-direction: column;
}
.toggleInputWrapper {
> div {
padding: 12px 0;
&:not(:first-child) {
border-top: 1px solid var(--color-border-primary);
}
}
}
.verifiedSteps {
font-size: 15px;
li {
counter-increment: steps;
padding-left: 34px;
margin-top: 24px;
position: relative;
h2 {
font-weight: 600;
}
&::before {
content: counter(steps);
position: absolute;
left: 0;
border: 1px solid var(--color-border-primary);
border-radius: 9999px;
font-weight: 600;
padding: 4px;
width: 16px;
height: 16px;
line-height: 16px;
text-align: center;
}
}
}
.details {
color: var(--color-text-secondary);
font-size: 13px;
margin-top: 8px;
summary {
cursor: pointer;
font-weight: 600;
list-style: none;
margin-bottom: 8px;
text-decoration: underline;
text-decoration-style: dotted;
}
:global(.icon) {
width: 1.4em;
height: 1.4em;
vertical-align: middle;
transition: transform 0.2s ease-in-out;
}
&[open] :global(.icon) {
transform: rotate(-180deg);
}
}

View File

@ -0,0 +1,85 @@
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { CopyLinkField } from '@/flavours/glitch/components/form_fields/copy_link_field';
import { Icon } from '@/flavours/glitch/components/icon';
import { createAppSelector, useAppSelector } from '@/flavours/glitch/store';
import ExpandArrowIcon from '@/material-icons/400-24px/expand_more.svg?react';
import type { DialogModalProps } from '../../ui/components/dialog_modal';
import { DialogModal } from '../../ui/components/dialog_modal';
import classes from './styles.module.scss';
const selectAccountUrl = createAppSelector(
[(state) => state.meta.get('me') as string, (state) => state.accounts],
(accountId, accounts) => {
const account = accounts.get(accountId);
return account?.get('url') ?? '';
},
);
export const VerifiedModal: FC<DialogModalProps> = ({ onClose }) => {
const accountUrl = useAppSelector(selectAccountUrl);
return (
<DialogModal
onClose={onClose}
title={
<FormattedMessage
id='account_edit.verified_modal.title'
defaultMessage='How to add a verified link'
/>
}
noCancelButton
>
<FormattedMessage
id='account_edit.verified_modal.details'
defaultMessage='Add credibility to your Mastodon profile by verifying links to personal websites. Heres how it works:'
tagName='p'
/>
<ol className={classes.verifiedSteps}>
<li>
<CopyLinkField
label={
<FormattedMessage
id='account_edit.verified_modal.step1.header'
defaultMessage='Copy the HTML code below and paste into the header of your website'
tagName='h2'
/>
}
value={`<a rel="me" href="${accountUrl}">Mastodon</a>`}
/>
<details className={classes.details}>
<summary>
<FormattedMessage
id='account_edit.verified_modal.invisible_link.summary'
defaultMessage='How do I make the link invisible?'
/>
<Icon icon={ExpandArrowIcon} id='arrow' />
</summary>
<FormattedMessage
id='account_edit.verified_modal.invisible_link.details'
defaultMessage='Add the link to your header. The important part is rel="me" which prevents impersonation on websites with user-generated content. You can even use a link tag in the header of the page instead of {tag}, but the HTML must be accessible without executing JavaScript.'
values={{ tag: <code>&lt;a&gt;</code> }}
/>
</details>
</li>
<li>
<FormattedMessage
id='account_edit.verified_modal.step2.header'
defaultMessage='Add your website as a custom field'
tagName='h2'
/>
<FormattedMessage
id='account_edit.verified_modal.step2.details'
defaultMessage='If youve already added your website as a custom field, youll need to delete and re-add it to trigger verification.'
tagName='p'
/>
</li>
</ol>
</DialogModal>
);
};

View File

@ -24,6 +24,38 @@
border: 1px solid var(--color-border-primary); border: 1px solid var(--color-border-primary);
} }
.field {
padding: 12px 0;
display: flex;
gap: 4px;
align-items: start;
> div {
flex-grow: 1;
}
}
.fieldName {
color: var(--color-text-secondary);
font-size: 13px;
}
.verifiedLinkHelpButton {
font-size: 13px;
font-weight: 600;
text-decoration: underline;
&:global(.button) {
color: var(--color-text-primary);
&:active,
&:hover,
&:focus {
text-decoration: underline;
}
}
}
// Featured Tags Page // Featured Tags Page
.wrapper { .wrapper {
@ -58,48 +90,6 @@
} }
} }
// Modals
.inputWrapper {
position: relative;
}
// Override input styles
.inputWrapper .inputText {
font-size: 15px;
padding-right: 32px;
}
textarea.inputText {
min-height: 82px;
height: 100%;
// 160px is approx the height of the modal header and footer
max-height: calc(80vh - 160px);
}
.inputWrapper :global(.emoji-picker-dropdown) {
position: absolute;
bottom: 10px;
right: 8px;
height: 24px;
z-index: 1;
:global(.icon-button) {
color: var(--color-text-secondary);
}
}
.toggleInputWrapper {
> div {
padding: 12px 0;
&:not(:first-child) {
border-top: 1px solid var(--color-border-primary);
}
}
}
// Column component // Column component
.column { .column {
@ -195,14 +185,3 @@ textarea.inputText {
.sectionSubtitle { .sectionSubtitle {
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
// Counter component
.counter {
margin-top: 4px;
font-size: 13px;
}
.counterError {
color: var(--color-text-error);
}

View File

@ -82,10 +82,11 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
const { collections, status } = useAppSelector((state) => const { collections, status } = useAppSelector((state) =>
selectAccountCollections(state, accountId ?? null), selectAccountCollections(state, accountId ?? null),
); );
const publicCollections = collections.filter( const listedCollections = collections.filter(
// This filter only applies when viewing your own profile, where the endpoint // Hide unlisted and empty collections to avoid confusion
// returns all collections, but we hide unlisted ones here to avoid confusion // (Unlisted collections will only be part of the payload
(item) => item.discoverable, // when viewing your own profile.)
(item) => item.discoverable && !!item.item_count,
); );
if (accountId === null) { if (accountId === null) {
@ -124,7 +125,7 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
{accountId && ( {accountId && (
<AccountHeader accountId={accountId} hideTabs={forceEmptyState} /> <AccountHeader accountId={accountId} hideTabs={forceEmptyState} />
)} )}
{publicCollections.length > 0 && status === 'idle' && ( {listedCollections.length > 0 && status === 'idle' && (
<> <>
<h4 className='column-subheading'> <h4 className='column-subheading'>
<FormattedMessage <FormattedMessage
@ -133,13 +134,13 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
/> />
</h4> </h4>
<ItemList> <ItemList>
{publicCollections.map((item, index) => ( {listedCollections.map((item, index) => (
<CollectionListItem <CollectionListItem
key={item.id} key={item.id}
collection={item} collection={item}
withoutBorder={index === publicCollections.length - 1} withoutBorder={index === listedCollections.length - 1}
positionInList={index + 1} positionInList={index + 1}
listSize={publicCollections.length} listSize={listedCollections.length}
/> />
))} ))}
</ItemList> </ItemList>

View File

@ -0,0 +1,212 @@
import { Fragment, useCallback, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Button } from '@/flavours/glitch/components/button';
import { useRelationship } from '@/flavours/glitch/hooks/useRelationship';
import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
import { Account } from 'flavours/glitch/components/account';
import { DisplayName } from 'flavours/glitch/components/display_name';
import {
Article,
ItemList,
} from 'flavours/glitch/components/scrollable_list/components';
import { useAccount } from 'flavours/glitch/hooks/useAccount';
import { me } from 'flavours/glitch/initial_state';
import classes from './styles.module.scss';
const messages = defineMessages({
empty: {
id: 'collections.accounts.empty_title',
defaultMessage: 'This collection is empty',
},
accounts: {
id: 'collections.detail.accounts_heading',
defaultMessage: 'Accounts',
},
});
const SimpleAuthorName: React.FC<{ id: string }> = ({ id }) => {
const account = useAccount(id);
return <DisplayName account={account} variant='simple' />;
};
const AccountItem: React.FC<{
accountId: string | undefined;
collectionOwnerId: string;
withBorder?: boolean;
}> = ({ accountId, withBorder = true, 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}
withBorder={withBorder}
id={accountId}
/>
);
};
const SensitiveScreen: React.FC<{
sensitive: boolean | undefined;
focusTargetRef: React.RefObject<HTMLHeadingElement>;
children: React.ReactNode;
}> = ({ sensitive, focusTargetRef, children }) => {
const [isVisible, setIsVisible] = useState(!sensitive);
const showAnyway = useCallback(() => {
setIsVisible(true);
setTimeout(() => {
focusTargetRef.current?.focus();
}, 0);
}, [focusTargetRef]);
if (isVisible) {
return children;
}
return (
<div className={classes.sensitiveWarning}>
<FormattedMessage
id='collections.detail.sensitive_note'
defaultMessage='This collection contains accounts and content that may be sensitive to some users.'
tagName='p'
/>
<Button onClick={showAnyway}>
<FormattedMessage
id='content_warning.show'
defaultMessage='Show anyway'
tagName={Fragment}
/>
</Button>
</div>
);
};
/**
* Returns the collection's account items. If the current user's account
* is part of the collection, it will be returned separately.
*/
function getCollectionItems(collection: ApiCollectionJSON | undefined) {
if (!collection)
return {
currentUserInCollection: null,
items: [],
};
const { account_id, items } = collection;
const isOwnCollection = account_id === me;
const currentUserIndex = items.findIndex(
(account) => account.account_id === me,
);
if (isOwnCollection || currentUserIndex === -1) {
return {
currentUserInCollection: null,
items,
};
} else {
return {
currentUserInCollection: items.at(currentUserIndex) ?? null,
items: items.toSpliced(currentUserIndex, 1),
};
}
}
export const CollectionAccountsList: React.FC<{
collection?: ApiCollectionJSON;
isLoading: boolean;
}> = ({ collection, isLoading }) => {
const intl = useIntl();
const listHeadingRef = useRef<HTMLHeadingElement>(null);
const isOwnCollection = collection?.account_id === me;
const { items, currentUserInCollection } = getCollectionItems(collection);
return (
<ItemList
isLoading={isLoading}
emptyMessage={intl.formatMessage(messages.empty)}
>
{collection && currentUserInCollection ? (
<>
<h3 className={classes.columnSubheading}>
<FormattedMessage
id='collections.detail.author_added_you'
defaultMessage='{author} added you to this collection'
values={{
author: <SimpleAuthorName id={collection.account_id} />,
}}
tagName={Fragment}
/>
</h3>
<Article
key={currentUserInCollection.account_id}
aria-posinset={1}
aria-setsize={items.length}
>
<AccountItem
withBorder={false}
accountId={currentUserInCollection.account_id}
collectionOwnerId={collection.account_id}
/>
</Article>
<h3
className={classes.columnSubheading}
tabIndex={-1}
ref={listHeadingRef}
>
<FormattedMessage
id='collections.detail.other_accounts_in_collection'
defaultMessage='Others in this collection:'
tagName={Fragment}
/>
</h3>
</>
) : (
<h3
className='column-subheading sr-only'
tabIndex={-1}
ref={listHeadingRef}
>
{intl.formatMessage(messages.accounts)}
</h3>
)}
{collection && (
<SensitiveScreen
sensitive={!isOwnCollection && collection.sensitive}
focusTargetRef={listHeadingRef}
>
{items.map(({ account_id }, index, items) => (
<Article
key={account_id}
aria-posinset={index + (currentUserInCollection ? 2 : 1)}
aria-setsize={items.length}
>
<AccountItem
accountId={account_id}
collectionOwnerId={collection.account_id}
/>
</Article>
))}
</SensitiveScreen>
)}
</ItemList>
);
};

View File

@ -6,11 +6,9 @@ import { Helmet } from 'react-helmet';
import { useLocation, useParams } from 'react-router'; import { useLocation, useParams } from 'react-router';
import { openModal } from '@/flavours/glitch/actions/modal'; import { openModal } from '@/flavours/glitch/actions/modal';
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 type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections'; import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
import { Account } from 'flavours/glitch/components/account';
import { Avatar } from 'flavours/glitch/components/avatar'; import { Avatar } from 'flavours/glitch/components/avatar';
import { Column } from 'flavours/glitch/components/column'; import { Column } from 'flavours/glitch/components/column';
import { ColumnHeader } from 'flavours/glitch/components/column_header'; import { ColumnHeader } from 'flavours/glitch/components/column_header';
@ -19,26 +17,19 @@ import {
LinkedDisplayName, LinkedDisplayName,
} from 'flavours/glitch/components/display_name'; } from 'flavours/glitch/components/display_name';
import { IconButton } from 'flavours/glitch/components/icon_button'; import { IconButton } from 'flavours/glitch/components/icon_button';
import { import { Scrollable } from 'flavours/glitch/components/scrollable_list/components';
Article,
ItemList,
Scrollable,
} from 'flavours/glitch/components/scrollable_list/components';
import { Tag } from 'flavours/glitch/components/tags/tag'; import { Tag } from 'flavours/glitch/components/tags/tag';
import { useAccount } from 'flavours/glitch/hooks/useAccount'; import { useAccount } from 'flavours/glitch/hooks/useAccount';
import { me } from 'flavours/glitch/initial_state'; import { me } from 'flavours/glitch/initial_state';
import { fetchCollection } from 'flavours/glitch/reducers/slices/collections'; import { fetchCollection } from 'flavours/glitch/reducers/slices/collections';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
import { CollectionAccountsList } from './collection_list';
import { CollectionMetaData } from './collection_list_item'; import { CollectionMetaData } from './collection_list_item';
import { CollectionMenu } from './collection_menu'; import { CollectionMenu } from './collection_menu';
import classes from './styles.module.scss'; import classes from './styles.module.scss';
const messages = defineMessages({ const messages = defineMessages({
empty: {
id: 'collections.accounts.empty_title',
defaultMessage: 'This collection is empty',
},
loading: { loading: {
id: 'collections.detail.loading', id: 'collections.detail.loading',
defaultMessage: 'Loading collection…', defaultMessage: 'Loading collection…',
@ -47,10 +38,6 @@ const messages = defineMessages({
id: 'collections.detail.share', id: 'collections.detail.share',
defaultMessage: 'Share this collection', defaultMessage: 'Share this collection',
}, },
accounts: {
id: 'collections.detail.accounts_heading',
defaultMessage: 'Accounts',
},
}); });
export const AuthorNote: React.FC<{ id: string; previewMode?: boolean }> = ({ export const AuthorNote: React.FC<{ id: string; previewMode?: boolean }> = ({
@ -149,33 +136,10 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
collection={collection} collection={collection}
className={classes.metaData} className={classes.metaData}
/> />
<h2 className='sr-only'>{intl.formatMessage(messages.accounts)}</h2>
</div> </div>
); );
}; };
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 }) => {
@ -185,7 +149,6 @@ export const CollectionDetailPage: React.FC<{
const collection = useAppSelector((state) => const collection = useAppSelector((state) =>
id ? state.collections.collections[id] : undefined, id ? state.collections.collections[id] : undefined,
); );
const isLoading = !!id && !collection; const isLoading = !!id && !collection;
useEffect(() => { useEffect(() => {
@ -208,24 +171,7 @@ export const CollectionDetailPage: React.FC<{
<Scrollable> <Scrollable>
{collection && <CollectionHeader collection={collection} />} {collection && <CollectionHeader collection={collection} />}
<ItemList <CollectionAccountsList collection={collection} isLoading={isLoading} />
isLoading={isLoading}
emptyMessage={intl.formatMessage(messages.empty)}
>
{collection?.items.map(({ account_id }, index, items) => (
<Article
key={account_id}
data-id={account_id}
aria-posinset={index + 1}
aria-setsize={items.length}
>
<CollectionAccountItem
accountId={account_id}
collectionOwnerId={collection.account_id}
/>
</Article>
))}
</ItemList>
</Scrollable> </Scrollable>
<Helmet> <Helmet>

View File

@ -57,6 +57,18 @@
font-size: 15px; font-size: 15px;
} }
.columnSubheading {
background: var(--color-bg-secondary);
padding: 15px 20px;
font-size: 15px;
font-weight: 500;
&:focus-visible {
outline: var(--outline-focus-default);
outline-offset: -2px;
}
}
.displayNameWithAvatar { .displayNameWithAvatar {
display: inline-flex; display: inline-flex;
gap: 4px; gap: 4px;
@ -76,3 +88,18 @@
align-self: center; align-self: center;
} }
} }
.sensitiveWarning {
display: flex;
flex-direction: column;
align-items: center;
max-width: 460px;
margin: auto;
padding: 60px 30px;
gap: 20px;
text-align: center;
text-wrap: balance;
font-size: 15px;
line-height: 1.5;
cursor: default;
}

View File

@ -15,7 +15,6 @@ import { Account } from 'flavours/glitch/components/account';
import { Avatar } from 'flavours/glitch/components/avatar'; import { Avatar } from 'flavours/glitch/components/avatar';
import { Badge } from 'flavours/glitch/components/badge'; import { Badge } from 'flavours/glitch/components/badge';
import { Button } from 'flavours/glitch/components/button'; import { Button } from 'flavours/glitch/components/button';
import { Callout } from 'flavours/glitch/components/callout';
import { DisplayName } from 'flavours/glitch/components/display_name'; import { DisplayName } from 'flavours/glitch/components/display_name';
import { EmptyState } from 'flavours/glitch/components/empty_state'; import { EmptyState } from 'flavours/glitch/components/empty_state';
import { FormStack, Combobox } from 'flavours/glitch/components/form_fields'; import { FormStack, Combobox } from 'flavours/glitch/components/form_fields';
@ -40,7 +39,6 @@ import { getCollectionEditorState } from './state';
import classes from './styles.module.scss'; import classes from './styles.module.scss';
import { WizardStepHeader } from './wizard_step_header'; import { WizardStepHeader } from './wizard_step_header';
const MIN_ACCOUNT_COUNT = 1;
const MAX_ACCOUNT_COUNT = 25; const MAX_ACCOUNT_COUNT = 25;
function isOlderThanAWeek(date?: string): boolean { function isOlderThanAWeek(date?: string): boolean {
@ -164,9 +162,6 @@ export const CollectionAccounts: React.FC<{
); );
const hasMaxAccounts = accountIds.length === MAX_ACCOUNT_COUNT; const hasMaxAccounts = accountIds.length === MAX_ACCOUNT_COUNT;
const hasMinAccounts = accountIds.length === MIN_ACCOUNT_COUNT;
const hasTooFewAccounts = accountIds.length < MIN_ACCOUNT_COUNT;
const canSubmit = !hasTooFewAccounts;
const { const {
accountIds: suggestedAccountIds, accountIds: suggestedAccountIds,
@ -319,17 +314,13 @@ export const CollectionAccounts: React.FC<{
(e: React.FormEvent) => { (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!canSubmit) {
return;
}
if (!id) { if (!id) {
history.push(`/collections/new/details`, { history.push(`/collections/new/details`, {
account_ids: accountIds, account_ids: accountIds,
}); });
} }
}, },
[canSubmit, id, history, accountIds], [id, history, accountIds],
); );
const inputId = useId(); const inputId = useId();
@ -384,16 +375,6 @@ export const CollectionAccounts: React.FC<{
/> />
)} )}
{hasMinAccounts && (
<Callout>
<FormattedMessage
id='collections.hints.can_not_remove_more_accounts'
defaultMessage='Collections must contain at least {count, plural, one {# account} other {# accounts}}. Removing more accounts is not possible.'
values={{ count: MIN_ACCOUNT_COUNT }}
/>
</Callout>
)}
<Scrollable className={classes.scrollableWrapper}> <Scrollable className={classes.scrollableWrapper}>
<ItemList <ItemList
className={classes.scrollableInner} className={classes.scrollableInner}
@ -425,7 +406,7 @@ export const CollectionAccounts: React.FC<{
> >
<AddedAccountItem <AddedAccountItem
accountId={accountId} accountId={accountId}
isRemovable={!isEditMode || !hasMinAccounts} isRemovable={!isEditMode}
onRemove={handleRemoveAccountItem} onRemove={handleRemoveAccountItem}
/> />
</Article> </Article>
@ -435,39 +416,25 @@ export const CollectionAccounts: React.FC<{
</FormStack> </FormStack>
{!isEditMode && ( {!isEditMode && (
<div className={classes.stickyFooter}> <div className={classes.stickyFooter}>
{hasTooFewAccounts ? ( <div className={classes.actionWrapper}>
<Callout icon={false} className={classes.submitDisabledCallout}> <FormattedMessage
<FormattedMessage id='collections.hints.accounts_counter'
id='collections.hints.add_more_accounts' defaultMessage='{count} / {max} accounts'
defaultMessage='Add at least {count, plural, one {# account} other {# accounts}} to continue' values={{ count: accountIds.length, max: MAX_ACCOUNT_COUNT }}
values={{ count: MIN_ACCOUNT_COUNT }} >
/> {(text) => <div className={classes.itemCountReadout}>{text}</div>}
</Callout> </FormattedMessage>
) : ( <Button type='submit'>
<div className={classes.actionWrapper}> {id ? (
<FormattedMessage <FormattedMessage id='lists.save' defaultMessage='Save' />
id='collections.hints.accounts_counter' ) : (
defaultMessage='{count} / {max} accounts' <FormattedMessage
values={{ count: accountIds.length, max: MAX_ACCOUNT_COUNT }} id='collections.continue'
> defaultMessage='Continue'
{(text) => ( />
<div className={classes.itemCountReadout}>{text}</div>
)}
</FormattedMessage>
{canSubmit && (
<Button type='submit'>
{id ? (
<FormattedMessage id='lists.save' defaultMessage='Save' />
) : (
<FormattedMessage
id='collections.continue'
defaultMessage='Continue'
/>
)}
</Button>
)} )}
</div> </Button>
)} </div>
</div> </div>
)} )}
</form> </form>

View File

@ -6,7 +6,7 @@ import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import AddIcon from '@/material-icons/400-24px/add.svg?react'; import AddIcon from '@/material-icons/400-24px/add.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; import CollectionsFilledIcon from '@/material-icons/400-24px/category-fill.svg?react';
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react'; import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { Column } from 'flavours/glitch/components/column'; import { Column } from 'flavours/glitch/components/column';
import { ColumnHeader } from 'flavours/glitch/components/column_header'; import { ColumnHeader } from 'flavours/glitch/components/column_header';
@ -73,8 +73,8 @@ export const Collections: React.FC<{
> >
<ColumnHeader <ColumnHeader
title={intl.formatMessage(messages.heading)} title={intl.formatMessage(messages.heading)}
icon='list-ul' icon='collections'
iconComponent={ListAltIcon} iconComponent={CollectionsFilledIcon}
multiColumn={multiColumn} multiColumn={multiColumn}
extraButton={ extraButton={
<Link <Link

View File

@ -15,6 +15,8 @@ import AddIcon from '@/material-icons/400-24px/add.svg?react';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import BookmarksActiveIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react'; import BookmarksActiveIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
import BookmarksIcon from '@/material-icons/400-24px/bookmarks.svg?react'; import BookmarksIcon from '@/material-icons/400-24px/bookmarks.svg?react';
import CollectionsActiveIcon from '@/material-icons/400-24px/category-fill.svg?react';
import CollectionsIcon from '@/material-icons/400-24px/category.svg?react';
import HomeActiveIcon from '@/material-icons/400-24px/home-fill.svg?react'; import HomeActiveIcon from '@/material-icons/400-24px/home-fill.svg?react';
import HomeIcon from '@/material-icons/400-24px/home.svg?react'; import HomeIcon from '@/material-icons/400-24px/home.svg?react';
import InfoIcon from '@/material-icons/400-24px/info.svg?react'; import InfoIcon from '@/material-icons/400-24px/info.svg?react';
@ -53,6 +55,7 @@ import { selectUnreadNotificationGroupsCount } from 'flavours/glitch/selectors/n
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store'; import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { AnnualReportNavItem } from '../annual_report/nav_item'; import { AnnualReportNavItem } from '../annual_report/nav_item';
import { areCollectionsEnabled } from '../collections/utils';
import { DisabledAccountBanner } from './components/disabled_account_banner'; import { DisabledAccountBanner } from './components/disabled_account_banner';
import { FollowedTagsPanel } from './components/followed_tags_panel'; import { FollowedTagsPanel } from './components/followed_tags_panel';
@ -76,6 +79,10 @@ const messages = defineMessages({
direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' }, direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' }, favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
collections: {
id: 'navigation_bar.collections',
defaultMessage: 'Collections',
},
preferences: { preferences: {
id: 'navigation_bar.preferences', id: 'navigation_bar.preferences',
defaultMessage: 'Preferences', defaultMessage: 'Preferences',
@ -351,6 +358,16 @@ export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({
activeIconComponent={BookmarksActiveIcon} activeIconComponent={BookmarksActiveIcon}
text={intl.formatMessage(messages.bookmarks)} text={intl.formatMessage(messages.bookmarks)}
/> />
{areCollectionsEnabled() && (
<ColumnLink
transparent
to='/collections'
icon='collections'
iconComponent={CollectionsIcon}
activeIconComponent={CollectionsActiveIcon}
text={intl.formatMessage(messages.collections)}
/>
)}
<ColumnLink <ColumnLink
transparent transparent
to='/conversations' to='/conversations'

View File

@ -21,23 +21,26 @@ const messages = defineMessages({
}, },
}); });
interface ConfirmationModalProps {
title: React.ReactNode;
titleId?: string;
message?: React.ReactNode;
confirm: React.ReactNode;
cancel?: React.ReactNode;
secondary?: React.ReactNode;
onSecondary?: () => void;
onConfirm: () => void;
noCloseOnConfirm?: boolean;
extraContent?: React.ReactNode;
children?: React.ReactNode;
className?: string;
updating?: boolean;
disabled?: boolean;
noFocusButton?: boolean;
}
export const ConfirmationModal: React.FC< export const ConfirmationModal: React.FC<
{ ConfirmationModalProps & BaseConfirmationModalProps
title: React.ReactNode;
titleId?: string;
message?: React.ReactNode;
confirm: React.ReactNode;
cancel?: React.ReactNode;
secondary?: React.ReactNode;
onSecondary?: () => void;
onConfirm: () => void;
noCloseOnConfirm?: boolean;
extraContent?: React.ReactNode;
children?: React.ReactNode;
updating?: boolean;
disabled?: boolean;
noFocusButton?: boolean;
} & BaseConfirmationModalProps
> = ({ > = ({
title, title,
titleId, titleId,
@ -50,6 +53,7 @@ export const ConfirmationModal: React.FC<
onSecondary, onSecondary,
extraContent, extraContent,
children, children,
className,
updating, updating,
disabled, disabled,
noCloseOnConfirm = false, noCloseOnConfirm = false,
@ -70,7 +74,7 @@ export const ConfirmationModal: React.FC<
return ( return (
<ModalShell> <ModalShell>
<ModalShellBody> <ModalShellBody className={className}>
<h1 id={titleId}>{title}</h1> <h1 id={titleId}>{title}</h1>
{message && <p>{message}</p>} {message && <p>{message}</p>}

View File

@ -15,11 +15,10 @@ interface DialogModalProps {
title: ReactNode; title: ReactNode;
onClose: () => void; onClose: () => void;
description?: ReactNode; description?: ReactNode;
formClassName?: string; wrapperClassName?: string;
children?: ReactNode; children?: ReactNode;
noCancelButton?: boolean; noCancelButton?: boolean;
onSave?: () => void; buttons?: ReactNode;
saveLabel?: ReactNode;
} }
export const DialogModal: FC<DialogModalProps> = ({ export const DialogModal: FC<DialogModalProps> = ({
@ -27,16 +26,13 @@ export const DialogModal: FC<DialogModalProps> = ({
title, title,
onClose, onClose,
description, description,
formClassName, wrapperClassName,
children, children,
noCancelButton = false, noCancelButton = false,
onSave, buttons,
saveLabel,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const showButtons = !noCancelButton || onSave;
return ( return (
<div className={classNames('modal-root__modal dialog-modal', className)}> <div className={classNames('modal-root__modal dialog-modal', className)}>
<div className='dialog-modal__header'> <div className='dialog-modal__header'>
@ -61,13 +57,16 @@ export const DialogModal: FC<DialogModalProps> = ({
</div> </div>
)} )}
<div <div
className={classNames('dialog-modal__content__form', formClassName)} className={classNames(
'dialog-modal__content__form',
wrapperClassName,
)}
> >
{children} {children}
</div> </div>
</div> </div>
{showButtons && ( {(buttons || !noCancelButton) && (
<div className='dialog-modal__content__actions'> <div className='dialog-modal__content__actions'>
{!noCancelButton && ( {!noCancelButton && (
<Button onClick={onClose} secondary> <Button onClick={onClose} secondary>
@ -77,16 +76,7 @@ export const DialogModal: FC<DialogModalProps> = ({
/> />
</Button> </Button>
)} )}
{onSave && ( {buttons}
<Button onClick={onClose}>
{saveLabel ?? (
<FormattedMessage
id='confirmation_modal.cancel'
defaultMessage='Cancel'
/>
)}
</Button>
)}
</div> </div>
)} )}
</div> </div>

View File

@ -102,11 +102,20 @@ export const MODAL_COMPONENTS = {
'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_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': accountEditModal('NameModal'),
'ACCOUNT_EDIT_BIO': () => import('@/flavours/glitch/features/account_edit/components/bio_modal').then(module => ({ default: module.BioModal })), 'ACCOUNT_EDIT_BIO': accountEditModal('BioModal'),
'ACCOUNT_EDIT_PROFILE_DISPLAY': () => import('@/flavours/glitch/features/account_edit/components/profile_display_modal').then(module => ({ default: module.ProfileDisplayModal })), 'ACCOUNT_EDIT_PROFILE_DISPLAY': accountEditModal('ProfileDisplayModal'),
'ACCOUNT_EDIT_VERIFY_LINKS': accountEditModal('VerifiedModal'),
'ACCOUNT_EDIT_FIELD_EDIT': accountEditModal('EditFieldModal'),
'ACCOUNT_EDIT_FIELD_DELETE': accountEditModal('DeleteFieldModal'),
'ACCOUNT_EDIT_FIELDS_REORDER': accountEditModal('ReorderFieldsModal'),
}; };
/** @arg {keyof import('@/flavours/glitch/features/account_edit/modals')} type */
function accountEditModal(type) {
return () => import('@/flavours/glitch/features/account_edit/modals').then(module => ({ default: module[type] }));
}
export default class ModalRoot extends PureComponent { export default class ModalRoot extends PureComponent {
static propTypes = { static propTypes = {

View File

@ -12,6 +12,7 @@ import {
apiPostFeaturedTag, apiPostFeaturedTag,
} from '@/flavours/glitch/api/accounts'; } from '@/flavours/glitch/api/accounts';
import { apiGetSearch } from '@/flavours/glitch/api/search'; import { apiGetSearch } from '@/flavours/glitch/api/search';
import type { ApiAccountFieldJSON } from '@/flavours/glitch/api_types/accounts';
import type { import type {
ApiProfileJSON, ApiProfileJSON,
ApiProfileUpdateParams, ApiProfileUpdateParams,
@ -23,20 +24,25 @@ import type {
import type { AppDispatch } from '@/flavours/glitch/store'; import type { AppDispatch } from '@/flavours/glitch/store';
import { import {
createAppAsyncThunk, createAppAsyncThunk,
createAppSelector,
createDataLoadingThunk, createDataLoadingThunk,
} from '@/flavours/glitch/store/typed_functions'; } from '@/flavours/glitch/store/typed_functions';
import { hashObjectArray } from '@/flavours/glitch/utils/hash';
import type { SnakeToCamelCase } from '@/flavours/glitch/utils/types'; import type { SnakeToCamelCase } from '@/flavours/glitch/utils/types';
type ProfileData = { type ProfileData = {
[Key in keyof Omit< [Key in keyof Omit<
ApiProfileJSON, ApiProfileJSON,
'note' | 'featured_tags' 'note' | 'fields' | 'featured_tags'
> as SnakeToCamelCase<Key>]: ApiProfileJSON[Key]; > as SnakeToCamelCase<Key>]: ApiProfileJSON[Key];
} & { } & {
bio: ApiProfileJSON['note']; bio: ApiProfileJSON['note'];
fields: FieldData[];
featuredTags: TagData[]; featuredTags: TagData[];
}; };
export type FieldData = ApiAccountFieldJSON & { id: string };
export type TagData = { export type TagData = {
[Key in keyof Omit< [Key in keyof Omit<
ApiFeaturedTagJSON, ApiFeaturedTagJSON,
@ -186,7 +192,7 @@ const transformProfile = (result: ApiProfileJSON): ProfileData => ({
id: result.id, id: result.id,
displayName: result.display_name, displayName: result.display_name,
bio: result.note, bio: result.note,
fields: result.fields, fields: hashObjectArray(result.fields),
avatar: result.avatar, avatar: result.avatar,
avatarStatic: result.avatar_static, avatarStatic: result.avatar_static,
avatarDescription: result.avatar_description, avatarDescription: result.avatar_description,
@ -218,6 +224,83 @@ export const patchProfile = createDataLoadingThunk(
{ useLoadingBar: false }, { useLoadingBar: false },
); );
export const selectFieldById = createAppSelector(
[(state) => state.profileEdit.profile?.fields, (_, id?: string) => id],
(fields, fieldId) => {
if (!fields || !fieldId) {
return undefined;
}
return fields.find((field) => field.id === fieldId) ?? null;
},
);
export const updateField = createAppAsyncThunk(
`${profileEditSlice.name}/updateField`,
async (
arg: { id?: string; name: string; value: string },
{ getState, dispatch },
) => {
const fields = getState().profileEdit.profile?.fields;
if (!fields) {
throw new Error('Profile fields not found');
}
const maxFields = getState().server.getIn([
'server',
'configuration',
'accounts',
'max_fields',
]) as number | undefined;
if (maxFields && fields.length >= maxFields && !arg.id) {
throw new Error('Maximum number of profile fields reached');
}
// Replace the field data if there is an ID, otherwise append a new field.
const newFields: Pick<ApiAccountFieldJSON, 'name' | 'value'>[] = [];
for (const field of fields) {
if (field.id === arg.id) {
newFields.push({ name: arg.name, value: arg.value });
} else {
newFields.push({ name: field.name, value: field.value });
}
}
if (!arg.id) {
newFields.push({ name: arg.name, value: arg.value });
}
await dispatch(
patchProfile({
fields_attributes: newFields,
}),
);
},
);
export const removeField = createAppAsyncThunk(
`${profileEditSlice.name}/removeField`,
async (arg: { key: string }, { getState, dispatch }) => {
const fields = getState().profileEdit.profile?.fields;
if (!fields) {
throw new Error('Profile fields not found');
}
const field = fields.find((f) => f.id === arg.key);
if (!field) {
throw new Error('Field not found');
}
const newFields = fields
.filter((f) => f.id !== arg.key)
.map((f) => ({
name: f.name,
value: f.value,
}));
await dispatch(
patchProfile({
fields_attributes: newFields,
}),
);
},
);
export const fetchFeaturedTags = createDataLoadingThunk( export const fetchFeaturedTags = createDataLoadingThunk(
`${profileEditSlice.name}/fetchFeaturedTags`, `${profileEditSlice.name}/fetchFeaturedTags`,
apiGetCurrentFeaturedTags, apiGetCurrentFeaturedTags,

View File

@ -2074,7 +2074,10 @@ body > [data-popper-placement] {
.account { .account {
padding: 10px; // glitch: reduced padding padding: 10px; // glitch: reduced padding
border-bottom: 1px solid var(--color-border-primary);
&:not(&--without-border) {
border-bottom: 1px solid var(--color-border-primary);
}
.account__display-name { .account__display-name {
flex: 1 1 auto; flex: 1 1 auto;

View File

@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { cyrb32, hashObjectArray } from './hash';
describe('cyrb32', () => {
const input = 'mastodon';
it('returns a base-36 lowercase 1-6 character string', () => {
const hash = cyrb32(input);
expect(hash).toMatch(/^[0-9a-z]{1,6}$/);
});
it('returns the same output for same input and seed', () => {
const a = cyrb32(input, 1);
const b = cyrb32(input, 1);
expect(a).toBe(b);
});
it('produces different hashes for different seeds', () => {
const a = cyrb32(input, 1);
const b = cyrb32(input, 2);
expect(a).not.toBe(b);
});
});
describe('hashObjectArray', () => {
const input = [
{ name: 'Alice', value: 'Developer' },
{ name: 'Bob', value: 'Designer' },
{ name: 'Alice', value: 'Developer' }, // Duplicate
];
it('returns an array of the same length with unique hash keys', () => {
const result = hashObjectArray(input);
expect(result).toHaveLength(input.length);
const ids = result.map((obj) => obj.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(ids.length);
});
it('allows custom key names for the hash', () => {
const result = hashObjectArray(input, 'hashKey');
expect(result[0]).toHaveProperty('hashKey');
});
});

View File

@ -0,0 +1,39 @@
/**
* Fast insecure hash function.
* @param str String to hash.
* @param seed Optional seed value for different hash outputs of the same string.
* @returns Base-36 hash (1-6 characters, typically 5-6).
*/
export function cyrb32(str: string, seed = 0) {
let h1 = 0xdeadbeef ^ seed;
for (let i = 0; i < str.length; i++) {
h1 = Math.imul(h1 ^ str.charCodeAt(i), 0x9e3779b1);
}
return ((h1 ^ (h1 >>> 16)) >>> 0).toString(36);
}
/**
* Hashes an array of objects into a new array where each object has a unique hash key.
* @param array Array of objects to hash.
* @param key Key name to use for the hash in the resulting objects (default: 'id').
*/
export function hashObjectArray<
TObj extends object,
TKey extends string = 'id',
>(array: TObj[], key = 'id' as TKey): (TObj & Record<TKey, string>)[] {
const keySet = new Set<string>();
return array.map((obj) => {
const json = JSON.stringify(obj);
let seed = 0;
let hash = cyrb32(json, seed);
while (keySet.has(hash)) {
hash = cyrb32(json, ++seed);
}
keySet.add(hash);
return {
...obj,
[key]: hash,
} as TObj & Record<TKey, string>;
});
}

View File

@ -50,6 +50,10 @@ const meta = {
type: 'boolean', type: 'boolean',
description: 'Whether to display the account menu or not', description: 'Whether to display the account menu or not',
}, },
withBorder: {
type: 'boolean',
description: 'Whether to display the bottom border or not',
},
}, },
args: { args: {
name: 'Test User', name: 'Test User',
@ -60,6 +64,7 @@ const meta = {
defaultAction: 'mute', defaultAction: 'mute',
withBio: false, withBio: false,
withMenu: true, withMenu: true,
withBorder: true,
}, },
parameters: { parameters: {
state: { state: {
@ -103,6 +108,12 @@ export const NoMenu: Story = {
}, },
}; };
export const NoBorder: Story = {
args: {
withBorder: false,
},
};
export const Blocked: Story = { export const Blocked: Story = {
args: { args: {
defaultAction: 'block', defaultAction: 'block',

View File

@ -73,6 +73,7 @@ interface AccountProps {
defaultAction?: 'block' | 'mute'; defaultAction?: 'block' | 'mute';
withBio?: boolean; withBio?: boolean;
withMenu?: boolean; withMenu?: boolean;
withBorder?: boolean;
extraAccountInfo?: React.ReactNode; extraAccountInfo?: React.ReactNode;
children?: React.ReactNode; children?: React.ReactNode;
} }
@ -85,6 +86,7 @@ export const Account: React.FC<AccountProps> = ({
defaultAction, defaultAction,
withBio, withBio,
withMenu = true, withMenu = true,
withBorder = true,
extraAccountInfo, extraAccountInfo,
children, children,
}) => { }) => {
@ -290,6 +292,7 @@ export const Account: React.FC<AccountProps> = ({
<div <div
className={classNames('account', { className={classNames('account', {
'account--minimal': minimal, 'account--minimal': minimal,
'account--without-border': !withBorder,
})} })}
> >
<div <div

View File

@ -138,6 +138,8 @@ export const FollowButton: React.FC<{
: messages.follow; : messages.follow;
let label; let label;
let disabled =
relationship?.blocked_by || account?.suspended || !!account?.moved;
if (!signedIn) { if (!signedIn) {
label = intl.formatMessage(followMessage); label = intl.formatMessage(followMessage);
@ -147,12 +149,16 @@ export const FollowButton: React.FC<{
label = <LoadingIndicator />; label = <LoadingIndicator />;
} else if (relationship.muting && withUnmute) { } else if (relationship.muting && withUnmute) {
label = intl.formatMessage(messages.unmute); label = intl.formatMessage(messages.unmute);
disabled = false;
} else if (relationship.following) { } else if (relationship.following) {
label = intl.formatMessage(messages.unfollow); label = intl.formatMessage(messages.unfollow);
disabled = false;
} else if (relationship.blocking) { } else if (relationship.blocking) {
label = intl.formatMessage(messages.unblock); label = intl.formatMessage(messages.unblock);
disabled = false;
} else if (relationship.requested) { } else if (relationship.requested) {
label = intl.formatMessage(messages.followRequestCancel); label = intl.formatMessage(messages.followRequestCancel);
disabled = false;
} else if (relationship.followed_by && !account?.locked) { } else if (relationship.followed_by && !account?.locked) {
label = intl.formatMessage(messages.followBack); label = intl.formatMessage(messages.followBack);
} else { } else {
@ -187,11 +193,7 @@ export const FollowButton: React.FC<{
return ( return (
<Button <Button
onClick={handleClick} onClick={handleClick}
disabled={ disabled={disabled}
relationship?.blocked_by ||
(!(relationship?.following || relationship?.requested) &&
(account?.suspended || !!account?.moved))
}
secondary={following || relationship?.blocking} secondary={following || relationship?.blocking}
compact={compact} compact={compact}
className={classNames(className, { 'button--destructive': following })} className={classNames(className, { 'button--destructive': following })}

View File

@ -10,6 +10,7 @@ export {
type ComboboxItemState, type ComboboxItemState,
} from './combobox_field'; } from './combobox_field';
export { CopyLinkField } from './copy_link_field'; export { CopyLinkField } from './copy_link_field';
export { EmojiTextInputField, EmojiTextAreaField } from './emoji_text_field';
export { RadioButtonField, RadioButton } from './radio_button_field'; export { RadioButtonField, RadioButton } from './radio_button_field';
export { ToggleField, Toggle } from './toggle_field'; export { ToggleField, Toggle } from './toggle_field';
export { SelectField, Select } from './select_field'; export { SelectField, Select } from './select_field';

View File

@ -36,7 +36,7 @@ export const ItemList = forwardRef<
emptyMessage?: React.ReactNode; emptyMessage?: React.ReactNode;
} }
>(({ isLoading, emptyMessage, className, children, ...otherProps }, ref) => { >(({ isLoading, emptyMessage, className, children, ...otherProps }, ref) => {
if (Children.count(children) === 0 && emptyMessage) { if (!isLoading && Children.count(children) === 0 && emptyMessage) {
return <div className='empty-column-indicator'>{emptyMessage}</div>; return <div className='empty-column-indicator'>{emptyMessage}</div>;
} }

View File

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

View File

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

View File

@ -0,0 +1,37 @@
import type { FC } from 'react';
import { useCallback } from 'react';
import { openModal } from '@/mastodon/actions/modal';
import { useAppDispatch } from '@/mastodon/store';
import { EditButton, DeleteIconButton } from './edit_button';
export const AccountFieldActions: FC<{ item: string; id: string }> = ({
item,
id,
}) => {
const dispatch = useAppDispatch();
const handleEdit = useCallback(() => {
dispatch(
openModal({
modalType: 'ACCOUNT_EDIT_FIELD_EDIT',
modalProps: { fieldKey: id },
}),
);
}, [dispatch, id]);
const handleDelete = useCallback(() => {
dispatch(
openModal({
modalType: 'ACCOUNT_EDIT_FIELD_DELETE',
modalProps: { fieldKey: id },
}),
);
}, [dispatch, id]);
return (
<>
<EditButton item={item} edit onClick={handleEdit} />
<DeleteIconButton item={item} onClick={handleDelete} />
</>
);
};

View File

@ -9,6 +9,7 @@ import type { ModalType } from '@/mastodon/actions/modal';
import { openModal } from '@/mastodon/actions/modal'; import { openModal } from '@/mastodon/actions/modal';
import { Avatar } from '@/mastodon/components/avatar'; import { Avatar } from '@/mastodon/components/avatar';
import { Button } from '@/mastodon/components/button'; import { Button } from '@/mastodon/components/button';
import { DismissibleCallout } from '@/mastodon/components/callout/dismissible';
import { CustomEmojiProvider } from '@/mastodon/components/emoji/context'; import { CustomEmojiProvider } from '@/mastodon/components/emoji/context';
import { EmojiHTML } from '@/mastodon/components/emoji/html'; import { EmojiHTML } from '@/mastodon/components/emoji/html';
import { useElementHandledLink } from '@/mastodon/components/status/handled_link'; import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
@ -20,6 +21,7 @@ import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { AccountEditColumn, AccountEditEmptyColumn } from './components/column'; import { AccountEditColumn, AccountEditEmptyColumn } from './components/column';
import { EditButton } from './components/edit_button'; import { EditButton } from './components/edit_button';
import { AccountFieldActions } from './components/field_actions';
import { AccountEditSection } from './components/section'; import { AccountEditSection } from './components/section';
import classes from './styles.module.scss'; import classes from './styles.module.scss';
@ -54,6 +56,14 @@ export const messages = defineMessages({
defaultMessage: defaultMessage:
'Add your pronouns, external links, or anything else youd like to share.', 'Add your pronouns, external links, or anything else youd like to share.',
}, },
customFieldsName: {
id: 'account_edit.custom_fields.name',
defaultMessage: 'field',
},
customFieldsTipTitle: {
id: 'account_edit.custom_fields.tip_title',
defaultMessage: 'Tip: Adding verified links',
},
featuredHashtagsTitle: { featuredHashtagsTitle: {
id: 'account_edit.featured_hashtags.title', id: 'account_edit.featured_hashtags.title',
defaultMessage: 'Featured hashtags', defaultMessage: 'Featured hashtags',
@ -101,6 +111,9 @@ export const AccountEdit: FC = () => {
const handleBioEdit = useCallback(() => { const handleBioEdit = useCallback(() => {
handleOpenModal('ACCOUNT_EDIT_BIO'); handleOpenModal('ACCOUNT_EDIT_BIO');
}, [handleOpenModal]); }, [handleOpenModal]);
const handleCustomFieldsVerifiedHelp = useCallback(() => {
handleOpenModal('ACCOUNT_EDIT_VERIFY_LINKS');
}, [handleOpenModal]);
const handleProfileDisplayEdit = useCallback(() => { const handleProfileDisplayEdit = useCallback(() => {
handleOpenModal('ACCOUNT_EDIT_PROFILE_DISPLAY'); handleOpenModal('ACCOUNT_EDIT_PROFILE_DISPLAY');
}, [handleOpenModal]); }, [handleOpenModal]);
@ -123,6 +136,7 @@ export const AccountEdit: FC = () => {
const headerSrc = autoPlayGif ? profile.header : profile.headerStatic; const headerSrc = autoPlayGif ? profile.header : profile.headerStatic;
const hasName = !!profile.displayName; const hasName = !!profile.displayName;
const hasBio = !!profile.bio; const hasBio = !!profile.bio;
const hasFields = profile.fields.length > 0;
const hasTags = profile.featuredTags.length > 0; const hasTags = profile.featuredTags.length > 0;
return ( return (
@ -171,8 +185,48 @@ export const AccountEdit: FC = () => {
<AccountEditSection <AccountEditSection
title={messages.customFieldsTitle} title={messages.customFieldsTitle}
description={messages.customFieldsPlaceholder} description={messages.customFieldsPlaceholder}
showDescription showDescription={!hasFields}
/> >
<ol>
{profile.fields.map((field) => (
<li key={field.id} className={classes.field}>
<div>
<EmojiHTML
htmlString={field.name}
className={classes.fieldName}
{...htmlHandlers}
/>
<EmojiHTML htmlString={field.value} {...htmlHandlers} />
</div>
<AccountFieldActions
item={intl.formatMessage(messages.customFieldsName)}
id={field.id}
/>
</li>
))}
</ol>
<Button
onClick={handleCustomFieldsVerifiedHelp}
className={classes.verifiedLinkHelpButton}
plain
>
<FormattedMessage
id='account_edit.custom_fields.verified_hint'
defaultMessage='How do I add a verified link?'
/>
</Button>
{!hasFields && (
<DismissibleCallout
id='profile_edit_fields_tip'
title={intl.formatMessage(messages.customFieldsTipTitle)}
>
<FormattedMessage
id='account_edit.custom_fields.tip_content'
defaultMessage='You can easily add credibility to your Mastodon account by verifying links to any websites you own.'
/>
</DismissibleCallout>
)}
</AccountEditSection>
<AccountEditSection <AccountEditSection
title={messages.featuredHashtagsTitle} title={messages.featuredHashtagsTitle}

View File

@ -1,20 +1,14 @@
import { useCallback, useId, useRef, useState } from 'react'; import { useCallback, useId, useState } from 'react';
import type { ChangeEventHandler, FC } from 'react'; import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { TextArea } from '@/mastodon/components/form_fields'; import { EmojiTextAreaField } from '@/mastodon/components/form_fields';
import { insertEmojiAtPosition } from '@/mastodon/features/emoji/utils';
import type { BaseConfirmationModalProps } from '@/mastodon/features/ui/components/confirmation_modals'; import type { BaseConfirmationModalProps } from '@/mastodon/features/ui/components/confirmation_modals';
import { ConfirmationModal } from '@/mastodon/features/ui/components/confirmation_modals'; import { ConfirmationModal } from '@/mastodon/features/ui/components/confirmation_modals';
import { patchProfile } from '@/mastodon/reducers/slices/profile_edit'; import { patchProfile } from '@/mastodon/reducers/slices/profile_edit';
import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import classes from '../styles.module.scss';
import { CharCounter } from './char_counter';
import { EmojiPicker } from './emoji_picker';
const messages = defineMessages({ const messages = defineMessages({
addTitle: { addTitle: {
id: 'account_edit.bio_modal.add_title', id: 'account_edit.bio_modal.add_title',
@ -30,30 +24,23 @@ const messages = defineMessages({
}, },
}); });
const MAX_BIO_LENGTH = 500;
export const BioModal: FC<BaseConfirmationModalProps> = ({ onClose }) => { export const BioModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
const intl = useIntl(); const intl = useIntl();
const titleId = useId(); const titleId = useId();
const counterId = useId();
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const { profile: { bio } = {}, isPending } = useAppSelector( const { profile: { bio } = {}, isPending } = useAppSelector(
(state) => state.profileEdit, (state) => state.profileEdit,
); );
const [newBio, setNewBio] = useState(bio ?? ''); const [newBio, setNewBio] = useState(bio ?? '');
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback( const maxLength = useAppSelector(
(event) => { (state) =>
setNewBio(event.currentTarget.value); state.server.getIn([
}, 'server',
[], 'configuration',
'accounts',
'max_note_length',
]) as number | undefined,
); );
const handlePickEmoji = useCallback((emoji: string) => {
setNewBio((prev) => {
const position = textAreaRef.current?.selectionStart ?? prev.length;
return insertEmojiAtPosition(prev, emoji, position);
});
}, []);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
@ -70,27 +57,18 @@ export const BioModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
onConfirm={handleSave} onConfirm={handleSave}
onClose={onClose} onClose={onClose}
updating={isPending} updating={isPending}
disabled={newBio.length > MAX_BIO_LENGTH} disabled={!!maxLength && newBio.length > maxLength}
noFocusButton noFocusButton
> >
<div className={classes.inputWrapper}> <EmojiTextAreaField
<TextArea label=''
value={newBio} value={newBio}
ref={textAreaRef} onChange={setNewBio}
onChange={handleChange} aria-labelledby={titleId}
className={classes.inputText} maxLength={maxLength}
aria-labelledby={titleId} // eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
aria-describedby={counterId} autoFocus
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine. autoSize
autoFocus
autoSize
/>
<EmojiPicker onPick={handlePickEmoji} />
</div>
<CharCounter
currentLength={newBio.length}
maxLength={MAX_BIO_LENGTH}
id={counterId}
/> />
</ConfirmationModal> </ConfirmationModal>
); );

View File

@ -0,0 +1,175 @@
import { useCallback, useState } from 'react';
import type { FC } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import type { Map as ImmutableMap } from 'immutable';
import { Button } from '@/mastodon/components/button';
import { EmojiTextInputField } from '@/mastodon/components/form_fields';
import {
removeField,
selectFieldById,
updateField,
} from '@/mastodon/reducers/slices/profile_edit';
import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from '@/mastodon/store';
import { ConfirmationModal } from '../../ui/components/confirmation_modals';
import type { DialogModalProps } from '../../ui/components/dialog_modal';
import { DialogModal } from '../../ui/components/dialog_modal';
import classes from './styles.module.scss';
const messages = defineMessages({
editTitle: {
id: 'account_edit.field_edit_modal.edit_title',
defaultMessage: 'Edit custom field',
},
addTitle: {
id: 'account_edit.field_edit_modal.add_title',
defaultMessage: 'Add custom field',
},
editLabelField: {
id: 'account_edit.field_edit_modal.name_label',
defaultMessage: 'Label',
},
editLabelHint: {
id: 'account_edit.field_edit_modal.name_hint',
defaultMessage: 'E.g. “Personal website”',
},
editValueField: {
id: 'account_edit.field_edit_modal.value_label',
defaultMessage: 'Value',
},
editValueHint: {
id: 'account_edit.field_edit_modal.value_hint',
defaultMessage: 'E.g. “example.me”',
},
save: {
id: 'account_edit.save',
defaultMessage: 'Save',
},
});
const selectFieldLimits = createAppSelector(
[
(state) =>
state.server.getIn(['server', 'configuration', 'accounts']) as
| ImmutableMap<string, number>
| undefined,
],
(accounts) => ({
nameLimit: accounts?.get('profile_field_name_limit'),
valueLimit: accounts?.get('profile_field_value_limit'),
}),
);
export const EditFieldModal: FC<DialogModalProps & { fieldKey?: string }> = ({
onClose,
fieldKey,
}) => {
const intl = useIntl();
const field = useAppSelector((state) => selectFieldById(state, fieldKey));
const [newLabel, setNewLabel] = useState(field?.name ?? '');
const [newValue, setNewValue] = useState(field?.value ?? '');
const { nameLimit, valueLimit } = useAppSelector(selectFieldLimits);
const isPending = useAppSelector((state) => state.profileEdit.isPending);
const disabled =
!nameLimit ||
!valueLimit ||
newLabel.length > nameLimit ||
newValue.length > valueLimit;
const dispatch = useAppDispatch();
const handleSave = useCallback(() => {
if (disabled || isPending) {
return;
}
void dispatch(
updateField({ id: fieldKey, name: newLabel, value: newValue }),
).then(onClose);
}, [disabled, dispatch, fieldKey, isPending, newLabel, newValue, onClose]);
return (
<ConfirmationModal
onClose={onClose}
title={
field
? intl.formatMessage(messages.editTitle)
: intl.formatMessage(messages.addTitle)
}
confirm={intl.formatMessage(messages.save)}
onConfirm={handleSave}
updating={isPending}
disabled={disabled}
className={classes.wrapper}
>
<EmojiTextInputField
value={newLabel}
onChange={setNewLabel}
label={intl.formatMessage(messages.editLabelField)}
hint={intl.formatMessage(messages.editLabelHint)}
maxLength={nameLimit}
/>
<EmojiTextInputField
value={newValue}
onChange={setNewValue}
label={intl.formatMessage(messages.editValueField)}
hint={intl.formatMessage(messages.editValueHint)}
maxLength={valueLimit}
/>
</ConfirmationModal>
);
};
export const DeleteFieldModal: FC<DialogModalProps & { fieldKey: string }> = ({
onClose,
fieldKey,
}) => {
const isPending = useAppSelector((state) => state.profileEdit.isPending);
const dispatch = useAppDispatch();
const handleDelete = useCallback(() => {
void dispatch(removeField({ key: fieldKey })).then(onClose);
}, [dispatch, fieldKey, onClose]);
return (
<DialogModal
onClose={onClose}
title={
<FormattedMessage
id='account_edit.field_delete_modal.title'
defaultMessage='Delete custom field?'
/>
}
buttons={
<Button dangerous onClick={handleDelete} disabled={isPending}>
<FormattedMessage
id='account_edit.field_delete_modal.delete_button'
defaultMessage='Delete'
/>
</Button>
}
>
<FormattedMessage
id='account_edit.field_delete_modal.confirm'
defaultMessage='Are you sure you want to delete this custom field? This action cant be undone.'
tagName='p'
/>
</DialogModal>
);
};
export const RearrangeFieldsModal: FC<DialogModalProps> = ({ onClose }) => {
return (
<DialogModal onClose={onClose} title='Not implemented yet'>
<p>Not implemented yet</p>
</DialogModal>
);
};

View File

@ -0,0 +1,5 @@
export * from './bio_modal';
export * from './fields_modals';
export * from './name_modal';
export * from './profile_display_modal';
export * from './verified_modal';

View File

@ -1,20 +1,14 @@
import { useCallback, useId, useRef, useState } from 'react'; import { useCallback, useId, useState } from 'react';
import type { ChangeEventHandler, FC } from 'react'; import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { TextInput } from '@/mastodon/components/form_fields'; import { EmojiTextInputField } from '@/mastodon/components/form_fields';
import { insertEmojiAtPosition } from '@/mastodon/features/emoji/utils';
import type { BaseConfirmationModalProps } from '@/mastodon/features/ui/components/confirmation_modals'; import type { BaseConfirmationModalProps } from '@/mastodon/features/ui/components/confirmation_modals';
import { ConfirmationModal } from '@/mastodon/features/ui/components/confirmation_modals'; import { ConfirmationModal } from '@/mastodon/features/ui/components/confirmation_modals';
import { patchProfile } from '@/mastodon/reducers/slices/profile_edit'; import { patchProfile } from '@/mastodon/reducers/slices/profile_edit';
import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import classes from '../styles.module.scss';
import { CharCounter } from './char_counter';
import { EmojiPicker } from './emoji_picker';
const messages = defineMessages({ const messages = defineMessages({
addTitle: { addTitle: {
id: 'account_edit.name_modal.add_title', id: 'account_edit.name_modal.add_title',
@ -30,30 +24,24 @@ const messages = defineMessages({
}, },
}); });
const MAX_NAME_LENGTH = 30;
export const NameModal: FC<BaseConfirmationModalProps> = ({ onClose }) => { export const NameModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
const intl = useIntl(); const intl = useIntl();
const titleId = useId(); const titleId = useId();
const counterId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const { profile: { displayName } = {}, isPending } = useAppSelector( const { profile: { displayName } = {}, isPending } = useAppSelector(
(state) => state.profileEdit, (state) => state.profileEdit,
); );
const [newName, setNewName] = useState(displayName ?? ''); const maxLength = useAppSelector(
const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback( (state) =>
(event) => { state.server.getIn([
setNewName(event.currentTarget.value); 'server',
}, 'configuration',
[], 'accounts',
'max_display_name_length',
]) as number | undefined,
); );
const handlePickEmoji = useCallback((emoji: string) => {
setNewName((prev) => { const [newName, setNewName] = useState(displayName ?? '');
const position = inputRef.current?.selectionStart ?? prev.length;
return insertEmojiAtPosition(prev, emoji, position);
});
}, []);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
@ -70,27 +58,18 @@ export const NameModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
onConfirm={handleSave} onConfirm={handleSave}
onClose={onClose} onClose={onClose}
updating={isPending} updating={isPending}
disabled={newName.length > MAX_NAME_LENGTH} disabled={!!maxLength && newName.length > maxLength}
noCloseOnConfirm noCloseOnConfirm
noFocusButton noFocusButton
> >
<div className={classes.inputWrapper}> <EmojiTextInputField
<TextInput value={newName}
value={newName} onChange={setNewName}
ref={inputRef} aria-labelledby={titleId}
onChange={handleChange} maxLength={maxLength}
className={classes.inputText} label=''
aria-labelledby={titleId} // eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
aria-describedby={counterId} autoFocus
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
autoFocus
/>
<EmojiPicker onPick={handlePickEmoji} />
</div>
<CharCounter
currentLength={newName.length}
maxLength={MAX_NAME_LENGTH}
id={counterId}
/> />
</ConfirmationModal> </ConfirmationModal>
); );

View File

@ -12,7 +12,8 @@ import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import type { DialogModalProps } from '../../ui/components/dialog_modal'; import type { DialogModalProps } from '../../ui/components/dialog_modal';
import { DialogModal } from '../../ui/components/dialog_modal'; import { DialogModal } from '../../ui/components/dialog_modal';
import { messages } from '../index'; import { messages } from '../index';
import classes from '../styles.module.scss';
import classes from './styles.module.scss';
export const ProfileDisplayModal: FC<DialogModalProps> = ({ onClose }) => { export const ProfileDisplayModal: FC<DialogModalProps> = ({ onClose }) => {
const intl = useIntl(); const intl = useIntl();

View File

@ -0,0 +1,70 @@
.wrapper {
display: flex;
gap: 16px;
flex-direction: column;
}
.toggleInputWrapper {
> div {
padding: 12px 0;
&:not(:first-child) {
border-top: 1px solid var(--color-border-primary);
}
}
}
.verifiedSteps {
font-size: 15px;
li {
counter-increment: steps;
padding-left: 34px;
margin-top: 24px;
position: relative;
h2 {
font-weight: 600;
}
&::before {
content: counter(steps);
position: absolute;
left: 0;
border: 1px solid var(--color-border-primary);
border-radius: 9999px;
font-weight: 600;
padding: 4px;
width: 16px;
height: 16px;
line-height: 16px;
text-align: center;
}
}
}
.details {
color: var(--color-text-secondary);
font-size: 13px;
margin-top: 8px;
summary {
cursor: pointer;
font-weight: 600;
list-style: none;
margin-bottom: 8px;
text-decoration: underline;
text-decoration-style: dotted;
}
:global(.icon) {
width: 1.4em;
height: 1.4em;
vertical-align: middle;
transition: transform 0.2s ease-in-out;
}
&[open] :global(.icon) {
transform: rotate(-180deg);
}
}

View File

@ -0,0 +1,85 @@
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { CopyLinkField } from '@/mastodon/components/form_fields/copy_link_field';
import { Icon } from '@/mastodon/components/icon';
import { createAppSelector, useAppSelector } from '@/mastodon/store';
import ExpandArrowIcon from '@/material-icons/400-24px/expand_more.svg?react';
import type { DialogModalProps } from '../../ui/components/dialog_modal';
import { DialogModal } from '../../ui/components/dialog_modal';
import classes from './styles.module.scss';
const selectAccountUrl = createAppSelector(
[(state) => state.meta.get('me') as string, (state) => state.accounts],
(accountId, accounts) => {
const account = accounts.get(accountId);
return account?.get('url') ?? '';
},
);
export const VerifiedModal: FC<DialogModalProps> = ({ onClose }) => {
const accountUrl = useAppSelector(selectAccountUrl);
return (
<DialogModal
onClose={onClose}
title={
<FormattedMessage
id='account_edit.verified_modal.title'
defaultMessage='How to add a verified link'
/>
}
noCancelButton
>
<FormattedMessage
id='account_edit.verified_modal.details'
defaultMessage='Add credibility to your Mastodon profile by verifying links to personal websites. Heres how it works:'
tagName='p'
/>
<ol className={classes.verifiedSteps}>
<li>
<CopyLinkField
label={
<FormattedMessage
id='account_edit.verified_modal.step1.header'
defaultMessage='Copy the HTML code below and paste into the header of your website'
tagName='h2'
/>
}
value={`<a rel="me" href="${accountUrl}">Mastodon</a>`}
/>
<details className={classes.details}>
<summary>
<FormattedMessage
id='account_edit.verified_modal.invisible_link.summary'
defaultMessage='How do I make the link invisible?'
/>
<Icon icon={ExpandArrowIcon} id='arrow' />
</summary>
<FormattedMessage
id='account_edit.verified_modal.invisible_link.details'
defaultMessage='Add the link to your header. The important part is rel="me" which prevents impersonation on websites with user-generated content. You can even use a link tag in the header of the page instead of {tag}, but the HTML must be accessible without executing JavaScript.'
values={{ tag: <code>&lt;a&gt;</code> }}
/>
</details>
</li>
<li>
<FormattedMessage
id='account_edit.verified_modal.step2.header'
defaultMessage='Add your website as a custom field'
tagName='h2'
/>
<FormattedMessage
id='account_edit.verified_modal.step2.details'
defaultMessage='If youve already added your website as a custom field, youll need to delete and re-add it to trigger verification.'
tagName='p'
/>
</li>
</ol>
</DialogModal>
);
};

View File

@ -24,6 +24,38 @@
border: 1px solid var(--color-border-primary); border: 1px solid var(--color-border-primary);
} }
.field {
padding: 12px 0;
display: flex;
gap: 4px;
align-items: start;
> div {
flex-grow: 1;
}
}
.fieldName {
color: var(--color-text-secondary);
font-size: 13px;
}
.verifiedLinkHelpButton {
font-size: 13px;
font-weight: 600;
text-decoration: underline;
&:global(.button) {
color: var(--color-text-primary);
&:active,
&:hover,
&:focus {
text-decoration: underline;
}
}
}
// Featured Tags Page // Featured Tags Page
.wrapper { .wrapper {
@ -58,48 +90,6 @@
} }
} }
// Modals
.inputWrapper {
position: relative;
}
// Override input styles
.inputWrapper .inputText {
font-size: 15px;
padding-right: 32px;
}
textarea.inputText {
min-height: 82px;
height: 100%;
// 160px is approx the height of the modal header and footer
max-height: calc(80vh - 160px);
}
.inputWrapper :global(.emoji-picker-dropdown) {
position: absolute;
bottom: 10px;
right: 8px;
height: 24px;
z-index: 1;
:global(.icon-button) {
color: var(--color-text-secondary);
}
}
.toggleInputWrapper {
> div {
padding: 12px 0;
&:not(:first-child) {
border-top: 1px solid var(--color-border-primary);
}
}
}
// Column component // Column component
.column { .column {
@ -195,14 +185,3 @@ textarea.inputText {
.sectionSubtitle { .sectionSubtitle {
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
// Counter component
.counter {
margin-top: 4px;
font-size: 13px;
}
.counterError {
color: var(--color-text-error);
}

View File

@ -82,10 +82,11 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
const { collections, status } = useAppSelector((state) => const { collections, status } = useAppSelector((state) =>
selectAccountCollections(state, accountId ?? null), selectAccountCollections(state, accountId ?? null),
); );
const publicCollections = collections.filter( const listedCollections = collections.filter(
// This filter only applies when viewing your own profile, where the endpoint // Hide unlisted and empty collections to avoid confusion
// returns all collections, but we hide unlisted ones here to avoid confusion // (Unlisted collections will only be part of the payload
(item) => item.discoverable, // when viewing your own profile.)
(item) => item.discoverable && !!item.item_count,
); );
if (accountId === null) { if (accountId === null) {
@ -124,7 +125,7 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
{accountId && ( {accountId && (
<AccountHeader accountId={accountId} hideTabs={forceEmptyState} /> <AccountHeader accountId={accountId} hideTabs={forceEmptyState} />
)} )}
{publicCollections.length > 0 && status === 'idle' && ( {listedCollections.length > 0 && status === 'idle' && (
<> <>
<h4 className='column-subheading'> <h4 className='column-subheading'>
<FormattedMessage <FormattedMessage
@ -133,13 +134,13 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
/> />
</h4> </h4>
<ItemList> <ItemList>
{publicCollections.map((item, index) => ( {listedCollections.map((item, index) => (
<CollectionListItem <CollectionListItem
key={item.id} key={item.id}
collection={item} collection={item}
withoutBorder={index === publicCollections.length - 1} withoutBorder={index === listedCollections.length - 1}
positionInList={index + 1} positionInList={index + 1}
listSize={publicCollections.length} listSize={listedCollections.length}
/> />
))} ))}
</ItemList> </ItemList>

View File

@ -0,0 +1,212 @@
import { Fragment, useCallback, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Button } from '@/mastodon/components/button';
import { useRelationship } from '@/mastodon/hooks/useRelationship';
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
import { Account } from 'mastodon/components/account';
import { DisplayName } from 'mastodon/components/display_name';
import {
Article,
ItemList,
} from 'mastodon/components/scrollable_list/components';
import { useAccount } from 'mastodon/hooks/useAccount';
import { me } from 'mastodon/initial_state';
import classes from './styles.module.scss';
const messages = defineMessages({
empty: {
id: 'collections.accounts.empty_title',
defaultMessage: 'This collection is empty',
},
accounts: {
id: 'collections.detail.accounts_heading',
defaultMessage: 'Accounts',
},
});
const SimpleAuthorName: React.FC<{ id: string }> = ({ id }) => {
const account = useAccount(id);
return <DisplayName account={account} variant='simple' />;
};
const AccountItem: React.FC<{
accountId: string | undefined;
collectionOwnerId: string;
withBorder?: boolean;
}> = ({ accountId, withBorder = true, 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}
withBorder={withBorder}
id={accountId}
/>
);
};
const SensitiveScreen: React.FC<{
sensitive: boolean | undefined;
focusTargetRef: React.RefObject<HTMLHeadingElement>;
children: React.ReactNode;
}> = ({ sensitive, focusTargetRef, children }) => {
const [isVisible, setIsVisible] = useState(!sensitive);
const showAnyway = useCallback(() => {
setIsVisible(true);
setTimeout(() => {
focusTargetRef.current?.focus();
}, 0);
}, [focusTargetRef]);
if (isVisible) {
return children;
}
return (
<div className={classes.sensitiveWarning}>
<FormattedMessage
id='collections.detail.sensitive_note'
defaultMessage='This collection contains accounts and content that may be sensitive to some users.'
tagName='p'
/>
<Button onClick={showAnyway}>
<FormattedMessage
id='content_warning.show'
defaultMessage='Show anyway'
tagName={Fragment}
/>
</Button>
</div>
);
};
/**
* Returns the collection's account items. If the current user's account
* is part of the collection, it will be returned separately.
*/
function getCollectionItems(collection: ApiCollectionJSON | undefined) {
if (!collection)
return {
currentUserInCollection: null,
items: [],
};
const { account_id, items } = collection;
const isOwnCollection = account_id === me;
const currentUserIndex = items.findIndex(
(account) => account.account_id === me,
);
if (isOwnCollection || currentUserIndex === -1) {
return {
currentUserInCollection: null,
items,
};
} else {
return {
currentUserInCollection: items.at(currentUserIndex) ?? null,
items: items.toSpliced(currentUserIndex, 1),
};
}
}
export const CollectionAccountsList: React.FC<{
collection?: ApiCollectionJSON;
isLoading: boolean;
}> = ({ collection, isLoading }) => {
const intl = useIntl();
const listHeadingRef = useRef<HTMLHeadingElement>(null);
const isOwnCollection = collection?.account_id === me;
const { items, currentUserInCollection } = getCollectionItems(collection);
return (
<ItemList
isLoading={isLoading}
emptyMessage={intl.formatMessage(messages.empty)}
>
{collection && currentUserInCollection ? (
<>
<h3 className={classes.columnSubheading}>
<FormattedMessage
id='collections.detail.author_added_you'
defaultMessage='{author} added you to this collection'
values={{
author: <SimpleAuthorName id={collection.account_id} />,
}}
tagName={Fragment}
/>
</h3>
<Article
key={currentUserInCollection.account_id}
aria-posinset={1}
aria-setsize={items.length}
>
<AccountItem
withBorder={false}
accountId={currentUserInCollection.account_id}
collectionOwnerId={collection.account_id}
/>
</Article>
<h3
className={classes.columnSubheading}
tabIndex={-1}
ref={listHeadingRef}
>
<FormattedMessage
id='collections.detail.other_accounts_in_collection'
defaultMessage='Others in this collection:'
tagName={Fragment}
/>
</h3>
</>
) : (
<h3
className='column-subheading sr-only'
tabIndex={-1}
ref={listHeadingRef}
>
{intl.formatMessage(messages.accounts)}
</h3>
)}
{collection && (
<SensitiveScreen
sensitive={!isOwnCollection && collection.sensitive}
focusTargetRef={listHeadingRef}
>
{items.map(({ account_id }, index, items) => (
<Article
key={account_id}
aria-posinset={index + (currentUserInCollection ? 2 : 1)}
aria-setsize={items.length}
>
<AccountItem
accountId={account_id}
collectionOwnerId={collection.account_id}
/>
</Article>
))}
</SensitiveScreen>
)}
</ItemList>
);
};

View File

@ -6,11 +6,9 @@ import { Helmet } from 'react-helmet';
import { useLocation, useParams } from 'react-router'; import { useLocation, useParams } from 'react-router';
import { openModal } from '@/mastodon/actions/modal'; import { openModal } from '@/mastodon/actions/modal';
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 type { ApiCollectionJSON } from 'mastodon/api_types/collections'; import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
import { Account } from 'mastodon/components/account';
import { Avatar } from 'mastodon/components/avatar'; import { Avatar } from 'mastodon/components/avatar';
import { Column } from 'mastodon/components/column'; import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header'; import { ColumnHeader } from 'mastodon/components/column_header';
@ -19,26 +17,19 @@ import {
LinkedDisplayName, LinkedDisplayName,
} from 'mastodon/components/display_name'; } from 'mastodon/components/display_name';
import { IconButton } from 'mastodon/components/icon_button'; import { IconButton } from 'mastodon/components/icon_button';
import { import { Scrollable } from 'mastodon/components/scrollable_list/components';
Article,
ItemList,
Scrollable,
} from 'mastodon/components/scrollable_list/components';
import { Tag } from 'mastodon/components/tags/tag'; import { Tag } from 'mastodon/components/tags/tag';
import { useAccount } from 'mastodon/hooks/useAccount'; import { useAccount } from 'mastodon/hooks/useAccount';
import { me } from 'mastodon/initial_state'; import { me } from 'mastodon/initial_state';
import { fetchCollection } from 'mastodon/reducers/slices/collections'; import { fetchCollection } from 'mastodon/reducers/slices/collections';
import { useAppDispatch, useAppSelector } from 'mastodon/store'; import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { CollectionAccountsList } from './collection_list';
import { CollectionMetaData } from './collection_list_item'; import { CollectionMetaData } from './collection_list_item';
import { CollectionMenu } from './collection_menu'; import { CollectionMenu } from './collection_menu';
import classes from './styles.module.scss'; import classes from './styles.module.scss';
const messages = defineMessages({ const messages = defineMessages({
empty: {
id: 'collections.accounts.empty_title',
defaultMessage: 'This collection is empty',
},
loading: { loading: {
id: 'collections.detail.loading', id: 'collections.detail.loading',
defaultMessage: 'Loading collection…', defaultMessage: 'Loading collection…',
@ -47,10 +38,6 @@ const messages = defineMessages({
id: 'collections.detail.share', id: 'collections.detail.share',
defaultMessage: 'Share this collection', defaultMessage: 'Share this collection',
}, },
accounts: {
id: 'collections.detail.accounts_heading',
defaultMessage: 'Accounts',
},
}); });
export const AuthorNote: React.FC<{ id: string; previewMode?: boolean }> = ({ export const AuthorNote: React.FC<{ id: string; previewMode?: boolean }> = ({
@ -149,33 +136,10 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
collection={collection} collection={collection}
className={classes.metaData} className={classes.metaData}
/> />
<h2 className='sr-only'>{intl.formatMessage(messages.accounts)}</h2>
</div> </div>
); );
}; };
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 }) => {
@ -185,7 +149,6 @@ export const CollectionDetailPage: React.FC<{
const collection = useAppSelector((state) => const collection = useAppSelector((state) =>
id ? state.collections.collections[id] : undefined, id ? state.collections.collections[id] : undefined,
); );
const isLoading = !!id && !collection; const isLoading = !!id && !collection;
useEffect(() => { useEffect(() => {
@ -208,24 +171,7 @@ export const CollectionDetailPage: React.FC<{
<Scrollable> <Scrollable>
{collection && <CollectionHeader collection={collection} />} {collection && <CollectionHeader collection={collection} />}
<ItemList <CollectionAccountsList collection={collection} isLoading={isLoading} />
isLoading={isLoading}
emptyMessage={intl.formatMessage(messages.empty)}
>
{collection?.items.map(({ account_id }, index, items) => (
<Article
key={account_id}
data-id={account_id}
aria-posinset={index + 1}
aria-setsize={items.length}
>
<CollectionAccountItem
accountId={account_id}
collectionOwnerId={collection.account_id}
/>
</Article>
))}
</ItemList>
</Scrollable> </Scrollable>
<Helmet> <Helmet>

View File

@ -57,6 +57,18 @@
font-size: 15px; font-size: 15px;
} }
.columnSubheading {
background: var(--color-bg-secondary);
padding: 15px 20px;
font-size: 15px;
font-weight: 500;
&:focus-visible {
outline: var(--outline-focus-default);
outline-offset: -2px;
}
}
.displayNameWithAvatar { .displayNameWithAvatar {
display: inline-flex; display: inline-flex;
gap: 4px; gap: 4px;
@ -76,3 +88,18 @@
align-self: center; align-self: center;
} }
} }
.sensitiveWarning {
display: flex;
flex-direction: column;
align-items: center;
max-width: 460px;
margin: auto;
padding: 60px 30px;
gap: 20px;
text-align: center;
text-wrap: balance;
font-size: 15px;
line-height: 1.5;
cursor: default;
}

View File

@ -15,7 +15,6 @@ import { Account } from 'mastodon/components/account';
import { Avatar } from 'mastodon/components/avatar'; import { Avatar } from 'mastodon/components/avatar';
import { Badge } from 'mastodon/components/badge'; import { Badge } from 'mastodon/components/badge';
import { Button } from 'mastodon/components/button'; import { Button } from 'mastodon/components/button';
import { Callout } from 'mastodon/components/callout';
import { DisplayName } from 'mastodon/components/display_name'; import { DisplayName } from 'mastodon/components/display_name';
import { EmptyState } from 'mastodon/components/empty_state'; import { EmptyState } from 'mastodon/components/empty_state';
import { FormStack, Combobox } from 'mastodon/components/form_fields'; import { FormStack, Combobox } from 'mastodon/components/form_fields';
@ -40,7 +39,6 @@ import { getCollectionEditorState } from './state';
import classes from './styles.module.scss'; import classes from './styles.module.scss';
import { WizardStepHeader } from './wizard_step_header'; import { WizardStepHeader } from './wizard_step_header';
const MIN_ACCOUNT_COUNT = 1;
const MAX_ACCOUNT_COUNT = 25; const MAX_ACCOUNT_COUNT = 25;
function isOlderThanAWeek(date?: string): boolean { function isOlderThanAWeek(date?: string): boolean {
@ -164,9 +162,6 @@ export const CollectionAccounts: React.FC<{
); );
const hasMaxAccounts = accountIds.length === MAX_ACCOUNT_COUNT; const hasMaxAccounts = accountIds.length === MAX_ACCOUNT_COUNT;
const hasMinAccounts = accountIds.length === MIN_ACCOUNT_COUNT;
const hasTooFewAccounts = accountIds.length < MIN_ACCOUNT_COUNT;
const canSubmit = !hasTooFewAccounts;
const { const {
accountIds: suggestedAccountIds, accountIds: suggestedAccountIds,
@ -319,17 +314,13 @@ export const CollectionAccounts: React.FC<{
(e: React.FormEvent) => { (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!canSubmit) {
return;
}
if (!id) { if (!id) {
history.push(`/collections/new/details`, { history.push(`/collections/new/details`, {
account_ids: accountIds, account_ids: accountIds,
}); });
} }
}, },
[canSubmit, id, history, accountIds], [id, history, accountIds],
); );
const inputId = useId(); const inputId = useId();
@ -384,16 +375,6 @@ export const CollectionAccounts: React.FC<{
/> />
)} )}
{hasMinAccounts && (
<Callout>
<FormattedMessage
id='collections.hints.can_not_remove_more_accounts'
defaultMessage='Collections must contain at least {count, plural, one {# account} other {# accounts}}. Removing more accounts is not possible.'
values={{ count: MIN_ACCOUNT_COUNT }}
/>
</Callout>
)}
<Scrollable className={classes.scrollableWrapper}> <Scrollable className={classes.scrollableWrapper}>
<ItemList <ItemList
className={classes.scrollableInner} className={classes.scrollableInner}
@ -425,7 +406,7 @@ export const CollectionAccounts: React.FC<{
> >
<AddedAccountItem <AddedAccountItem
accountId={accountId} accountId={accountId}
isRemovable={!isEditMode || !hasMinAccounts} isRemovable={!isEditMode}
onRemove={handleRemoveAccountItem} onRemove={handleRemoveAccountItem}
/> />
</Article> </Article>
@ -435,39 +416,25 @@ export const CollectionAccounts: React.FC<{
</FormStack> </FormStack>
{!isEditMode && ( {!isEditMode && (
<div className={classes.stickyFooter}> <div className={classes.stickyFooter}>
{hasTooFewAccounts ? ( <div className={classes.actionWrapper}>
<Callout icon={false} className={classes.submitDisabledCallout}> <FormattedMessage
<FormattedMessage id='collections.hints.accounts_counter'
id='collections.hints.add_more_accounts' defaultMessage='{count} / {max} accounts'
defaultMessage='Add at least {count, plural, one {# account} other {# accounts}} to continue' values={{ count: accountIds.length, max: MAX_ACCOUNT_COUNT }}
values={{ count: MIN_ACCOUNT_COUNT }} >
/> {(text) => <div className={classes.itemCountReadout}>{text}</div>}
</Callout> </FormattedMessage>
) : ( <Button type='submit'>
<div className={classes.actionWrapper}> {id ? (
<FormattedMessage <FormattedMessage id='lists.save' defaultMessage='Save' />
id='collections.hints.accounts_counter' ) : (
defaultMessage='{count} / {max} accounts' <FormattedMessage
values={{ count: accountIds.length, max: MAX_ACCOUNT_COUNT }} id='collections.continue'
> defaultMessage='Continue'
{(text) => ( />
<div className={classes.itemCountReadout}>{text}</div>
)}
</FormattedMessage>
{canSubmit && (
<Button type='submit'>
{id ? (
<FormattedMessage id='lists.save' defaultMessage='Save' />
) : (
<FormattedMessage
id='collections.continue'
defaultMessage='Continue'
/>
)}
</Button>
)} )}
</div> </Button>
)} </div>
</div> </div>
)} )}
</form> </form>

View File

@ -6,7 +6,7 @@ import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import AddIcon from '@/material-icons/400-24px/add.svg?react'; import AddIcon from '@/material-icons/400-24px/add.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; import CollectionsFilledIcon from '@/material-icons/400-24px/category-fill.svg?react';
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react'; import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { Column } from 'mastodon/components/column'; import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header'; import { ColumnHeader } from 'mastodon/components/column_header';
@ -73,8 +73,8 @@ export const Collections: React.FC<{
> >
<ColumnHeader <ColumnHeader
title={intl.formatMessage(messages.heading)} title={intl.formatMessage(messages.heading)}
icon='list-ul' icon='collections'
iconComponent={ListAltIcon} iconComponent={CollectionsFilledIcon}
multiColumn={multiColumn} multiColumn={multiColumn}
extraButton={ extraButton={
<Link <Link

View File

@ -14,6 +14,8 @@ import AddIcon from '@/material-icons/400-24px/add.svg?react';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import BookmarksActiveIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react'; import BookmarksActiveIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
import BookmarksIcon from '@/material-icons/400-24px/bookmarks.svg?react'; import BookmarksIcon from '@/material-icons/400-24px/bookmarks.svg?react';
import CollectionsActiveIcon from '@/material-icons/400-24px/category-fill.svg?react';
import CollectionsIcon from '@/material-icons/400-24px/category.svg?react';
import HomeActiveIcon from '@/material-icons/400-24px/home-fill.svg?react'; import HomeActiveIcon from '@/material-icons/400-24px/home-fill.svg?react';
import HomeIcon from '@/material-icons/400-24px/home.svg?react'; import HomeIcon from '@/material-icons/400-24px/home.svg?react';
import InfoIcon from '@/material-icons/400-24px/info.svg?react'; import InfoIcon from '@/material-icons/400-24px/info.svg?react';
@ -48,6 +50,7 @@ import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifica
import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { AnnualReportNavItem } from '../annual_report/nav_item'; import { AnnualReportNavItem } from '../annual_report/nav_item';
import { areCollectionsEnabled } from '../collections/utils';
import { DisabledAccountBanner } from './components/disabled_account_banner'; import { DisabledAccountBanner } from './components/disabled_account_banner';
import { FollowedTagsPanel } from './components/followed_tags_panel'; import { FollowedTagsPanel } from './components/followed_tags_panel';
@ -71,6 +74,10 @@ const messages = defineMessages({
direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' }, direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' }, favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
collections: {
id: 'navigation_bar.collections',
defaultMessage: 'Collections',
},
preferences: { preferences: {
id: 'navigation_bar.preferences', id: 'navigation_bar.preferences',
defaultMessage: 'Preferences', defaultMessage: 'Preferences',
@ -325,6 +332,16 @@ export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({
activeIconComponent={BookmarksActiveIcon} activeIconComponent={BookmarksActiveIcon}
text={intl.formatMessage(messages.bookmarks)} text={intl.formatMessage(messages.bookmarks)}
/> />
{areCollectionsEnabled() && (
<ColumnLink
transparent
to='/collections'
icon='collections'
iconComponent={CollectionsIcon}
activeIconComponent={CollectionsActiveIcon}
text={intl.formatMessage(messages.collections)}
/>
)}
<ColumnLink <ColumnLink
transparent transparent
to='/conversations' to='/conversations'

View File

@ -13,23 +13,26 @@ export interface BaseConfirmationModalProps {
onClose: () => void; onClose: () => void;
} }
interface ConfirmationModalProps {
title: React.ReactNode;
titleId?: string;
message?: React.ReactNode;
confirm: React.ReactNode;
cancel?: React.ReactNode;
secondary?: React.ReactNode;
onSecondary?: () => void;
onConfirm: () => void;
noCloseOnConfirm?: boolean;
extraContent?: React.ReactNode;
children?: React.ReactNode;
className?: string;
updating?: boolean;
disabled?: boolean;
noFocusButton?: boolean;
}
export const ConfirmationModal: React.FC< export const ConfirmationModal: React.FC<
{ ConfirmationModalProps & BaseConfirmationModalProps
title: React.ReactNode;
titleId?: string;
message?: React.ReactNode;
confirm: React.ReactNode;
cancel?: React.ReactNode;
secondary?: React.ReactNode;
onSecondary?: () => void;
onConfirm: () => void;
noCloseOnConfirm?: boolean;
extraContent?: React.ReactNode;
children?: React.ReactNode;
updating?: boolean;
disabled?: boolean;
noFocusButton?: boolean;
} & BaseConfirmationModalProps
> = ({ > = ({
title, title,
titleId, titleId,
@ -42,6 +45,7 @@ export const ConfirmationModal: React.FC<
onSecondary, onSecondary,
extraContent, extraContent,
children, children,
className,
updating, updating,
disabled, disabled,
noCloseOnConfirm = false, noCloseOnConfirm = false,
@ -62,7 +66,7 @@ export const ConfirmationModal: React.FC<
return ( return (
<ModalShell> <ModalShell>
<ModalShellBody> <ModalShellBody className={className}>
<h1 id={titleId}>{title}</h1> <h1 id={titleId}>{title}</h1>
{message && <p>{message}</p>} {message && <p>{message}</p>}

View File

@ -15,11 +15,10 @@ interface DialogModalProps {
title: ReactNode; title: ReactNode;
onClose: () => void; onClose: () => void;
description?: ReactNode; description?: ReactNode;
formClassName?: string; wrapperClassName?: string;
children?: ReactNode; children?: ReactNode;
noCancelButton?: boolean; noCancelButton?: boolean;
onSave?: () => void; buttons?: ReactNode;
saveLabel?: ReactNode;
} }
export const DialogModal: FC<DialogModalProps> = ({ export const DialogModal: FC<DialogModalProps> = ({
@ -27,16 +26,13 @@ export const DialogModal: FC<DialogModalProps> = ({
title, title,
onClose, onClose,
description, description,
formClassName, wrapperClassName,
children, children,
noCancelButton = false, noCancelButton = false,
onSave, buttons,
saveLabel,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const showButtons = !noCancelButton || onSave;
return ( return (
<div className={classNames('modal-root__modal dialog-modal', className)}> <div className={classNames('modal-root__modal dialog-modal', className)}>
<div className='dialog-modal__header'> <div className='dialog-modal__header'>
@ -61,13 +57,16 @@ export const DialogModal: FC<DialogModalProps> = ({
</div> </div>
)} )}
<div <div
className={classNames('dialog-modal__content__form', formClassName)} className={classNames(
'dialog-modal__content__form',
wrapperClassName,
)}
> >
{children} {children}
</div> </div>
</div> </div>
{showButtons && ( {(buttons || !noCancelButton) && (
<div className='dialog-modal__content__actions'> <div className='dialog-modal__content__actions'>
{!noCancelButton && ( {!noCancelButton && (
<Button onClick={onClose} secondary> <Button onClick={onClose} secondary>
@ -77,16 +76,7 @@ export const DialogModal: FC<DialogModalProps> = ({
/> />
</Button> </Button>
)} )}
{onSave && ( {buttons}
<Button onClick={onClose}>
{saveLabel ?? (
<FormattedMessage
id='confirmation_modal.cancel'
defaultMessage='Cancel'
/>
)}
</Button>
)}
</div> </div>
)} )}
</div> </div>

View File

@ -94,11 +94,20 @@ export const MODAL_COMPONENTS = {
'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_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': accountEditModal('NameModal'),
'ACCOUNT_EDIT_BIO': () => import('@/mastodon/features/account_edit/components/bio_modal').then(module => ({ default: module.BioModal })), 'ACCOUNT_EDIT_BIO': accountEditModal('BioModal'),
'ACCOUNT_EDIT_PROFILE_DISPLAY': () => import('@/mastodon/features/account_edit/components/profile_display_modal').then(module => ({ default: module.ProfileDisplayModal })), 'ACCOUNT_EDIT_PROFILE_DISPLAY': accountEditModal('ProfileDisplayModal'),
'ACCOUNT_EDIT_VERIFY_LINKS': accountEditModal('VerifiedModal'),
'ACCOUNT_EDIT_FIELD_EDIT': accountEditModal('EditFieldModal'),
'ACCOUNT_EDIT_FIELD_DELETE': accountEditModal('DeleteFieldModal'),
'ACCOUNT_EDIT_FIELDS_REORDER': accountEditModal('ReorderFieldsModal'),
}; };
/** @arg {keyof import('@/mastodon/features/account_edit/modals')} type */
function accountEditModal(type) {
return () => import('@/mastodon/features/account_edit/modals').then(module => ({ default: module[type] }));
}
export default class ModalRoot extends PureComponent { export default class ModalRoot extends PureComponent {
static propTypes = { static propTypes = {

View File

@ -276,6 +276,8 @@
"callout.dismiss": "Afvis", "callout.dismiss": "Afvis",
"carousel.current": "<sr>Dias</sr> {current, number} / {max, number}", "carousel.current": "<sr>Dias</sr> {current, number} / {max, number}",
"carousel.slide": "Dias {current, number} af {max, number}", "carousel.slide": "Dias {current, number} af {max, number}",
"character_counter.recommended": "{currentLength}/{maxLength} anbefalede tegn",
"character_counter.required": "{currentLength}/{maxLength} tegn",
"closed_registrations.other_server_instructions": "Eftersom Mastodon er decentraliseret, kan du oprette en konto på en anden server og stadig interagere med denne.", "closed_registrations.other_server_instructions": "Eftersom Mastodon er decentraliseret, kan du oprette en konto på en anden server og stadig interagere med denne.",
"closed_registrations_modal.description": "Oprettelse af en konto på {domain} er i øjeblikket ikke muligt, men husk på, at du ikke behøver en konto specifikt på {domain} for at bruge Mastodon.", "closed_registrations_modal.description": "Oprettelse af en konto på {domain} er i øjeblikket ikke muligt, men husk på, at du ikke behøver en konto specifikt på {domain} for at bruge Mastodon.",
"closed_registrations_modal.find_another_server": "Find en anden server", "closed_registrations_modal.find_another_server": "Find en anden server",

View File

@ -166,7 +166,7 @@
"account_edit.profile_tab.hint.title": "Darstellung kann abweichen", "account_edit.profile_tab.hint.title": "Darstellung kann abweichen",
"account_edit.profile_tab.show_featured.description": "„Vorgestellt“ ist ein optionaler Tab, der von dir ausgewählte Profile hervorhebt.", "account_edit.profile_tab.show_featured.description": "„Vorgestellt“ ist ein optionaler Tab, der von dir ausgewählte Profile hervorhebt.",
"account_edit.profile_tab.show_featured.title": "„Vorgestellt“-Tab anzeigen", "account_edit.profile_tab.show_featured.title": "„Vorgestellt“-Tab anzeigen",
"account_edit.profile_tab.show_media.description": "„Medien“ ist ein optionaler Tab, der deine Beiträge mit Bildern oder Videos anzeigt.", "account_edit.profile_tab.show_media.description": "„Medien“ ist ein optionaler Tab, der deine Beiträge mit Audio, Bildern oder Videos anzeigt.",
"account_edit.profile_tab.show_media.title": "„Medien“-Tab anzeigen", "account_edit.profile_tab.show_media.title": "„Medien“-Tab anzeigen",
"account_edit.profile_tab.show_media_replies.description": "Durch das Aktivieren werden sowohl deine Beiträge als auch deine Antworten auf Beiträge anderer im „Medien“-Tab angezeigt.", "account_edit.profile_tab.show_media_replies.description": "Durch das Aktivieren werden sowohl deine Beiträge als auch deine Antworten auf Beiträge anderer im „Medien“-Tab angezeigt.",
"account_edit.profile_tab.show_media_replies.title": "Antworten im „Medien“-Tab anzeigen", "account_edit.profile_tab.show_media_replies.title": "Antworten im „Medien“-Tab anzeigen",
@ -276,6 +276,8 @@
"callout.dismiss": "Verwerfen", "callout.dismiss": "Verwerfen",
"carousel.current": "<sr>Seite</sr> {current, number}/{max, number}", "carousel.current": "<sr>Seite</sr> {current, number}/{max, number}",
"carousel.slide": "Seite {current, number} von {max, number}", "carousel.slide": "Seite {current, number} von {max, number}",
"character_counter.recommended": "{currentLength}/{maxLength} empfohlenen Zeichen",
"character_counter.required": "{currentLength}/{maxLength} Zeichen",
"closed_registrations.other_server_instructions": "Da Mastodon dezentralisiert ist, kannst du dich auch woanders im Fediverse registrieren und trotzdem mit diesem Server in Kontakt bleiben.", "closed_registrations.other_server_instructions": "Da Mastodon dezentralisiert ist, kannst du dich auch woanders im Fediverse registrieren und trotzdem mit diesem Server in Kontakt bleiben.",
"closed_registrations_modal.description": "Das Anlegen eines Kontos auf {domain} ist derzeit nicht möglich, aber bedenke, dass du nicht zwingend auf {domain} ein Konto benötigst, um Mastodon nutzen zu können.", "closed_registrations_modal.description": "Das Anlegen eines Kontos auf {domain} ist derzeit nicht möglich, aber bedenke, dass du nicht zwingend auf {domain} ein Konto benötigst, um Mastodon nutzen zu können.",
"closed_registrations_modal.find_another_server": "Anderen Server suchen", "closed_registrations_modal.find_another_server": "Anderen Server suchen",

View File

@ -276,6 +276,8 @@
"callout.dismiss": "Παράβλεψη", "callout.dismiss": "Παράβλεψη",
"carousel.current": "<sr>Διαφάνεια</sr> {current, number} / {max, number}", "carousel.current": "<sr>Διαφάνεια</sr> {current, number} / {max, number}",
"carousel.slide": "Διαφάνεια {current, number} από {max, number}", "carousel.slide": "Διαφάνεια {current, number} από {max, number}",
"character_counter.recommended": "{currentLength}/{maxLength} προτεινόμενοι χαρακτήρες",
"character_counter.required": "{currentLength}/{maxLength} χαρακτήρες",
"closed_registrations.other_server_instructions": "Καθώς το Mastodon είναι αποκεντρωμένο, μπορείς να δημιουργήσεις λογαριασμό σε άλλον διακομιστή αλλά να συνεχίσεις να αλληλεπιδράς με αυτόν.", "closed_registrations.other_server_instructions": "Καθώς το Mastodon είναι αποκεντρωμένο, μπορείς να δημιουργήσεις λογαριασμό σε άλλον διακομιστή αλλά να συνεχίσεις να αλληλεπιδράς με αυτόν.",
"closed_registrations_modal.description": "Η δημιουργία λογαριασμού στον {domain} προς το παρόν δεν είναι δυνατή, αλλά λάβε υπόψη ότι δεν χρειάζεσαι λογαριασμό ειδικά στον {domain} για να χρησιμοποιήσεις το Mastodon.", "closed_registrations_modal.description": "Η δημιουργία λογαριασμού στον {domain} προς το παρόν δεν είναι δυνατή, αλλά λάβε υπόψη ότι δεν χρειάζεσαι λογαριασμό ειδικά στον {domain} για να χρησιμοποιήσεις το Mastodon.",
"closed_registrations_modal.find_another_server": "Βρες άλλον διακομιστή", "closed_registrations_modal.find_another_server": "Βρες άλλον διακομιστή",
@ -575,7 +577,7 @@
"filter_modal.select_filter.subtitle": "Χρησιμοποιήστε μια υπάρχουσα κατηγορία ή δημιουργήστε μια νέα", "filter_modal.select_filter.subtitle": "Χρησιμοποιήστε μια υπάρχουσα κατηγορία ή δημιουργήστε μια νέα",
"filter_modal.select_filter.title": "Φιλτράρισμα αυτής της ανάρτησης", "filter_modal.select_filter.title": "Φιλτράρισμα αυτής της ανάρτησης",
"filter_modal.title.status": "Φιλτράρισμα μιας ανάρτησης", "filter_modal.title.status": "Φιλτράρισμα μιας ανάρτησης",
"filter_warning.matches_filter": "Ταιριάζει με το φίλτρο “<span>{title}</span>”", "filter_warning.matches_filter": "Αντιστοιχεί με το φίλτρο “<span>{title}</span>”",
"filtered_notifications_banner.pending_requests": "Από {count, plural, =0 {κανένα} one {ένα άτομο} other {# άτομα}} που μπορεί να ξέρεις", "filtered_notifications_banner.pending_requests": "Από {count, plural, =0 {κανένα} one {ένα άτομο} other {# άτομα}} που μπορεί να ξέρεις",
"filtered_notifications_banner.title": "Φιλτραρισμένες ειδοποιήσεις", "filtered_notifications_banner.title": "Φιλτραρισμένες ειδοποιήσεις",
"firehose.all": "Όλα", "firehose.all": "Όλα",
@ -1045,7 +1047,7 @@
"search.quick_action.go_to_account": "Μετάβαση στο προφίλ {x}", "search.quick_action.go_to_account": "Μετάβαση στο προφίλ {x}",
"search.quick_action.go_to_hashtag": "Μετάβαση στην ετικέτα {x}", "search.quick_action.go_to_hashtag": "Μετάβαση στην ετικέτα {x}",
"search.quick_action.open_url": "Άνοιγμα διεύθυνσης URL στο Mastodon", "search.quick_action.open_url": "Άνοιγμα διεύθυνσης URL στο Mastodon",
"search.quick_action.status_search": "Αναρτήσεις που ταιριάζουν με {x}", "search.quick_action.status_search": "Αναρτήσεις που αντιστοιχούν με {x}",
"search.search_or_paste": "Αναζήτηση ή εισαγωγή URL", "search.search_or_paste": "Αναζήτηση ή εισαγωγή URL",
"search_popout.full_text_search_disabled_message": "Μη διαθέσιμο στο {domain}.", "search_popout.full_text_search_disabled_message": "Μη διαθέσιμο στο {domain}.",
"search_popout.full_text_search_logged_out_message": "Διαθέσιμο μόνο όταν συνδεθείς.", "search_popout.full_text_search_logged_out_message": "Διαθέσιμο μόνο όταν συνδεθείς.",

View File

@ -276,6 +276,8 @@
"callout.dismiss": "Dismiss", "callout.dismiss": "Dismiss",
"carousel.current": "<sr>Slide</sr> {current, number} / {max, number}", "carousel.current": "<sr>Slide</sr> {current, number} / {max, number}",
"carousel.slide": "Slide {current, number} of {max, number}", "carousel.slide": "Slide {current, number} of {max, number}",
"character_counter.recommended": "{currentLength}/{maxLength} recommended characters",
"character_counter.required": "{currentLength}/{maxLength} characters",
"closed_registrations.other_server_instructions": "Since Mastodon is decentralised, you can create an account on another server and still interact with this one.", "closed_registrations.other_server_instructions": "Since Mastodon is decentralised, you can create an account on another server and still interact with this one.",
"closed_registrations_modal.description": "Creating an account on {domain} is currently not possible, but please keep in mind that you do not need an account specifically on {domain} to use Mastodon.", "closed_registrations_modal.description": "Creating an account on {domain} is currently not possible, but please keep in mind that you do not need an account specifically on {domain} to use Mastodon.",
"closed_registrations_modal.find_another_server": "Find another server", "closed_registrations_modal.find_another_server": "Find another server",

View File

@ -149,16 +149,28 @@
"account_edit.button.add": "Add {item}", "account_edit.button.add": "Add {item}",
"account_edit.button.delete": "Delete {item}", "account_edit.button.delete": "Delete {item}",
"account_edit.button.edit": "Edit {item}", "account_edit.button.edit": "Edit {item}",
"account_edit.char_counter": "{currentLength}/{maxLength} characters",
"account_edit.column_button": "Done", "account_edit.column_button": "Done",
"account_edit.column_title": "Edit Profile", "account_edit.column_title": "Edit Profile",
"account_edit.custom_fields.name": "field",
"account_edit.custom_fields.placeholder": "Add your pronouns, external links, or anything else youd like to share.", "account_edit.custom_fields.placeholder": "Add your pronouns, external links, or anything else youd like to share.",
"account_edit.custom_fields.tip_content": "You can easily add credibility to your Mastodon account by verifying links to any websites you own.",
"account_edit.custom_fields.tip_title": "Tip: Adding verified links",
"account_edit.custom_fields.title": "Custom fields", "account_edit.custom_fields.title": "Custom fields",
"account_edit.custom_fields.verified_hint": "How do I add a verified link?",
"account_edit.display_name.placeholder": "Your display name is how your name appears on your profile and in timelines.", "account_edit.display_name.placeholder": "Your display name is how your name appears on your profile and in timelines.",
"account_edit.display_name.title": "Display name", "account_edit.display_name.title": "Display name",
"account_edit.featured_hashtags.item": "hashtags", "account_edit.featured_hashtags.item": "hashtags",
"account_edit.featured_hashtags.placeholder": "Help others identify, and have quick access to, your favorite topics.", "account_edit.featured_hashtags.placeholder": "Help others identify, and have quick access to, your favorite topics.",
"account_edit.featured_hashtags.title": "Featured hashtags", "account_edit.featured_hashtags.title": "Featured hashtags",
"account_edit.field_delete_modal.confirm": "Are you sure you want to delete this custom field? This action cant be undone.",
"account_edit.field_delete_modal.delete_button": "Delete",
"account_edit.field_delete_modal.title": "Delete custom field?",
"account_edit.field_edit_modal.add_title": "Add custom field",
"account_edit.field_edit_modal.edit_title": "Edit custom field",
"account_edit.field_edit_modal.name_hint": "E.g. “Personal website”",
"account_edit.field_edit_modal.name_label": "Label",
"account_edit.field_edit_modal.value_hint": "E.g. “example.me”",
"account_edit.field_edit_modal.value_label": "Value",
"account_edit.name_modal.add_title": "Add display name", "account_edit.name_modal.add_title": "Add display name",
"account_edit.name_modal.edit_title": "Edit display name", "account_edit.name_modal.edit_title": "Edit display name",
"account_edit.profile_tab.button_label": "Customize", "account_edit.profile_tab.button_label": "Customize",
@ -173,6 +185,13 @@
"account_edit.profile_tab.subtitle": "Customize the tabs on your profile and what they display.", "account_edit.profile_tab.subtitle": "Customize the tabs on your profile and what they display.",
"account_edit.profile_tab.title": "Profile tab settings", "account_edit.profile_tab.title": "Profile tab settings",
"account_edit.save": "Save", "account_edit.save": "Save",
"account_edit.verified_modal.details": "Add credibility to your Mastodon profile by verifying links to personal websites. Heres how it works:",
"account_edit.verified_modal.invisible_link.details": "Add the link to your header. The important part is rel=\"me\" which prevents impersonation on websites with user-generated content. You can even use a link tag in the header of the page instead of {tag}, but the HTML must be accessible without executing JavaScript.",
"account_edit.verified_modal.invisible_link.summary": "How do I make the link invisible?",
"account_edit.verified_modal.step1.header": "Copy the HTML code below and paste into the header of your website",
"account_edit.verified_modal.step2.details": "If youve already added your website as a custom field, youll need to delete and re-add it to trigger verification.",
"account_edit.verified_modal.step2.header": "Add your website as a custom field",
"account_edit.verified_modal.title": "How to add a verified link",
"account_edit_tags.add_tag": "Add #{tagName}", "account_edit_tags.add_tag": "Add #{tagName}",
"account_edit_tags.column_title": "Edit featured hashtags", "account_edit_tags.column_title": "Edit featured hashtags",
"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.",
@ -308,15 +327,16 @@
"collections.delete_collection": "Delete collection", "collections.delete_collection": "Delete collection",
"collections.description_length_hint": "100 characters limit", "collections.description_length_hint": "100 characters limit",
"collections.detail.accounts_heading": "Accounts", "collections.detail.accounts_heading": "Accounts",
"collections.detail.author_added_you": "{author} added you to this collection",
"collections.detail.curated_by_author": "Curated by {author}", "collections.detail.curated_by_author": "Curated by {author}",
"collections.detail.curated_by_you": "Curated by you", "collections.detail.curated_by_you": "Curated by you",
"collections.detail.loading": "Loading collection…", "collections.detail.loading": "Loading collection…",
"collections.detail.other_accounts_in_collection": "Others in this collection:",
"collections.detail.sensitive_note": "This collection contains accounts and content that may be sensitive to some users.",
"collections.detail.share": "Share this collection", "collections.detail.share": "Share this collection",
"collections.edit_details": "Edit details", "collections.edit_details": "Edit details",
"collections.error_loading_collections": "There was an error when trying to load your collections.", "collections.error_loading_collections": "There was an error when trying to load your collections.",
"collections.hints.accounts_counter": "{count} / {max} accounts", "collections.hints.accounts_counter": "{count} / {max} accounts",
"collections.hints.add_more_accounts": "Add at least {count, plural, one {# account} other {# accounts}} to continue",
"collections.hints.can_not_remove_more_accounts": "Collections must contain at least {count, plural, one {# account} other {# accounts}}. Removing more accounts is not possible.",
"collections.last_updated_at": "Last updated: {date}", "collections.last_updated_at": "Last updated: {date}",
"collections.manage_accounts": "Manage accounts", "collections.manage_accounts": "Manage accounts",
"collections.mark_as_sensitive": "Mark as sensitive", "collections.mark_as_sensitive": "Mark as sensitive",
@ -772,6 +792,7 @@
"navigation_bar.automated_deletion": "Automated post deletion", "navigation_bar.automated_deletion": "Automated post deletion",
"navigation_bar.blocks": "Blocked users", "navigation_bar.blocks": "Blocked users",
"navigation_bar.bookmarks": "Bookmarks", "navigation_bar.bookmarks": "Bookmarks",
"navigation_bar.collections": "Collections",
"navigation_bar.direct": "Private mentions", "navigation_bar.direct": "Private mentions",
"navigation_bar.domain_blocks": "Blocked domains", "navigation_bar.domain_blocks": "Blocked domains",
"navigation_bar.favourites": "Favorites", "navigation_bar.favourites": "Favorites",

View File

@ -531,7 +531,7 @@
"loading_indicator.label": "Ŝargado…", "loading_indicator.label": "Ŝargado…",
"media_gallery.hide": "Kaŝi", "media_gallery.hide": "Kaŝi",
"moved_to_account_banner.text": "Via konto {disabledAccount} estas malvalidigita ĉar vi movis ĝin al {movedToAccount}.", "moved_to_account_banner.text": "Via konto {disabledAccount} estas malvalidigita ĉar vi movis ĝin al {movedToAccount}.",
"mute_modal.hide_from_notifications": "Kaŝi de sciigoj", "mute_modal.hide_from_notifications": "Kaŝi el sciigoj",
"mute_modal.hide_options": "Kaŝi agordojn", "mute_modal.hide_options": "Kaŝi agordojn",
"mute_modal.indefinite": "Ĝis mi malsilentas ilin", "mute_modal.indefinite": "Ĝis mi malsilentas ilin",
"mute_modal.show_options": "Montri agordojn", "mute_modal.show_options": "Montri agordojn",

View File

@ -276,6 +276,8 @@
"callout.dismiss": "Descartar", "callout.dismiss": "Descartar",
"carousel.current": "<sr>Diapositiva</sr> {current, number} / {max, number}", "carousel.current": "<sr>Diapositiva</sr> {current, number} / {max, number}",
"carousel.slide": "Diapositiva {current, number} de {max, number}", "carousel.slide": "Diapositiva {current, number} de {max, number}",
"character_counter.recommended": "{currentLength}/{maxLength} caracteres recomendados",
"character_counter.required": "{currentLength}/{maxLength} caracteres",
"closed_registrations.other_server_instructions": "Ya que Mastodon es descentralizado, podés crearte una cuenta en otro servidor y todavía interactuar con éste.", "closed_registrations.other_server_instructions": "Ya que Mastodon es descentralizado, podés crearte una cuenta en otro servidor y todavía interactuar con éste.",
"closed_registrations_modal.description": "Actualmente no es posible crearte una cuenta en {domain}. pero recordá que no necesitás tener una cuenta puntualmente dentro de {domain} para poder usar Mastodon.", "closed_registrations_modal.description": "Actualmente no es posible crearte una cuenta en {domain}. pero recordá que no necesitás tener una cuenta puntualmente dentro de {domain} para poder usar Mastodon.",
"closed_registrations_modal.find_another_server": "Buscar otro servidor", "closed_registrations_modal.find_another_server": "Buscar otro servidor",

View File

@ -276,6 +276,8 @@
"callout.dismiss": "Avvís", "callout.dismiss": "Avvís",
"carousel.current": "<sr>Glæra</sr> {current, number} / {max, number}", "carousel.current": "<sr>Glæra</sr> {current, number} / {max, number}",
"carousel.slide": "Glæra {current, number} av {max, number}", "carousel.slide": "Glæra {current, number} av {max, number}",
"character_counter.recommended": "{currentLength}/{maxLength} viðmæld tekn",
"character_counter.required": "{currentLength}/{maxLength} tekn",
"closed_registrations.other_server_instructions": "Av tí at Mastodon er desentraliserað, kanst tú stovna eina kontu á einum øðrum ambætara og framvegis virka saman við hesum ambætaranum.", "closed_registrations.other_server_instructions": "Av tí at Mastodon er desentraliserað, kanst tú stovna eina kontu á einum øðrum ambætara og framvegis virka saman við hesum ambætaranum.",
"closed_registrations_modal.description": "Tað er ikki møguligt at stovna sær eina kontu á {domain} í løtuni, men vinarliga hav í huga at tær nýtist ikki eina kontu á júst {domain} fyri at brúka Mastodon.", "closed_registrations_modal.description": "Tað er ikki møguligt at stovna sær eina kontu á {domain} í løtuni, men vinarliga hav í huga at tær nýtist ikki eina kontu á júst {domain} fyri at brúka Mastodon.",
"closed_registrations_modal.find_another_server": "Finn ein annan ambætara", "closed_registrations_modal.find_another_server": "Finn ein annan ambætara",

View File

@ -276,6 +276,8 @@
"callout.dismiss": "Desbotar", "callout.dismiss": "Desbotar",
"carousel.current": "<sr>Filmina</sr> {current, number} / {max, number}", "carousel.current": "<sr>Filmina</sr> {current, number} / {max, number}",
"carousel.slide": "Filmina {current, number} de {max, number}", "carousel.slide": "Filmina {current, number} de {max, number}",
"character_counter.recommended": "{currentLength}/{maxLength} caracteres recomendados",
"character_counter.required": "{currentLength}/{maxLength} caracteres",
"closed_registrations.other_server_instructions": "Cómo Mastodon é descentralizado, podes crear unha conta noutro servidor e interactuar igualmente con este.", "closed_registrations.other_server_instructions": "Cómo Mastodon é descentralizado, podes crear unha conta noutro servidor e interactuar igualmente con este.",
"closed_registrations_modal.description": "Actualmente non é posible crear unha conta en {domain}, pero ten en conta que non precisas unha conta específicamente en {domain} para usar Mastodon.", "closed_registrations_modal.description": "Actualmente non é posible crear unha conta en {domain}, pero ten en conta que non precisas unha conta específicamente en {domain} para usar Mastodon.",
"closed_registrations_modal.find_another_server": "Atopa outro servidor", "closed_registrations_modal.find_another_server": "Atopa outro servidor",

View File

@ -276,6 +276,8 @@
"callout.dismiss": "לבטל", "callout.dismiss": "לבטל",
"carousel.current": "<sr>שקופית</sr>{current, number} מתוך {max, number}", "carousel.current": "<sr>שקופית</sr>{current, number} מתוך {max, number}",
"carousel.slide": "שקופית {current, number} מתוך {max, number}", "carousel.slide": "שקופית {current, number} מתוך {max, number}",
"character_counter.recommended": "{currentLength}/{maxLength} תווים מומלצים",
"character_counter.required": "{currentLength}/{maxLength} תווים",
"closed_registrations.other_server_instructions": "מכיוון שמסטודון היא רשת מבוזרת, ניתן ליצור חשבון על שרת נוסף ועדיין לקיים קשר עם משתמשים בשרת זה.", "closed_registrations.other_server_instructions": "מכיוון שמסטודון היא רשת מבוזרת, ניתן ליצור חשבון על שרת נוסף ועדיין לקיים קשר עם משתמשים בשרת זה.",
"closed_registrations_modal.description": "יצירת חשבון על שרת {domain} איננה אפשרית כרגע, אבל זכרו שאינכן זקוקות לחשבון על {domain} כדי להשתמש במסטודון.", "closed_registrations_modal.description": "יצירת חשבון על שרת {domain} איננה אפשרית כרגע, אבל זכרו שאינכן זקוקות לחשבון על {domain} כדי להשתמש במסטודון.",
"closed_registrations_modal.find_another_server": "חיפוש שרת אחר", "closed_registrations_modal.find_another_server": "חיפוש שרת אחר",

View File

@ -152,6 +152,7 @@
"account_edit.char_counter": "{currentLength}/{maxLength} karakter", "account_edit.char_counter": "{currentLength}/{maxLength} karakter",
"account_edit.column_button": "Kész", "account_edit.column_button": "Kész",
"account_edit.column_title": "Profil szerkesztése", "account_edit.column_title": "Profil szerkesztése",
"account_edit.custom_fields.placeholder": "Add meg a névmásaidat, külső hivatkozásaidat vagy bármi mást, amelyet megosztanál.",
"account_edit.custom_fields.title": "Egyéni mezők", "account_edit.custom_fields.title": "Egyéni mezők",
"account_edit.display_name.placeholder": "A megjelenítendő név az, ahogy a neved megjelenik a profilodon és az idővonalakon.", "account_edit.display_name.placeholder": "A megjelenítendő név az, ahogy a neved megjelenik a profilodon és az idővonalakon.",
"account_edit.display_name.title": "Megjelenítendő név", "account_edit.display_name.title": "Megjelenítendő név",
@ -167,9 +168,17 @@
"account_edit.profile_tab.show_featured.title": "„Kiemelt” lap megjelenítése", "account_edit.profile_tab.show_featured.title": "„Kiemelt” lap megjelenítése",
"account_edit.profile_tab.show_media.description": "A „Média” egy nem kötelező lap, amely a képeket vagy videókat tartalmazó bejegyzéseidet jeleníti meg.", "account_edit.profile_tab.show_media.description": "A „Média” egy nem kötelező lap, amely a képeket vagy videókat tartalmazó bejegyzéseidet jeleníti meg.",
"account_edit.profile_tab.show_media.title": "„Média” lap megjelenítése", "account_edit.profile_tab.show_media.title": "„Média” lap megjelenítése",
"account_edit.profile_tab.show_media_replies.description": "Ha engedélyezve van, akkor a Média lap megjeleníti a bejegyzéseidet és a mások bejegyzéseihez írt válaszaidat.",
"account_edit.profile_tab.show_media_replies.title": "Válaszok megjelenítése a „Média” lapon",
"account_edit.profile_tab.subtitle": "Szabd testre a profilodon látható lapokat, és a megjelenített tartalmukat.",
"account_edit.profile_tab.title": "Profil lap beállításai", "account_edit.profile_tab.title": "Profil lap beállításai",
"account_edit.save": "Mentés", "account_edit.save": "Mentés",
"account_edit_tags.add_tag": "#{tagName} hozzáadása", "account_edit_tags.add_tag": "#{tagName} hozzáadása",
"account_edit_tags.column_title": "Kiemelt hashtagek szerkesztése",
"account_edit_tags.help_text": "A kiemelt hashtagek segítenek a felhasználóknak abban, hogy interakcióba lépjenek a profiloddal. Szűrőként jelennek meg a Profil oldalad Tevékenység nézetében.",
"account_edit_tags.search_placeholder": "Hashtag megadása…",
"account_edit_tags.suggestions": "Javaslatok:",
"account_edit_tags.tag_status_count": "{count, plural, one {# bejegyzés} other {# bejegyzés}}",
"account_note.placeholder": "Kattintás jegyzet hozzáadásához", "account_note.placeholder": "Kattintás jegyzet hozzáadásához",
"admin.dashboard.daily_retention": "Napi regisztráció utáni felhasználómegtartási arány", "admin.dashboard.daily_retention": "Napi regisztráció utáni felhasználómegtartási arány",
"admin.dashboard.monthly_retention": "Havi regisztráció utáni felhasználómegtartási arány", "admin.dashboard.monthly_retention": "Havi regisztráció utáni felhasználómegtartási arány",
@ -267,11 +276,20 @@
"callout.dismiss": "Elvetés", "callout.dismiss": "Elvetés",
"carousel.current": "{current, number}. <sr>dia</sr> / {max, number}", "carousel.current": "{current, number}. <sr>dia</sr> / {max, number}",
"carousel.slide": "{current, number}. dia / {max, number}", "carousel.slide": "{current, number}. dia / {max, number}",
"character_counter.recommended": "{currentLength}/{maxLength} javasolt karakter",
"character_counter.required": "{currentLength}/{maxLength} karakter",
"closed_registrations.other_server_instructions": "Mivel a Mastdon decentralizált, létrehozhatsz egy fiókot egy másik kiszolgálón és mégis kapcsolódhatsz ehhez.", "closed_registrations.other_server_instructions": "Mivel a Mastdon decentralizált, létrehozhatsz egy fiókot egy másik kiszolgálón és mégis kapcsolódhatsz ehhez.",
"closed_registrations_modal.description": "Fiók létrehozása a {domain} kiszolgálón jelenleg nem lehetséges, de jó, ha tudod, hogy nem szükséges fiókkal rendelkezni pont a {domain} kiszolgálón, hogy használhasd a Mastodont.", "closed_registrations_modal.description": "Fiók létrehozása a {domain} kiszolgálón jelenleg nem lehetséges, de jó, ha tudod, hogy nem szükséges fiókkal rendelkezni pont a {domain} kiszolgálón, hogy használhasd a Mastodont.",
"closed_registrations_modal.find_another_server": "Másik kiszolgáló keresése", "closed_registrations_modal.find_another_server": "Másik kiszolgáló keresése",
"closed_registrations_modal.preamble": "A Mastodon decentralizált, így teljesen mindegy, hol hozod létre a fiókodat, követhetsz és kapcsolódhatsz bárkivel ezen a kiszolgálón is. Saját magad is üzemeltethetsz kiszolgálót!", "closed_registrations_modal.preamble": "A Mastodon decentralizált, így teljesen mindegy, hol hozod létre a fiókodat, követhetsz és kapcsolódhatsz bárkivel ezen a kiszolgálón is. Saját magad is üzemeltethetsz kiszolgálót!",
"closed_registrations_modal.title": "Regisztráció a Mastodonra", "closed_registrations_modal.title": "Regisztráció a Mastodonra",
"collection.share_modal.share_link_label": "Megosztási hivatkozás",
"collection.share_modal.share_via_post": "Közzététel a Mastodonon",
"collection.share_modal.share_via_system": "Megosztás…",
"collection.share_modal.title": "Gyűjtemény megosztása",
"collection.share_modal.title_new": "Oszd meg az új gyűjteményedet!",
"collection.share_template_other": "Nézd meg ezt a gyűjteményt: {link}",
"collection.share_template_own": "Nézd meg az új gyűjteményemet: {link}",
"collections.account_count": "{count, plural, one {# fiók} other {# fiók}}", "collections.account_count": "{count, plural, one {# fiók} other {# fiók}}",
"collections.accounts.empty_description": "Adj hozzá legfeljebb {count} követett fiókot", "collections.accounts.empty_description": "Adj hozzá legfeljebb {count} követett fiókot",
"collections.accounts.empty_title": "Ez a gyűjtemény üres", "collections.accounts.empty_title": "Ez a gyűjtemény üres",
@ -290,6 +308,8 @@
"collections.delete_collection": "Gyűjtemény törlése", "collections.delete_collection": "Gyűjtemény törlése",
"collections.description_length_hint": "100 karakteres korlát", "collections.description_length_hint": "100 karakteres korlát",
"collections.detail.accounts_heading": "Fiókok", "collections.detail.accounts_heading": "Fiókok",
"collections.detail.curated_by_author": "Válogatta: {author}",
"collections.detail.curated_by_you": "Te válogattad",
"collections.detail.loading": "Gyűjtemény betöltése…", "collections.detail.loading": "Gyűjtemény betöltése…",
"collections.detail.share": "Gyűjtemény megosztása", "collections.detail.share": "Gyűjtemény megosztása",
"collections.edit_details": "Részletek szerkesztése", "collections.edit_details": "Részletek szerkesztése",
@ -304,11 +324,15 @@
"collections.name_length_hint": "40 karakteres korlát", "collections.name_length_hint": "40 karakteres korlát",
"collections.new_collection": "Új gyűjtemény", "collections.new_collection": "Új gyűjtemény",
"collections.no_collections_yet": "Még nincsenek gyűjtemények.", "collections.no_collections_yet": "Még nincsenek gyűjtemények.",
"collections.old_last_post_note": "Egy hete osztott meg legutóbb",
"collections.remove_account": "Fiók eltávolítása", "collections.remove_account": "Fiók eltávolítása",
"collections.report_collection": "Gyűjtemény jelentése",
"collections.search_accounts_label": "Hozzáadandó fiókok keresése…", "collections.search_accounts_label": "Hozzáadandó fiókok keresése…",
"collections.search_accounts_max_reached": "Elérte a hozzáadott fiókok maximális számát", "collections.search_accounts_max_reached": "Elérte a hozzáadott fiókok maximális számát",
"collections.sensitive": "Érzékeny",
"collections.topic_hint": "Egy hashtag hozzáadása segít másoknak abban, hogy megértsék a gyűjtemény fő témáját.", "collections.topic_hint": "Egy hashtag hozzáadása segít másoknak abban, hogy megértsék a gyűjtemény fő témáját.",
"collections.view_collection": "Gyűjtemény megtekintése", "collections.view_collection": "Gyűjtemény megtekintése",
"collections.view_other_collections_by_user": "Felhasználó más gyűjteményeinek megtekintése",
"collections.visibility_public": "Nyilvános", "collections.visibility_public": "Nyilvános",
"collections.visibility_public_hint": "Felfedezhető a keresési találatokban és az egyéb helyeken, ahol ajánlások jelennek meg.", "collections.visibility_public_hint": "Felfedezhető a keresési találatokban és az egyéb helyeken, ahol ajánlások jelennek meg.",
"collections.visibility_title": "Láthatóság", "collections.visibility_title": "Láthatóság",
@ -397,6 +421,9 @@
"confirmations.discard_draft.post.title": "Elveted a piszkozatot?", "confirmations.discard_draft.post.title": "Elveted a piszkozatot?",
"confirmations.discard_edit_media.confirm": "Elvetés", "confirmations.discard_edit_media.confirm": "Elvetés",
"confirmations.discard_edit_media.message": "Mentetlen változtatásaid vannak a média leírásában vagy előnézetében, mindenképp elveted?", "confirmations.discard_edit_media.message": "Mentetlen változtatásaid vannak a média leírásában vagy előnézetében, mindenképp elveted?",
"confirmations.follow_to_collection.confirm": "Követés, és gyűjteményhez adás",
"confirmations.follow_to_collection.message": "Követned kell {name} felhasználót, hogy hozzáadhasd egy gyűjteményhez.",
"confirmations.follow_to_collection.title": "Fiók követése?",
"confirmations.follow_to_list.confirm": "Követés, és hozzáadás a listához", "confirmations.follow_to_list.confirm": "Követés, és hozzáadás a listához",
"confirmations.follow_to_list.message": "Követned kell {name} felhasználót, hogy hozzáadhasd a listához.", "confirmations.follow_to_list.message": "Követned kell {name} felhasználót, hogy hozzáadhasd a listához.",
"confirmations.follow_to_list.title": "Felhasználó követése?", "confirmations.follow_to_list.title": "Felhasználó követése?",
@ -440,6 +467,7 @@
"conversation.open": "Beszélgetés megtekintése", "conversation.open": "Beszélgetés megtekintése",
"conversation.with": "Velük: {names}", "conversation.with": "Velük: {names}",
"copy_icon_button.copied": "A szöveg a vágólapra másolva", "copy_icon_button.copied": "A szöveg a vágólapra másolva",
"copy_icon_button.copy_this_text": "Hivatkozás vágólapra másolása",
"copypaste.copied": "Másolva", "copypaste.copied": "Másolva",
"copypaste.copy_to_clipboard": "Másolás vágólapra", "copypaste.copy_to_clipboard": "Másolás vágólapra",
"directory.federated": "Az ismert födiverzumból", "directory.federated": "Az ismert födiverzumból",
@ -657,6 +685,7 @@
"keyboard_shortcuts.direct": "Személyes említések oszlop megnyitása", "keyboard_shortcuts.direct": "Személyes említések oszlop megnyitása",
"keyboard_shortcuts.down": "Mozgás lefelé a listában", "keyboard_shortcuts.down": "Mozgás lefelé a listában",
"keyboard_shortcuts.enter": "Bejegyzés megnyitása", "keyboard_shortcuts.enter": "Bejegyzés megnyitása",
"keyboard_shortcuts.explore": "Felkapottak idővonalának megnyitása",
"keyboard_shortcuts.favourite": "Bejegyzés kedvencnek jelölése", "keyboard_shortcuts.favourite": "Bejegyzés kedvencnek jelölése",
"keyboard_shortcuts.favourites": "Kedvencek lista megnyitása", "keyboard_shortcuts.favourites": "Kedvencek lista megnyitása",
"keyboard_shortcuts.federated": "Föderációs idővonal megnyitása", "keyboard_shortcuts.federated": "Föderációs idővonal megnyitása",
@ -969,6 +998,7 @@
"report.category.title_account": "profillal", "report.category.title_account": "profillal",
"report.category.title_status": "bejegyzéssel", "report.category.title_status": "bejegyzéssel",
"report.close": "Kész", "report.close": "Kész",
"report.collection_comment": "Miért jelented ezt a gyűjteményt?",
"report.comment.title": "Van valami, amiről tudnunk kellene?", "report.comment.title": "Van valami, amiről tudnunk kellene?",
"report.forward": "Továbbítás: {target}", "report.forward": "Továbbítás: {target}",
"report.forward_hint": "Ez a fiók egy másik kiszolgálóról van. Oda is elküldésre kerüljön a jelentés egy anonimizált másolata?", "report.forward_hint": "Ez a fiók egy másik kiszolgálóról van. Oda is elküldésre kerüljön a jelentés egy anonimizált másolata?",
@ -990,6 +1020,8 @@
"report.rules.title": "Mely szabályok lettek megsértve?", "report.rules.title": "Mely szabályok lettek megsértve?",
"report.statuses.subtitle": "Válaszd ki az összes megfelelőt", "report.statuses.subtitle": "Válaszd ki az összes megfelelőt",
"report.statuses.title": "Vannak olyan bejegyzések, amelyek alátámasztják ezt a jelentést?", "report.statuses.title": "Vannak olyan bejegyzések, amelyek alátámasztják ezt a jelentést?",
"report.submission_error": "A jelentés beküldése nem sikerült",
"report.submission_error_details": "Ellenőrizd a hálózati kapcsolatot és próbáld meg újra.",
"report.submit": "Küldés", "report.submit": "Küldés",
"report.target": "{target} jelentése", "report.target": "{target} jelentése",
"report.thanks.take_action": "Itt vannak a beállítások, melyek szabályozzák, hogy mit látsz a Mastodonon:", "report.thanks.take_action": "Itt vannak a beállítások, melyek szabályozzák, hogy mit látsz a Mastodonon:",
@ -1043,6 +1075,9 @@
"sign_in_banner.mastodon_is": "A Mastodon a legjobb módja annak, hogy a történésekkel kapcsolatban naprakész maradj.", "sign_in_banner.mastodon_is": "A Mastodon a legjobb módja annak, hogy a történésekkel kapcsolatban naprakész maradj.",
"sign_in_banner.sign_in": "Bejelentkezés", "sign_in_banner.sign_in": "Bejelentkezés",
"sign_in_banner.sso_redirect": "Bejelentkezés vagy regisztráció", "sign_in_banner.sso_redirect": "Bejelentkezés vagy regisztráció",
"skip_links.hotkey": "<span>Hotkey</span> {hotkey}",
"skip_links.skip_to_content": "Ugrás a fő tartalomhoz",
"skip_links.skip_to_navigation": "Ugrás a fő navigációhoz",
"status.admin_account": "Moderációs felület megnyitása @{name} fiókhoz", "status.admin_account": "Moderációs felület megnyitása @{name} fiókhoz",
"status.admin_domain": "Moderációs felület megnyitása {domain} esetében", "status.admin_domain": "Moderációs felület megnyitása {domain} esetében",
"status.admin_status": "Bejegyzés megnyitása a moderációs felületen", "status.admin_status": "Bejegyzés megnyitása a moderációs felületen",

View File

@ -276,6 +276,8 @@
"callout.dismiss": "Vísa frá", "callout.dismiss": "Vísa frá",
"carousel.current": "<sr>Skyggna</sr> {current, number} / {max, number}", "carousel.current": "<sr>Skyggna</sr> {current, number} / {max, number}",
"carousel.slide": "Skyggna {current, number} af {max, number}", "carousel.slide": "Skyggna {current, number} af {max, number}",
"character_counter.recommended": "{currentLength}/{maxLength} stafir sem mælt er með",
"character_counter.required": "{currentLength}/{maxLength} stafir",
"closed_registrations.other_server_instructions": "Þar sem Mastodon er ekki miðstýrt, þá getur þú búið til aðgang á öðrum þjóni, en samt haft samskipti við þennan.", "closed_registrations.other_server_instructions": "Þar sem Mastodon er ekki miðstýrt, þá getur þú búið til aðgang á öðrum þjóni, en samt haft samskipti við þennan.",
"closed_registrations_modal.description": "Að búa til aðgang á {domain} er ekki mögulegt eins og er, en vinsamlegast hafðu í huga að þú þarft ekki aðgang sérstaklega á {domain} til að nota Mastodon.", "closed_registrations_modal.description": "Að búa til aðgang á {domain} er ekki mögulegt eins og er, en vinsamlegast hafðu í huga að þú þarft ekki aðgang sérstaklega á {domain} til að nota Mastodon.",
"closed_registrations_modal.find_another_server": "Finna annan netþjón", "closed_registrations_modal.find_another_server": "Finna annan netþjón",

View File

@ -276,6 +276,8 @@
"callout.dismiss": "Ignora", "callout.dismiss": "Ignora",
"carousel.current": "<sr>Diapositiva</sr> {current, number} / {max, number}", "carousel.current": "<sr>Diapositiva</sr> {current, number} / {max, number}",
"carousel.slide": "Diapositiva {current, number} di {max, number}", "carousel.slide": "Diapositiva {current, number} di {max, number}",
"character_counter.recommended": "{currentLength}/{maxLength} caratteri consigliati",
"character_counter.required": "{currentLength}/{maxLength} caratteri",
"closed_registrations.other_server_instructions": "Poiché Mastodon è decentralizzato, puoi creare un profilo su un altro server, pur continuando a interagire con questo.", "closed_registrations.other_server_instructions": "Poiché Mastodon è decentralizzato, puoi creare un profilo su un altro server, pur continuando a interagire con questo.",
"closed_registrations_modal.description": "Correntemente, è impossibile creare un profilo su {domain}, ma sei pregato di tenere presente che non necessiti di un profilo specificamente su {domain} per utilizzare Mastodon.", "closed_registrations_modal.description": "Correntemente, è impossibile creare un profilo su {domain}, ma sei pregato di tenere presente che non necessiti di un profilo specificamente su {domain} per utilizzare Mastodon.",
"closed_registrations_modal.find_another_server": "Trova un altro server", "closed_registrations_modal.find_another_server": "Trova un altro server",

View File

@ -44,9 +44,11 @@
"account.familiar_followers_two": "Fylgt av {name1} og {name2}", "account.familiar_followers_two": "Fylgt av {name1} og {name2}",
"account.featured": "Utvald", "account.featured": "Utvald",
"account.featured.accounts": "Profilar", "account.featured.accounts": "Profilar",
"account.featured.collections": "Samlingar",
"account.featured.hashtags": "Emneknaggar", "account.featured.hashtags": "Emneknaggar",
"account.featured_tags.last_status_at": "Sist nytta {date}", "account.featured_tags.last_status_at": "Sist nytta {date}",
"account.featured_tags.last_status_never": "Ingen innlegg", "account.featured_tags.last_status_never": "Ingen innlegg",
"account.field_overflow": "Vis heile innhaldet",
"account.filters.all": "All aktivitet", "account.filters.all": "All aktivitet",
"account.filters.boosts_toggle": "Vis framhevingar", "account.filters.boosts_toggle": "Vis framhevingar",
"account.filters.posts_boosts": "Innlegg og framhevingar", "account.filters.posts_boosts": "Innlegg og framhevingar",
@ -159,9 +161,19 @@
"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",
"account_edit.name_modal.edit_title": "Endre synleg namn", "account_edit.name_modal.edit_title": "Endre synleg namn",
"account_edit.profile_tab.button_label": "Tilpass",
"account_edit.profile_tab.hint.description": "Desse innstillingane tilpassar kva folk ser på {server} i dei offisielle appane, men dei gjeld ikkje dermed folk på andre tenarar og tredjepartsappar.",
"account_edit.profile_tab.hint.title": "Visinga er likevel ulik",
"account_edit.profile_tab.show_featured.description": "«Utvalt» er ei tilleggsfane der du kan syna fram andre kontoar.",
"account_edit.profile_tab.show_featured.title": "Vis «Utvalt»-fana",
"account_edit.profile_tab.show_media.description": "«Medium» er ei tilleggsfane for innlegga dine som inneheld bilete eller filmar.",
"account_edit.profile_tab.show_media.title": "Vis «Medium»-fana",
"account_edit.profile_tab.show_media_replies.description": "Når medium-fana er i bruk, syner ho både innlegga dine og svar på andre folk sine innlegg.",
"account_edit.profile_tab.show_media_replies.title": "Ta med svar i «Medium»-fana",
"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.add_tag": "Legg til #{tagName}",
"account_edit_tags.column_title": "Rediger utvalde emneknaggar", "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.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.search_placeholder": "Skriv ein emneknagg…",
@ -269,6 +281,13 @@
"closed_registrations_modal.find_another_server": "Finn ein annan tenar", "closed_registrations_modal.find_another_server": "Finn ein annan tenar",
"closed_registrations_modal.preamble": "Mastodon er desentralisert, så uansett kvar du opprettar ein konto, vil du kunne fylgje og samhandle med alle på denne tenaren. Du kan til og med ha din eigen tenar!", "closed_registrations_modal.preamble": "Mastodon er desentralisert, så uansett kvar du opprettar ein konto, vil du kunne fylgje og samhandle med alle på denne tenaren. Du kan til og med ha din eigen tenar!",
"closed_registrations_modal.title": "Registrer deg på Mastodon", "closed_registrations_modal.title": "Registrer deg på Mastodon",
"collection.share_modal.share_link_label": "Delingslenke",
"collection.share_modal.share_via_post": "Legg ut på Mastodon",
"collection.share_modal.share_via_system": "Del med…",
"collection.share_modal.title": "Del ei samling",
"collection.share_modal.title_new": "Del den nye samlinga di!",
"collection.share_template_other": "Sjekk denne samlinga: {link}",
"collection.share_template_own": "Sjekk den nye samlinga mi: {link}",
"collections.account_count": "{count, plural, one {# konto} other {# kontoar}}", "collections.account_count": "{count, plural, one {# konto} other {# kontoar}}",
"collections.accounts.empty_description": "Legg til opp til {count} kontoar du fylgjer", "collections.accounts.empty_description": "Legg til opp til {count} kontoar du fylgjer",
"collections.accounts.empty_title": "Denne samlinga er tom", "collections.accounts.empty_title": "Denne samlinga er tom",
@ -305,11 +324,13 @@
"collections.no_collections_yet": "Du har ingen samlingar enno.", "collections.no_collections_yet": "Du har ingen samlingar enno.",
"collections.old_last_post_note": "Sist lagt ut for over ei veke sidan", "collections.old_last_post_note": "Sist lagt ut for over ei veke sidan",
"collections.remove_account": "Fjern denne kontoen", "collections.remove_account": "Fjern denne kontoen",
"collections.report_collection": "Rapporter denne samlinga",
"collections.search_accounts_label": "Søk etter kontoar å leggja til…", "collections.search_accounts_label": "Søk etter kontoar å leggja til…",
"collections.search_accounts_max_reached": "Du har nådd grensa for kor mange kontoar du kan leggja til", "collections.search_accounts_max_reached": "Du har nådd grensa for kor mange kontoar du kan leggja til",
"collections.sensitive": "Ømtolig", "collections.sensitive": "Ømtolig",
"collections.topic_hint": "Legg til ein emneknagg som hjelper andre å forstå hovudemnet for denne samlinga.", "collections.topic_hint": "Legg til ein emneknagg som hjelper andre å forstå hovudemnet for denne samlinga.",
"collections.view_collection": "Sjå samlinga", "collections.view_collection": "Sjå samlinga",
"collections.view_other_collections_by_user": "Sjå andre samlingar frå denne personen",
"collections.visibility_public": "Offentleg", "collections.visibility_public": "Offentleg",
"collections.visibility_public_hint": "Kan koma opp i søkjeresultat og andre stader der tilrådingar syner.", "collections.visibility_public_hint": "Kan koma opp i søkjeresultat og andre stader der tilrådingar syner.",
"collections.visibility_title": "Vising", "collections.visibility_title": "Vising",
@ -443,9 +464,10 @@
"conversation.mark_as_read": "Marker som lesen", "conversation.mark_as_read": "Marker som lesen",
"conversation.open": "Sjå samtale", "conversation.open": "Sjå samtale",
"conversation.with": "Med {names}", "conversation.with": "Med {names}",
"copy_icon_button.copied": "Kopiert til utklyppstavla", "copy_icon_button.copied": "Kopiert til utklippstavla",
"copy_icon_button.copy_this_text": "Kopier til utklippstavla",
"copypaste.copied": "Kopiert", "copypaste.copied": "Kopiert",
"copypaste.copy_to_clipboard": "Kopier til utklyppstavla", "copypaste.copy_to_clipboard": "Kopier til utklippstavla",
"directory.federated": "Frå den kjende allheimen", "directory.federated": "Frå den kjende allheimen",
"directory.local": "Berre frå {domain}", "directory.local": "Berre frå {domain}",
"directory.new_arrivals": "Nyleg tilkomne", "directory.new_arrivals": "Nyleg tilkomne",
@ -661,6 +683,7 @@
"keyboard_shortcuts.direct": "Opne spalta med private omtalar", "keyboard_shortcuts.direct": "Opne spalta med private omtalar",
"keyboard_shortcuts.down": "Flytt nedover i lista", "keyboard_shortcuts.down": "Flytt nedover i lista",
"keyboard_shortcuts.enter": "Opne innlegg", "keyboard_shortcuts.enter": "Opne innlegg",
"keyboard_shortcuts.explore": "Opne «populært»-tidslina",
"keyboard_shortcuts.favourite": "Marker innlegget som favoritt", "keyboard_shortcuts.favourite": "Marker innlegget som favoritt",
"keyboard_shortcuts.favourites": "Opne favorittlista", "keyboard_shortcuts.favourites": "Opne favorittlista",
"keyboard_shortcuts.federated": "Opne den samla tidslina", "keyboard_shortcuts.federated": "Opne den samla tidslina",
@ -973,6 +996,7 @@
"report.category.title_account": "profil", "report.category.title_account": "profil",
"report.category.title_status": "innlegg", "report.category.title_status": "innlegg",
"report.close": "Ferdig", "report.close": "Ferdig",
"report.collection_comment": "Kvifor vil du rapportera denne samlinga?",
"report.comment.title": "Er det noko anna du meiner me bør vite?", "report.comment.title": "Er det noko anna du meiner me bør vite?",
"report.forward": "Vidaresend til {target}", "report.forward": "Vidaresend til {target}",
"report.forward_hint": "Kontoen er frå ein annan tenar. Vil du senda ein anonymisert kopi av rapporten dit òg?", "report.forward_hint": "Kontoen er frå ein annan tenar. Vil du senda ein anonymisert kopi av rapporten dit òg?",
@ -994,6 +1018,8 @@
"report.rules.title": "Kva reglar vert brotne?", "report.rules.title": "Kva reglar vert brotne?",
"report.statuses.subtitle": "Velg det som gjeld", "report.statuses.subtitle": "Velg det som gjeld",
"report.statuses.title": "Er det innlegg som støttar opp under denne rapporten?", "report.statuses.title": "Er det innlegg som støttar opp under denne rapporten?",
"report.submission_error": "Greidde ikkje senda rapporten",
"report.submission_error_details": "Sjekk nettilkoplinga di og prøv att seinare.",
"report.submit": "Send inn", "report.submit": "Send inn",
"report.target": "Rapporterer {target}", "report.target": "Rapporterer {target}",
"report.thanks.take_action": "Dette er dei ulike alternativa for å kontrollere kva du ser på Mastodon:", "report.thanks.take_action": "Dette er dei ulike alternativa for å kontrollere kva du ser på Mastodon:",
@ -1047,6 +1073,9 @@
"sign_in_banner.mastodon_is": "Mastodon er den beste måten å fylgja med på det som skjer.", "sign_in_banner.mastodon_is": "Mastodon er den beste måten å fylgja med på det som skjer.",
"sign_in_banner.sign_in": "Logg inn", "sign_in_banner.sign_in": "Logg inn",
"sign_in_banner.sso_redirect": "Logg inn eller registrer deg", "sign_in_banner.sso_redirect": "Logg inn eller registrer deg",
"skip_links.hotkey": "<span>Snøggtast</span> {hotkey}",
"skip_links.skip_to_content": "Gå til hovudinnhald",
"skip_links.skip_to_navigation": "Gå til hovudnavigasjon",
"status.admin_account": "Opne moderasjonsgrensesnitt for @{name}", "status.admin_account": "Opne moderasjonsgrensesnitt for @{name}",
"status.admin_domain": "Opna moderatorgrensesnittet for {domain}", "status.admin_domain": "Opna moderatorgrensesnittet for {domain}",
"status.admin_status": "Opne denne statusen i moderasjonsgrensesnittet", "status.admin_status": "Opne denne statusen i moderasjonsgrensesnittet",

View File

@ -268,6 +268,8 @@
"callout.dismiss": "Descartar", "callout.dismiss": "Descartar",
"carousel.current": "<sr>Slide</sr> {current, number} / {max, number}", "carousel.current": "<sr>Slide</sr> {current, number} / {max, number}",
"carousel.slide": "Slide {current, number} de {max, number}", "carousel.slide": "Slide {current, number} de {max, number}",
"character_counter.recommended": "{currentLength}/{maxLength} caracteres recomendados",
"character_counter.required": "{currentLength}/{maxLength} caracteres",
"closed_registrations.other_server_instructions": "Visto que o Mastodon é descentralizado, podes criar uma conta noutro servidor e interagir com este na mesma.", "closed_registrations.other_server_instructions": "Visto que o Mastodon é descentralizado, podes criar uma conta noutro servidor e interagir com este na mesma.",
"closed_registrations_modal.description": "Neste momento não é possível criar uma conta em {domain}, mas lembramos que não é preciso ter uma conta especificamente em {domain} para usar o Mastodon.", "closed_registrations_modal.description": "Neste momento não é possível criar uma conta em {domain}, mas lembramos que não é preciso ter uma conta especificamente em {domain} para usar o Mastodon.",
"closed_registrations_modal.find_another_server": "Procurar outro servidor", "closed_registrations_modal.find_another_server": "Procurar outro servidor",

View File

@ -274,6 +274,8 @@
"callout.dismiss": "Hidhe tej", "callout.dismiss": "Hidhe tej",
"carousel.current": "<sr>Diapozitivi</sr> {current, number} / {max, number}", "carousel.current": "<sr>Diapozitivi</sr> {current, number} / {max, number}",
"carousel.slide": "Diapozitivi {current, number} nga {max, number} gjithsej", "carousel.slide": "Diapozitivi {current, number} nga {max, number} gjithsej",
"character_counter.recommended": "{currentLength}/{maxLength} shenja e rekomanduara",
"character_counter.required": "{currentLength}/{maxLength} shenja",
"closed_registrations.other_server_instructions": "Ngaqë Mastodon-i është i decentralizuar, mund të krijoni një llogari në një tjetër shërbyes dhe prapë të ndëveproni me këtë këtu.", "closed_registrations.other_server_instructions": "Ngaqë Mastodon-i është i decentralizuar, mund të krijoni një llogari në një tjetër shërbyes dhe prapë të ndëveproni me këtë këtu.",
"closed_registrations_modal.description": "Krijimi i një llogarie te {domain} aktualisht është i pamundur, por kini parasysh se skeni nevojë për një llogari posaçërisht në {domain} që të përdorni Mastodon-in.", "closed_registrations_modal.description": "Krijimi i një llogarie te {domain} aktualisht është i pamundur, por kini parasysh se skeni nevojë për një llogari posaçërisht në {domain} që të përdorni Mastodon-in.",
"closed_registrations_modal.find_another_server": "Gjeni shërbyes tjetër", "closed_registrations_modal.find_another_server": "Gjeni shërbyes tjetër",

View File

@ -200,6 +200,8 @@
"callout.dismiss": "Avfärda", "callout.dismiss": "Avfärda",
"carousel.current": "<sr>Bild</sr>{current, number} / {max, number}", "carousel.current": "<sr>Bild</sr>{current, number} / {max, number}",
"carousel.slide": "Bild {current, number} av {max, number}", "carousel.slide": "Bild {current, number} av {max, number}",
"character_counter.recommended": "{currentLength}/{maxLength} rekommenderade tecken",
"character_counter.required": "{currentLength}/{maxLength} tecken",
"closed_registrations.other_server_instructions": "Eftersom Mastodon är decentraliserat kan du skapa ett konto på en annan server och fortfarande interagera med denna.", "closed_registrations.other_server_instructions": "Eftersom Mastodon är decentraliserat kan du skapa ett konto på en annan server och fortfarande interagera med denna.",
"closed_registrations_modal.description": "Det är för närvarande inte möjligt att skapa ett konto på {domain} men kom ihåg att du inte behöver ett konto specifikt på {domain} för att använda Mastodon.", "closed_registrations_modal.description": "Det är för närvarande inte möjligt att skapa ett konto på {domain} men kom ihåg att du inte behöver ett konto specifikt på {domain} för att använda Mastodon.",
"closed_registrations_modal.find_another_server": "Hitta en annan server", "closed_registrations_modal.find_another_server": "Hitta en annan server",

View File

@ -276,6 +276,8 @@
"callout.dismiss": "Bỏ qua", "callout.dismiss": "Bỏ qua",
"carousel.current": "<sr>Slide</sr> {current, number} / {max, number}", "carousel.current": "<sr>Slide</sr> {current, number} / {max, number}",
"carousel.slide": "Slide {current, number} trong {max, number}", "carousel.slide": "Slide {current, number} trong {max, number}",
"character_counter.recommended": "{currentLength}/{maxLength} ký tự đề xuất",
"character_counter.required": "{currentLength}/{maxLength} ký tự",
"closed_registrations.other_server_instructions": "Tạo tài khoản trên máy chủ khác và vẫn tương tác với máy chủ này.", "closed_registrations.other_server_instructions": "Tạo tài khoản trên máy chủ khác và vẫn tương tác với máy chủ này.",
"closed_registrations_modal.description": "{domain} hiện tắt đăng ký, nhưng hãy lưu ý rằng bạn không cần một tài khoản riêng trên {domain} để sử dụng Mastodon.", "closed_registrations_modal.description": "{domain} hiện tắt đăng ký, nhưng hãy lưu ý rằng bạn không cần một tài khoản riêng trên {domain} để sử dụng Mastodon.",
"closed_registrations_modal.find_another_server": "Tìm máy chủ khác", "closed_registrations_modal.find_another_server": "Tìm máy chủ khác",

View File

@ -276,6 +276,8 @@
"callout.dismiss": "忽略", "callout.dismiss": "忽略",
"carousel.current": "<sr>幻灯片</sr> {current, number} / {max, number}", "carousel.current": "<sr>幻灯片</sr> {current, number} / {max, number}",
"carousel.slide": "第 {current, number} 张幻灯片,共 {max, number} 张", "carousel.slide": "第 {current, number} 张幻灯片,共 {max, number} 张",
"character_counter.recommended": "{currentLength}/{maxLength} 个推荐字符",
"character_counter.required": "{currentLength}/{maxLength} 个字",
"closed_registrations.other_server_instructions": "基于 Mastodon 去中心化的特性,你可以其他服务器上创建账号,并继续与此服务器互动。", "closed_registrations.other_server_instructions": "基于 Mastodon 去中心化的特性,你可以其他服务器上创建账号,并继续与此服务器互动。",
"closed_registrations_modal.description": "你目前无法在 {domain} 上创建账号,但请注意,使用 Mastodon 并非需要专门在 {domain} 上注册账号。", "closed_registrations_modal.description": "你目前无法在 {domain} 上创建账号,但请注意,使用 Mastodon 并非需要专门在 {domain} 上注册账号。",
"closed_registrations_modal.find_another_server": "查找其他服务器", "closed_registrations_modal.find_another_server": "查找其他服务器",

View File

@ -276,6 +276,8 @@
"callout.dismiss": "關閉", "callout.dismiss": "關閉",
"carousel.current": "<sr>頁面</sr> {current, number} / {max, number}", "carousel.current": "<sr>頁面</sr> {current, number} / {max, number}",
"carousel.slide": "{max, number} 頁中之第 {current, number} 頁", "carousel.slide": "{max, number} 頁中之第 {current, number} 頁",
"character_counter.recommended": "{currentLength}/{maxLength} 個建議字數",
"character_counter.required": "{currentLength}/{maxLength} 個字",
"closed_registrations.other_server_instructions": "因為 Mastodon 是去中心化的,所以您也能於其他伺服器上建立帳號,並仍然與此伺服器互動。", "closed_registrations.other_server_instructions": "因為 Mastodon 是去中心化的,所以您也能於其他伺服器上建立帳號,並仍然與此伺服器互動。",
"closed_registrations_modal.description": "目前無法於 {domain} 建立新帳號,但也請別忘了,您並不一定需要有 {domain} 伺服器的帳號,也能使用 Mastodon。", "closed_registrations_modal.description": "目前無法於 {domain} 建立新帳號,但也請別忘了,您並不一定需要有 {domain} 伺服器的帳號,也能使用 Mastodon。",
"closed_registrations_modal.find_another_server": "尋找另一個伺服器", "closed_registrations_modal.find_another_server": "尋找另一個伺服器",

View File

@ -12,6 +12,7 @@ import {
apiPostFeaturedTag, apiPostFeaturedTag,
} from '@/mastodon/api/accounts'; } from '@/mastodon/api/accounts';
import { apiGetSearch } from '@/mastodon/api/search'; import { apiGetSearch } from '@/mastodon/api/search';
import type { ApiAccountFieldJSON } from '@/mastodon/api_types/accounts';
import type { import type {
ApiProfileJSON, ApiProfileJSON,
ApiProfileUpdateParams, ApiProfileUpdateParams,
@ -23,20 +24,25 @@ import type {
import type { AppDispatch } from '@/mastodon/store'; import type { AppDispatch } from '@/mastodon/store';
import { import {
createAppAsyncThunk, createAppAsyncThunk,
createAppSelector,
createDataLoadingThunk, createDataLoadingThunk,
} from '@/mastodon/store/typed_functions'; } from '@/mastodon/store/typed_functions';
import { hashObjectArray } from '@/mastodon/utils/hash';
import type { SnakeToCamelCase } from '@/mastodon/utils/types'; import type { SnakeToCamelCase } from '@/mastodon/utils/types';
type ProfileData = { type ProfileData = {
[Key in keyof Omit< [Key in keyof Omit<
ApiProfileJSON, ApiProfileJSON,
'note' | 'featured_tags' 'note' | 'fields' | 'featured_tags'
> as SnakeToCamelCase<Key>]: ApiProfileJSON[Key]; > as SnakeToCamelCase<Key>]: ApiProfileJSON[Key];
} & { } & {
bio: ApiProfileJSON['note']; bio: ApiProfileJSON['note'];
fields: FieldData[];
featuredTags: TagData[]; featuredTags: TagData[];
}; };
export type FieldData = ApiAccountFieldJSON & { id: string };
export type TagData = { export type TagData = {
[Key in keyof Omit< [Key in keyof Omit<
ApiFeaturedTagJSON, ApiFeaturedTagJSON,
@ -186,7 +192,7 @@ const transformProfile = (result: ApiProfileJSON): ProfileData => ({
id: result.id, id: result.id,
displayName: result.display_name, displayName: result.display_name,
bio: result.note, bio: result.note,
fields: result.fields, fields: hashObjectArray(result.fields),
avatar: result.avatar, avatar: result.avatar,
avatarStatic: result.avatar_static, avatarStatic: result.avatar_static,
avatarDescription: result.avatar_description, avatarDescription: result.avatar_description,
@ -218,6 +224,83 @@ export const patchProfile = createDataLoadingThunk(
{ useLoadingBar: false }, { useLoadingBar: false },
); );
export const selectFieldById = createAppSelector(
[(state) => state.profileEdit.profile?.fields, (_, id?: string) => id],
(fields, fieldId) => {
if (!fields || !fieldId) {
return undefined;
}
return fields.find((field) => field.id === fieldId) ?? null;
},
);
export const updateField = createAppAsyncThunk(
`${profileEditSlice.name}/updateField`,
async (
arg: { id?: string; name: string; value: string },
{ getState, dispatch },
) => {
const fields = getState().profileEdit.profile?.fields;
if (!fields) {
throw new Error('Profile fields not found');
}
const maxFields = getState().server.getIn([
'server',
'configuration',
'accounts',
'max_fields',
]) as number | undefined;
if (maxFields && fields.length >= maxFields && !arg.id) {
throw new Error('Maximum number of profile fields reached');
}
// Replace the field data if there is an ID, otherwise append a new field.
const newFields: Pick<ApiAccountFieldJSON, 'name' | 'value'>[] = [];
for (const field of fields) {
if (field.id === arg.id) {
newFields.push({ name: arg.name, value: arg.value });
} else {
newFields.push({ name: field.name, value: field.value });
}
}
if (!arg.id) {
newFields.push({ name: arg.name, value: arg.value });
}
await dispatch(
patchProfile({
fields_attributes: newFields,
}),
);
},
);
export const removeField = createAppAsyncThunk(
`${profileEditSlice.name}/removeField`,
async (arg: { key: string }, { getState, dispatch }) => {
const fields = getState().profileEdit.profile?.fields;
if (!fields) {
throw new Error('Profile fields not found');
}
const field = fields.find((f) => f.id === arg.key);
if (!field) {
throw new Error('Field not found');
}
const newFields = fields
.filter((f) => f.id !== arg.key)
.map((f) => ({
name: f.name,
value: f.value,
}));
await dispatch(
patchProfile({
fields_attributes: newFields,
}),
);
},
);
export const fetchFeaturedTags = createDataLoadingThunk( export const fetchFeaturedTags = createDataLoadingThunk(
`${profileEditSlice.name}/fetchFeaturedTags`, `${profileEditSlice.name}/fetchFeaturedTags`,
apiGetCurrentFeaturedTags, apiGetCurrentFeaturedTags,

View File

@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { cyrb32, hashObjectArray } from './hash';
describe('cyrb32', () => {
const input = 'mastodon';
it('returns a base-36 lowercase 1-6 character string', () => {
const hash = cyrb32(input);
expect(hash).toMatch(/^[0-9a-z]{1,6}$/);
});
it('returns the same output for same input and seed', () => {
const a = cyrb32(input, 1);
const b = cyrb32(input, 1);
expect(a).toBe(b);
});
it('produces different hashes for different seeds', () => {
const a = cyrb32(input, 1);
const b = cyrb32(input, 2);
expect(a).not.toBe(b);
});
});
describe('hashObjectArray', () => {
const input = [
{ name: 'Alice', value: 'Developer' },
{ name: 'Bob', value: 'Designer' },
{ name: 'Alice', value: 'Developer' }, // Duplicate
];
it('returns an array of the same length with unique hash keys', () => {
const result = hashObjectArray(input);
expect(result).toHaveLength(input.length);
const ids = result.map((obj) => obj.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(ids.length);
});
it('allows custom key names for the hash', () => {
const result = hashObjectArray(input, 'hashKey');
expect(result[0]).toHaveProperty('hashKey');
});
});

View File

@ -0,0 +1,39 @@
/**
* Fast insecure hash function.
* @param str String to hash.
* @param seed Optional seed value for different hash outputs of the same string.
* @returns Base-36 hash (1-6 characters, typically 5-6).
*/
export function cyrb32(str: string, seed = 0) {
let h1 = 0xdeadbeef ^ seed;
for (let i = 0; i < str.length; i++) {
h1 = Math.imul(h1 ^ str.charCodeAt(i), 0x9e3779b1);
}
return ((h1 ^ (h1 >>> 16)) >>> 0).toString(36);
}
/**
* Hashes an array of objects into a new array where each object has a unique hash key.
* @param array Array of objects to hash.
* @param key Key name to use for the hash in the resulting objects (default: 'id').
*/
export function hashObjectArray<
TObj extends object,
TKey extends string = 'id',
>(array: TObj[], key = 'id' as TKey): (TObj & Record<TKey, string>)[] {
const keySet = new Set<string>();
return array.map((obj) => {
const json = JSON.stringify(obj);
let seed = 0;
let hash = cyrb32(json, seed);
while (keySet.has(hash)) {
hash = cyrb32(json, ++seed);
}
keySet.add(hash);
return {
...obj,
[key]: hash,
} as TObj & Record<TKey, string>;
});
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m260-520 220-360 220 360H260ZM700-80q-75 0-127.5-52.5T520-260q0-75 52.5-127.5T700-440q75 0 127.5 52.5T880-260q0 75-52.5 127.5T700-80Zm-580-20v-320h320v320H120Z"/></svg>

After

Width:  |  Height:  |  Size: 265 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m260-520 220-360 220 360H260ZM700-80q-75 0-127.5-52.5T520-260q0-75 52.5-127.5T700-440q75 0 127.5 52.5T880-260q0 75-52.5 127.5T700-80Zm-580-20v-320h320v320H120Zm580-60q42 0 71-29t29-71q0-42-29-71t-71-29q-42 0-71 29t-29 71q0 42 29 71t71 29Zm-500-20h160v-160H200v160Zm202-420h156l-78-126-78 126Zm78 0ZM360-340Zm340 80Z"/></svg>

After

Width:  |  Height:  |  Size: 421 B

View File

@ -2009,7 +2009,10 @@ body > [data-popper-placement] {
.account { .account {
padding: 16px; padding: 16px;
border-bottom: 1px solid var(--color-border-primary);
&:not(&--without-border) {
border-bottom: 1px solid var(--color-border-primary);
}
.account__display-name { .account__display-name {
flex: 1 1 auto; flex: 1 1 auto;

View File

@ -410,6 +410,11 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
return unless poll.present? && poll.expires_at.present? && poll.votes.exists? return unless poll.present? && poll.expires_at.present? && poll.votes.exists?
# If the poll had previously expired, notifications should have already been sent out (or scheduled),
# and re-scheduling them would cause duplicate notifications for people who had already dismissed them
# (see #37948)
return if @previous_expires_at&.past?
PollExpirationNotifyWorker.remove_from_scheduled(poll.id) if @previous_expires_at.present? && @previous_expires_at > poll.expires_at PollExpirationNotifyWorker.remove_from_scheduled(poll.id) if @previous_expires_at.present? && @previous_expires_at > poll.expires_at
PollExpirationNotifyWorker.perform_at(poll.expires_at + 5.minutes, poll.id) PollExpirationNotifyWorker.perform_at(poll.expires_at + 5.minutes, poll.id)
end end

View File

@ -4,7 +4,7 @@ class ResolveURLService < BaseService
include JsonLdHelper include JsonLdHelper
include Authorization include Authorization
USERNAME_STATUS_RE = %r{/@(?<username>#{Account::USERNAME_RE})/(?<status_id>[0-9]+)\Z} USERNAME_STATUS_RE = %r{/@(?<username>#{Account::USERNAME_RE})/(statuses/)?(?<status_id>[0-9a-zA-Z]+)\Z}
def call(url, on_behalf_of: nil) def call(url, on_behalf_of: nil)
@url = url @url = url

View File

@ -1,7 +1,7 @@
<%= t 'devise.mailer.two_factor_enabled.title' %> <%= t 'devise.mailer.webauthn_credential.added.title' %>
=== ===
<%= t 'devise.mailer.two_factor_enabled.explanation' %> <%= t 'devise.mailer.webauthn_credential.added.explanation' %>
=> <%= edit_user_registration_url %> => <%= edit_user_registration_url %>

View File

@ -1,7 +1,7 @@
<%= t 'devise.mailer.webauthn_credential.added.title' %> <%= t 'devise.mailer.webauthn_enabled.title' %>
=== ===
<%= t 'devise.mailer.webauthn_credential.added.explanation' %> <%= t 'devise.mailer.webauthn_enabled.explanation' %>
=> <%= edit_user_registration_url %> => <%= edit_user_registration_url %>

View File

@ -84,9 +84,9 @@ es-AR:
credential_flow_not_configured: Las credenciales de contraseña del propietario del recurso fallaron debido a que "Doorkeeper.configure.resource_owner_from_credentials" está sin configurar. credential_flow_not_configured: Las credenciales de contraseña del propietario del recurso fallaron debido a que "Doorkeeper.configure.resource_owner_from_credentials" está sin configurar.
invalid_client: La autenticación del cliente falló debido a que es un cliente desconocido, o no está incluída la autenticación del cliente, o el método de autenticación no está soportado. invalid_client: La autenticación del cliente falló debido a que es un cliente desconocido, o no está incluída la autenticación del cliente, o el método de autenticación no está soportado.
invalid_code_challenge_method: invalid_code_challenge_method:
one: El code_challenge_method debe ser %{challenge_methods}. one: "«code_challenge_method» debe ser %{challenge_methods}."
other: El code_challenge_method debe ser uno de %{challenge_methods}. other: "«code_challenge_method» debe ser uno de %{challenge_methods}."
zero: El servidor de autorización no soporta PKCE, ya que no hay valores aceptados de code_challenge_method. zero: El servidor de autorización no es compatible con PKCE, ya que no hay valores aceptados de «code_challenge_method».
invalid_grant: La concesión de autorización ofrecida no es válida, venció, se revocó, no coincide con la dirección web de redireccionamiento usada en la petición de autorización, o fue emitida para otro cliente. invalid_grant: La concesión de autorización ofrecida no es válida, venció, se revocó, no coincide con la dirección web de redireccionamiento usada en la petición de autorización, o fue emitida para otro cliente.
invalid_redirect_uri: La dirección web de redireccionamiento incluida no es válida. invalid_redirect_uri: La dirección web de redireccionamiento incluida no es válida.
invalid_request: invalid_request:

View File

@ -83,6 +83,10 @@ hu:
access_denied: Az erőforrás tulajdonosa vagy az engedélyező kiszolgáló elutasította a kérést. access_denied: Az erőforrás tulajdonosa vagy az engedélyező kiszolgáló elutasította a kérést.
credential_flow_not_configured: Az erőforrás tulajdonos jelszóadatainak átadása megszakadt, mert a Doorkeeper.configure.resource_owner_from_credentials beállítatlan. credential_flow_not_configured: Az erőforrás tulajdonos jelszóadatainak átadása megszakadt, mert a Doorkeeper.configure.resource_owner_from_credentials beállítatlan.
invalid_client: A kliens hitelesítése megszakadt, mert ismeretlen a kliens, a kliens nem küldött hitelesítést, vagy a hitelesítés módja nem támogatott. invalid_client: A kliens hitelesítése megszakadt, mert ismeretlen a kliens, a kliens nem küldött hitelesítést, vagy a hitelesítés módja nem támogatott.
invalid_code_challenge_method:
one: 'A code_challange_method értékének ennek kell lennie: %{challenge_methods}.'
other: 'A code_challange_method értékének ennek egyikének kell lennie: %{challenge_methods}.'
zero: A hitelesítési kiszolgáló nem támogatja a PKCE-t, mivel nincs elfogadott code_challange_method érték.
invalid_grant: A biztosított hitelesítés érvénytelen, lejárt, visszavont, vagy nem egyezik a hitelesítési kérésben használt URI-val, vagy más kliensnek címezték. invalid_grant: A biztosított hitelesítés érvénytelen, lejárt, visszavont, vagy nem egyezik a hitelesítési kérésben használt URI-val, vagy más kliensnek címezték.
invalid_redirect_uri: Az átirányító URI nem valós. invalid_redirect_uri: Az átirányító URI nem valós.
invalid_request: invalid_request:

View File

@ -83,6 +83,10 @@ pt-PT:
access_denied: O proprietário do recurso ou servidor de autorização negou o pedido. access_denied: O proprietário do recurso ou servidor de autorização negou o pedido.
credential_flow_not_configured: Falha no fluxo de credenciais da palavra-passe do proprietário do recurso porque Doorkeeper.configure.resource_owner_from_credentials não está configurado. credential_flow_not_configured: Falha no fluxo de credenciais da palavra-passe do proprietário do recurso porque Doorkeeper.configure.resource_owner_from_credentials não está configurado.
invalid_client: A autenticação do cliente falhou devido a cliente desconhecido, sem autenticação de cliente incluída ou método de autenticação não suportado. invalid_client: A autenticação do cliente falhou devido a cliente desconhecido, sem autenticação de cliente incluída ou método de autenticação não suportado.
invalid_code_challenge_method:
one: O code_challenge_method deve ser %{challenge_methods}.
other: O code_challenge_method deve ser um de %{challenge_methods}.
zero: O servidor de autorização não suporta PKCE pois não há valores aceites para code_challenge_method.
invalid_grant: A concessão de autorização fornecida é inválida, expirou, foi revogada, não corresponde à URI de redirecionamento usada no pedido de autorização ou foi emitida para outro cliente. invalid_grant: A concessão de autorização fornecida é inválida, expirou, foi revogada, não corresponde à URI de redirecionamento usada no pedido de autorização ou foi emitida para outro cliente.
invalid_redirect_uri: A URI de redirecionamento incluída não é válida. invalid_redirect_uri: A URI de redirecionamento incluída não é válida.
invalid_request: invalid_request:

View File

@ -1468,10 +1468,10 @@ el:
add_keyword: Προσθήκη λέξης-κλειδιού add_keyword: Προσθήκη λέξης-κλειδιού
keywords: Λέξεις-κλειδιά keywords: Λέξεις-κλειδιά
statuses: Μεμονωμένες αναρτήσεις statuses: Μεμονωμένες αναρτήσεις
statuses_hint_html: Αυτό το φίλτρο εφαρμόζεται για την επιλογή μεμονωμένων αναρτήσεων, ανεξάρτητα από το αν ταιριάζουν με τις λέξεις - κλειδιά παρακάτω. <a href="%{path}">Επισκόπηση ή αφαίρεση αναρτήσεων από το φίλτρο</a>. statuses_hint_html: Αυτό το φίλτρο εφαρμόζεται για την επιλογή μεμονωμένων αναρτήσεων, ανεξάρτητα από το αν αντιστοιχούν με τις λέξεις-κλειδιά παρακάτω. <a href="%{path}">Επισκόπηση ή αφαίρεση αναρτήσεων από το φίλτρο</a>.
title: Επεξεργασία φίλτρου title: Επεξεργασία φίλτρου
errors: errors:
deprecated_api_multiple_keywords: Αυτές οι παράμετροι δεν μπορούν να αλλάξουν από αυτήν την εφαρμογή επειδή ισχύουν για περισσότερες από μία λέξεις - κλειδιά φίλτρου. Χρησιμοποίησε μια πιο πρόσφατη εφαρμογή ή την ιστοσελίδα. deprecated_api_multiple_keywords: Αυτές οι παράμετροι δεν μπορούν να αλλάξουν από αυτήν την εφαρμογή επειδή ισχύουν για περισσότερες από μία λέξεις-κλειδιά φίλτρου. Χρησιμοποίησε μια πιο πρόσφατη εφαρμογή ή την ιστοσελίδα.
invalid_context: Δόθηκε κενό ή μη έγκυρο περιεχόμενο invalid_context: Δόθηκε κενό ή μη έγκυρο περιεχόμενο
index: index:
contexts: Φίλτρα σε %{contexts} contexts: Φίλτρα σε %{contexts}
@ -1480,8 +1480,8 @@ el:
expires_in: Λήγει σε %{distance} expires_in: Λήγει σε %{distance}
expires_on: Λήγει στις %{date} expires_on: Λήγει στις %{date}
keywords: keywords:
one: "%{count} λέξη - κλειδί" one: "%{count} λέξη-κλειδί"
other: "%{count} λέξεις - κλειδιά" other: "%{count} λέξεις-κλειδιά"
statuses: statuses:
one: "%{count} ανάρτηση" one: "%{count} ανάρτηση"
other: "%{count} αναρτήσεις" other: "%{count} αναρτήσεις"
@ -1505,8 +1505,8 @@ el:
one: "<strong>%{count}</strong> στοιχείο σε αυτή τη σελίδα είναι επιλεγμένο." one: "<strong>%{count}</strong> στοιχείο σε αυτή τη σελίδα είναι επιλεγμένο."
other: Όλα τα <strong>%{count}</strong> στοιχεία σε αυτή τη σελίδα είναι επιλεγμένα. other: Όλα τα <strong>%{count}</strong> στοιχεία σε αυτή τη σελίδα είναι επιλεγμένα.
all_matching_items_selected_html: all_matching_items_selected_html:
one: "<strong>%{count}</strong> στοιχείο που ταιριάζει με την αναζήτησή σου είναι επιλεγμένο." one: "<strong>%{count}</strong> στοιχείο που αντιστοιχεί με την αναζήτησή σου είναι επιλεγμένο."
other: Όλα τα <strong>%{count}</strong> στοιχεία που ταιριάζουν στην αναζήτησή σου είναι επιλεγμένα. other: Όλα τα <strong>%{count}</strong> στοιχεία που αντιστοιχούν με την αναζήτησή σου είναι επιλεγμένα.
cancel: Άκυρο cancel: Άκυρο
changes_saved_msg: Οι αλλαγές αποθηκεύτηκαν! changes_saved_msg: Οι αλλαγές αποθηκεύτηκαν!
confirm: Επιβεβαίωση confirm: Επιβεβαίωση
@ -1517,8 +1517,8 @@ el:
order_by: Ταξινόμηση κατά order_by: Ταξινόμηση κατά
save_changes: Αποθήκευση αλλαγών save_changes: Αποθήκευση αλλαγών
select_all_matching_items: select_all_matching_items:
one: Επέλεξε %{count} στοιχείο που ταιριάζει με την αναζήτησή σου. one: Επέλεξε %{count} στοιχείο που αντιστοιχεί με την αναζήτησή σου.
other: Επέλεξε και τα %{count} αντικείμενα που ταιριάζουν στην αναζήτησή σου. other: Επέλεξε όλα τα %{count} αντικείμενα που αντιστοιχούν με την αναζήτησή σου.
today: σήμερα today: σήμερα
validation_errors: validation_errors:
one: Κάτι δεν πάει καλά! Για κοίταξε το παρακάτω σφάλμα one: Κάτι δεν πάει καλά! Για κοίταξε το παρακάτω σφάλμα
@ -1988,7 +1988,7 @@ el:
unlisted_long: Κρυμμένη από τα αποτελέσματα αναζήτησης Mastodon, τις τάσεις και τις δημόσιες ροές unlisted_long: Κρυμμένη από τα αποτελέσματα αναζήτησης Mastodon, τις τάσεις και τις δημόσιες ροές
statuses_cleanup: statuses_cleanup:
enabled: Αυτόματη διαγραφή παλιών αναρτήσεων enabled: Αυτόματη διαγραφή παλιών αναρτήσεων
enabled_hint: Διαγράφει αυτόματα τις αναρτήσεις σου μόλις φτάσουν σε ένα καθορισμένο όριο ηλικίας, εκτός αν ταιριάζουν με μία από τις παρακάτω εξαιρέσεις enabled_hint: Διαγράφει αυτόματα τις αναρτήσεις σου μόλις φτάσουν σε ένα καθορισμένο όριο ηλικίας, εκτός αν αντιστοιχούν με μία από τις παρακάτω εξαιρέσεις
exceptions: Εξαιρέσεις exceptions: Εξαιρέσεις
explanation: Η αυτόματη διαγραφή εκτελείται με χαμηλή προτεραιότητα. Μπορεί να υπάρξει καθυστέρηση μεταξύ της επίτευξης του ορίου ηλικίας και της διαγραφής. explanation: Η αυτόματη διαγραφή εκτελείται με χαμηλή προτεραιότητα. Μπορεί να υπάρξει καθυστέρηση μεταξύ της επίτευξης του ορίου ηλικίας και της διαγραφής.
ignore_favs: Αγνόηση αγαπημένων ignore_favs: Αγνόηση αγαπημένων
@ -2027,7 +2027,7 @@ el:
errors: errors:
too_late: Είναι πολύ αργά για να κάνεις έφεση σε αυτό το παράπτωμα too_late: Είναι πολύ αργά για να κάνεις έφεση σε αυτό το παράπτωμα
tags: tags:
does_not_match_previous_name: δεν ταιριάζει με το προηγούμενο όνομα does_not_match_previous_name: δεν αντιστοιχεί με το προηγούμενο όνομα
terms_of_service: terms_of_service:
title: Όροι Παροχής Υπηρεσιών title: Όροι Παροχής Υπηρεσιών
terms_of_service_interstitial: terms_of_service_interstitial:

View File

@ -146,8 +146,8 @@ hu:
security_measures: security_measures:
only_password: Csak jelszó only_password: Csak jelszó
password_and_2fa: Jelszó és kétlépcsős hitelesítés password_and_2fa: Jelszó és kétlépcsős hitelesítés
sensitive: Kényes sensitive: Érzékeny
sensitized: Kényesnek jelölve sensitized: Érzékenynek jelölve
shared_inbox_url: Megosztott bejövő üzenetek URL shared_inbox_url: Megosztott bejövő üzenetek URL
show: show:
created_reports: Létrehozott jelentések created_reports: Létrehozott jelentések
@ -165,7 +165,7 @@ hu:
unblock_email: E-mail-cím tiltásának feloldása unblock_email: E-mail-cím tiltásának feloldása
unblocked_email_msg: A(z) %{username} e-mail-cím tiltása sikeresen feloldva unblocked_email_msg: A(z) %{username} e-mail-cím tiltása sikeresen feloldva
unconfirmed_email: Nem megerősített e-mail unconfirmed_email: Nem megerősített e-mail
undo_sensitized: Kényesnek jelölés visszavonása undo_sensitized: Érzékenynek jelölés visszavonása
undo_silenced: Némítás visszavonása undo_silenced: Némítás visszavonása
undo_suspension: Felfüggesztés visszavonása undo_suspension: Felfüggesztés visszavonása
unsilenced_msg: A %{username} fiók korlátozásait sikeresen levettük unsilenced_msg: A %{username} fiók korlátozásait sikeresen levettük
@ -267,6 +267,7 @@ hu:
demote_user_html: "%{name} lefokozta %{target} felhasználót" demote_user_html: "%{name} lefokozta %{target} felhasználót"
destroy_announcement_html: "%{name} törölte a %{target} közleményt" destroy_announcement_html: "%{name} törölte a %{target} közleményt"
destroy_canonical_email_block_html: "%{name} engedélyezte a(z) %{target} hashű e-mailt" destroy_canonical_email_block_html: "%{name} engedélyezte a(z) %{target} hashű e-mailt"
destroy_collection_html: "%{name} eltávolította %{target} felhasználó gyűjteményét"
destroy_custom_emoji_html: "%{name} törölte a(z) %{target} emodzsit" destroy_custom_emoji_html: "%{name} törölte a(z) %{target} emodzsit"
destroy_domain_allow_html: "%{name} letiltotta a föderációt a %{target} domainnel" destroy_domain_allow_html: "%{name} letiltotta a föderációt a %{target} domainnel"
destroy_domain_block_html: "%{name} engedélyezte a %{target} domaint" destroy_domain_block_html: "%{name} engedélyezte a %{target} domaint"
@ -306,6 +307,7 @@ hu:
unsilence_account_html: "%{name} feloldotta a némítást %{target} felhasználói fiókján" unsilence_account_html: "%{name} feloldotta a némítást %{target} felhasználói fiókján"
unsuspend_account_html: "%{name} feloldotta %{target} felhasználói fiókjának felfüggesztését" unsuspend_account_html: "%{name} feloldotta %{target} felhasználói fiókjának felfüggesztését"
update_announcement_html: "%{name} frissítette a %{target} közleményt" update_announcement_html: "%{name} frissítette a %{target} közleményt"
update_collection_html: "%{name} frissítette %{target} felhasználó gyűjteményét"
update_custom_emoji_html: "%{name} frissítette az emodzsit: %{target}" update_custom_emoji_html: "%{name} frissítette az emodzsit: %{target}"
update_domain_block_html: "%{name} frissítette a %{target} domain tiltását" update_domain_block_html: "%{name} frissítette a %{target} domain tiltását"
update_ip_block_html: "%{name} módosította a(z) %{target} IP-címre vonatkozó szabályt" update_ip_block_html: "%{name} módosította a(z) %{target} IP-címre vonatkozó szabályt"
@ -689,6 +691,7 @@ hu:
cancel: Mégse cancel: Mégse
category: Kategória category: Kategória
category_description_html: A fiók vagy tartalom bejelentésének oka a jelentett fiókkal kapcsolatos kommunikációban idézve lesz category_description_html: A fiók vagy tartalom bejelentésének oka a jelentett fiókkal kapcsolatos kommunikációban idézve lesz
collections: Gyûjtemények (%{count})
comment: comment:
none: Egyik sem none: Egyik sem
comment_description_html: 'Hogy további információkat adjon, %{name} ezt írta:' comment_description_html: 'Hogy további információkat adjon, %{name} ezt írta:'
@ -724,6 +727,7 @@ hu:
resolved_msg: A bejelentést sikeresen megoldottuk! resolved_msg: A bejelentést sikeresen megoldottuk!
skip_to_actions: Tovább az intézkedésekhez skip_to_actions: Tovább az intézkedésekhez
status: Állapot status: Állapot
statuses: Bejegyzések (%{count})
statuses_description_html: A sértő tartalmat idézni fogjuk a bejelentett fiókkal való kommunikáció során statuses_description_html: A sértő tartalmat idézni fogjuk a bejelentett fiókkal való kommunikáció során
summary: summary:
action_preambles: action_preambles:

View File

@ -69,7 +69,7 @@ lv:
domain: Domēns domain: Domēns
edit: Labot edit: Labot
email: E-pasts email: E-pasts
email_status: E-pasta statuss email_status: E-pasta stāvoklis
enable: Atsaldēt enable: Atsaldēt
enable_sign_in_token_auth: Iespējot autentificēšanos ar e-pasta pilnvaru enable_sign_in_token_auth: Iespējot autentificēšanos ar e-pasta pilnvaru
enabled: Iespējots enabled: Iespējots
@ -84,10 +84,10 @@ lv:
joined: Pievienojies joined: Pievienojies
location: location:
all: Visi all: Visi
local: Vietējie local: Vietēji
remote: Attālinātie remote: Attāli
title: Atrašanās vieta title: Atrašanās vieta
login_status: Pieteikšanās statuss login_status: Pieteikšanās stāvoklis
media_attachments: Multivides pielikumi media_attachments: Multivides pielikumi
memorialize: Pārvērst atmiņās memorialize: Pārvērst atmiņās
memorialized: Piemiņa saglabāta memorialized: Piemiņa saglabāta
@ -464,8 +464,8 @@ lv:
no_email_domain_block_selected: Neviens e-pasta domēna bloks netika mainīts, jo neviens netika atlasīts no_email_domain_block_selected: Neviens e-pasta domēna bloks netika mainīts, jo neviens netika atlasīts
not_permitted: Nav atļauta not_permitted: Nav atļauta
resolved_dns_records_hint_html: Domēna vārds saistās ar zemāk norādītajiem MX domēniem, kuri beigās ir atbildīgi par e-pasta pieņemšana. MX domēna liegšana liegs reģistrēšanos no jebkuras e-pasta adreses, kas izmanto to pašu MX domēnu, pat ja redzamais domēna vārds ir atšķirīgs. <strong>Jāuzmanās, lai neliegtu galvenos e-pasta pakalpojuma sniedzējus.</strong> resolved_dns_records_hint_html: Domēna vārds saistās ar zemāk norādītajiem MX domēniem, kuri beigās ir atbildīgi par e-pasta pieņemšana. MX domēna liegšana liegs reģistrēšanos no jebkuras e-pasta adreses, kas izmanto to pašu MX domēnu, pat ja redzamais domēna vārds ir atšķirīgs. <strong>Jāuzmanās, lai neliegtu galvenos e-pasta pakalpojuma sniedzējus.</strong>
resolved_through_html: Atrisināts, izmantojot %{domain} resolved_through_html: Atrisināts no %{domain}
title: Bloķētie e-pasta domēni title: Liegtie e-pasta domēni
export_domain_allows: export_domain_allows:
new: new:
title: Importēt domēnu atļaujas title: Importēt domēnu atļaujas
@ -514,7 +514,7 @@ lv:
follow_recommendations: follow_recommendations:
description_html: "<strong>Sekošanas ieteikumi palīdz jauniem lietotājiem ātri arast saistošu saturu</strong>. Kad lietotājs nav pietiekami mijiedarbojies ar citiem, lai veidotos pielāgoti sekošanas iteikumi, tiek ieteikti šie konti. Tie tiek pārskaitļoti ik dienas, izmantojot kontu, kuriem ir augstākās nesenās iesaistīšanās un lielākais vietējo sekotāju skaits norādītajā valodā." description_html: "<strong>Sekošanas ieteikumi palīdz jauniem lietotājiem ātri arast saistošu saturu</strong>. Kad lietotājs nav pietiekami mijiedarbojies ar citiem, lai veidotos pielāgoti sekošanas iteikumi, tiek ieteikti šie konti. Tie tiek pārskaitļoti ik dienas, izmantojot kontu, kuriem ir augstākās nesenās iesaistīšanās un lielākais vietējo sekotāju skaits norādītajā valodā."
language: Valodai language: Valodai
status: Statuss status: Stāvoklis
suppress: Apspiest sekošanas rekomendāciju suppress: Apspiest sekošanas rekomendāciju
suppressed: Apspiestie suppressed: Apspiestie
title: Sekošanas ieteikumi title: Sekošanas ieteikumi
@ -615,8 +615,8 @@ lv:
'94670856': 3 gadi '94670856': 3 gadi
new: new:
title: Izveidot jaunu IP noteikumu title: Izveidot jaunu IP noteikumu
no_ip_block_selected: Neviens IP noteikums netika mainīts, jo netika atlasīts no_ip_block_selected: Neviena IP kārtula netika mainīta, jo neviena netika atlasīta
title: IP noteikumi title: IP kārtulas
relationships: relationships:
title: "%{acct} attiecības" title: "%{acct} attiecības"
relays: relays:
@ -633,7 +633,7 @@ lv:
save_and_enable: Saglabāt un iespējot save_and_enable: Saglabāt un iespējot
setup: Iestatīt releja savienojumu setup: Iestatīt releja savienojumu
signatures_not_enabled: Releji nedarbosies pareizi, kamēr ir iespējots drošais režīms vai ierobežotas federācijas režīms signatures_not_enabled: Releji nedarbosies pareizi, kamēr ir iespējots drošais režīms vai ierobežotas federācijas režīms
status: Statuss status: Stāvoklis
title: Releji title: Releji
report_notes: report_notes:
created_msg: Ziņojuma piezīme sekmīgi izveidota. created_msg: Ziņojuma piezīme sekmīgi izveidota.
@ -697,34 +697,34 @@ lv:
reported_account: Ziņotais konts reported_account: Ziņotais konts
reported_by: Ziņoja reported_by: Ziņoja
reported_with_application: Ziņots no lietotnes reported_with_application: Ziņots no lietotnes
resolved: Atrisināts resolved: Atrisināti
resolved_msg: Ziņojums sekmīgi atrisināts. resolved_msg: Ziņojums sekmīgi atrisināts.
skip_to_actions: Pāriet uz darbībām skip_to_actions: Pāriet uz darbībām
status: Statuss status: Stāvoklis
statuses_description_html: Pārkāpuma saturs tiks minēts saziņā ar paziņoto kontu statuses_description_html: Pārkāpuma saturs tiks minēts saziņā ar paziņoto kontu
summary: summary:
action_preambles: action_preambles:
delete_html: 'Jūs gatavojaties <strong>noņemt</strong> dažas no lietotāja <strong>@%{acct}</strong> ziņām. Tas:' delete_html: 'Tu grasies <strong>noņemt</strong> dažus no <strong>@%{acct}</strong> ierakstiem. Tas:'
mark_as_sensitive_html: 'Tu gatavojies <strong>atzīmēt</strong> dažus no lietotāja <strong>@%{acct}</strong> ierakstiem kā <strong>jūtīgus</strong>. Tas:' mark_as_sensitive_html: 'Tu grasies <strong>atzīmēt</strong> dažus no <strong>@%{acct}</strong> ierakstiem kā <strong>jūtīgus</strong>. Tas:'
silence_html: 'Jūs gatavojaties <strong>ierobežot</strong> <strong>@%{acct}</strong> kontu. Tas:' silence_html: 'Tu grasies <strong>ierobežot</strong> <strong>@%{acct}</strong> kontu. Tas:'
suspend_html: 'Jūs gatavojaties <strong>apturēt</strong> <strong>@%{acct}</strong> kontu. Tas:' suspend_html: 'Tu grasies <strong>apturēt</strong> <strong>@%{acct}</strong> kontu. Tas:'
actions: actions:
delete_html: Noņemt aizskarošos ierakstus delete_html: Noņemt aizskarošos ierakstus
mark_as_sensitive_html: Atzīmēt aizskarošo ierakstu informācijas nesējus kā jūtīgus mark_as_sensitive_html: Atzīmēt aizskarošo ierakstu informācijas nesējus kā jūtīgus
silence_html: Ievērojami ierobežo <strong>@%{acct}</strong> sasniedzamību, padarot viņa profilu un saturu redzamu tikai cilvēkiem, kas jau seko tam vai pašrocīgi uzmeklē profilu silence_html: Ievērojami ierobežo <strong>@%{acct}</strong> sasniedzamību, padarot viņa profilu un saturu redzamu tikai cilvēkiem, kas jau seko tam vai pašrocīgi uzmeklē profilu
suspend_html: Apturēt <strong>@%{acct}</strong>, padarot viņu profilu un saturu nepieejamu un neiespējamu mijiedarbību ar suspend_html: apturēs <strong>@%{acct}</strong> darbību, padarot profilu un saturu nepieejamu un neiespējamu mijiedarboties ar to;
close_report: 'Atzīmēt ziņojumu #%{id} kā atrisinātu' close_report: 'Atzīmēt ziņojumu #%{id} kā atrisinātu'
close_reports_html: Atzīmējiet <strong>visus</strong> pārskatus par <strong>@%{acct}</strong> kā atrisinātus close_reports_html: atzīmēs <strong>visus</strong> ziņojumus par <strong>@%{acct}</strong> kā atrisinātus;
delete_data_html: Dzēsiet lietotāja <strong>@%{acct}</strong> profilu un saturu pēc 30 dienām, ja vien to darbība pa šo laiku netiks atcelta delete_data_html: izdzēsīs lietotāja <strong>@%{acct}</strong> profilu un saturu pēc 30 dienām, ja vien tā darbība šajā laikā netiks atjaunota;
preview_preamble_html: "<strong>@%{acct}</strong> saņems brīdinājumu ar šādu saturu:" preview_preamble_html: "<strong>@%{acct}</strong> saņems brīdinājumu ar šādu saturu:"
record_strike_html: Ierakstiet brīdinājumu pret <strong>@%{acct}</strong>, lai palīdzētu jums izvērst turpmākus pārkāpumus no šī konta record_strike_html: ierakstīs brīdinājumu par <strong>@%{acct}</strong>, lai palīdzētu virzīt turpmākus šī konta pārkāpumu izskatīšanu.
send_email_html: Nosūtīt <strong>@%{acct}</strong> brīdinājuma e-pasta ziņojumu send_email_html: Nosūtīt <strong>@%{acct}</strong> brīdinājuma e-pasta ziņojumu
warning_placeholder: Izvēles papildu pamatojums satura pārraudzības darbībai. warning_placeholder: Izvēles papildu pamatojums satura pārraudzības darbībai.
target_origin: Konta, par kuru ziņots, izcelsme target_origin: Konta, par kuru ziņots, izcelsme
title: Ziņojumi title: Ziņojumi
unassign: Atsaukt unassign: Atsaukt
unknown_action_msg: 'Nezināms konts: %{action}' unknown_action_msg: 'Nezināms konts: %{action}'
unresolved: Neatrisinātie unresolved: Neatrisināti
updated_at: Atjaunināts updated_at: Atjaunināts
view_profile: Skatīt profilu view_profile: Skatīt profilu
roles: roles:
@ -839,7 +839,7 @@ lv:
domain_blocks: domain_blocks:
all: Visiem all: Visiem
disabled: Nevienam disabled: Nevienam
users: Vietējiem reģistrētiem lietotājiem users: Vietējiem lietotājiem, kuri ir pieteikušies
registrations: registrations:
moderation_recommandation: Lūgums nodrošināt, ka Tev ir pieņemama un atsaucīga satura pārraudzības komanda, pirms padari reģistrēšanos visiem pieejamu. moderation_recommandation: Lūgums nodrošināt, ka Tev ir pieņemama un atsaucīga satura pārraudzības komanda, pirms padari reģistrēšanos visiem pieejamu.
preamble: Kontrolē, kurš var izveidot kontu tavā serverī. preamble: Kontrolē, kurš var izveidot kontu tavā serverī.
@ -921,9 +921,9 @@ lv:
elasticsearch_analysis_index_mismatch: elasticsearch_analysis_index_mismatch:
message_html: 'Elasticsearch indeksa analizatora iestatījumi ir novecojuši. Lūdzu, izpildiet šo komandu: <code>tootctl search deploy --only-mapping --only=%{value}</code>' message_html: 'Elasticsearch indeksa analizatora iestatījumi ir novecojuši. Lūdzu, izpildiet šo komandu: <code>tootctl search deploy --only-mapping --only=%{value}</code>'
elasticsearch_health_red: elasticsearch_health_red:
message_html: Elasticsearch klasteris ir neveselīgs (sarkans statuss), meklēšanas līdzekļi nav pieejami message_html: Elasticsearch kopa ir neveselīga (sarkans stāvoklis), meklēšanas līdzekļi nav pieejami
elasticsearch_health_yellow: elasticsearch_health_yellow:
message_html: Elasticsearch klasteris ir neveselīgs (dzeltens statuss), tu varētu vēlēties meklēt iemeslu message_html: Elasticsearch kopa ir neveselīga (dzeltens stāvoklis), varētu būt nepieciešams izmeklēt cēloni
elasticsearch_index_mismatch: elasticsearch_index_mismatch:
message_html: Elasticsearch indeksa kartējumi ir novecojuši. Lūdzu, palaid <code>tootctl search deploy --only=%{value}</code> message_html: Elasticsearch indeksa kartējumi ir novecojuši. Lūdzu, palaid <code>tootctl search deploy --only=%{value}</code>
elasticsearch_preset: elasticsearch_preset:
@ -1088,6 +1088,9 @@ lv:
zero: Pēdējās nedēļas laikā izmantoja %{count} cilvēku zero: Pēdējās nedēļas laikā izmantoja %{count} cilvēku
title: Ieteikumi un pašlaik populāri title: Ieteikumi un pašlaik populāri
trending: Populārākie trending: Populārākie
username_blocks:
no_username_block_selected: Neviena lietotājvārdu kārtula netika mainīta, jo neviena netika atlasīta
title: Lietotājvārdu kārtulas
warning_presets: warning_presets:
add_new: Pievienot jaunu add_new: Pievienot jaunu
delete: Dzēst delete: Dzēst
@ -1112,7 +1115,7 @@ lv:
new: Jauna tīmekļa aizķere new: Jauna tīmekļa aizķere
rotate_secret: Pagriezt noslēpumu rotate_secret: Pagriezt noslēpumu
secret: Paraksta noslēpums secret: Paraksta noslēpums
status: Statuss status: Stāvoklis
title: Tīmekļa āķi title: Tīmekļa āķi
webhook: Tīmekļa āķis webhook: Tīmekļa āķis
admin_mailer: admin_mailer:
@ -1254,7 +1257,7 @@ lv:
preamble: Ar kontu šajā Mastodon serverī varēsi sekot jebkuram citam cilvēkam fediversā, neatkarīgi no tā, kur tiek mitināts viņu konts. preamble: Ar kontu šajā Mastodon serverī varēsi sekot jebkuram citam cilvēkam fediversā, neatkarīgi no tā, kur tiek mitināts viņu konts.
title: Atļauj tevi iestatīt %{domain}. title: Atļauj tevi iestatīt %{domain}.
status: status:
account_status: Konta statuss account_status: Konta stāvoklis
confirming: Gaida e-pasta adreses apstiprināšanas pabeigšanu. confirming: Gaida e-pasta adreses apstiprināšanas pabeigšanu.
functional: Tavs konts ir pilnā darba kārtībā. functional: Tavs konts ir pilnā darba kārtībā.
pending: Tavs pieteikums ir rindā uz izskatīšanu, ko veic mūsu personāls. Tas var aizņemt kādu laiku. Tu saņemsi e-pasta ziņojumu, ja Tavs pieteikums tiks apstiprināts. pending: Tavs pieteikums ir rindā uz izskatīšanu, ko veic mūsu personāls. Tas var aizņemt kādu laiku. Tu saņemsi e-pasta ziņojumu, ja Tavs pieteikums tiks apstiprināts.
@ -1376,10 +1379,10 @@ lv:
in_progress: Notiek tava arhīva apkopošana... in_progress: Notiek tava arhīva apkopošana...
request: Pieprasi savu arhīvu request: Pieprasi savu arhīvu
size: Izmērs size: Izmērs
blocks: Bloķētie konti blocks: Tu liedzi
bookmarks: Grāmatzīmes bookmarks: Grāmatzīmes
csv: CSV csv: CSV
domain_blocks: Bloķētie domēni domain_blocks: Liegtie domēni
lists: Saraksti lists: Saraksti
mutes: Apklusinātie konti mutes: Apklusinātie konti
storage: Multividesu krātuve storage: Multividesu krātuve
@ -1405,7 +1408,7 @@ lv:
deprecated_api_multiple_keywords: Šos parametrus šajā lietojumprogrammā nevar mainīt, jo tie attiecas uz vairāk nekā vienu filtra atslēgvārdu. Izmanto jaunāku lietojumprogrammu vai tīmekļa saskarni. deprecated_api_multiple_keywords: Šos parametrus šajā lietojumprogrammā nevar mainīt, jo tie attiecas uz vairāk nekā vienu filtra atslēgvārdu. Izmanto jaunāku lietojumprogrammu vai tīmekļa saskarni.
invalid_context: Nav, vai piegādāts nederīgs konteksts invalid_context: Nav, vai piegādāts nederīgs konteksts
index: index:
contexts: Filtri %{contexts} contexts: Atsijātāji %{contexts}
delete: Dzēst delete: Dzēst
empty: Tev nav filtru. empty: Tev nav filtru.
expires_in: Beidzas %{distance} expires_in: Beidzas %{distance}
@ -1422,7 +1425,7 @@ lv:
one: paslēpta %{count} individuālā ziņa one: paslēpta %{count} individuālā ziņa
other: slēptas %{count} individuālās ziņas other: slēptas %{count} individuālās ziņas
zero: "%{count} paslēptu ziņu" zero: "%{count} paslēptu ziņu"
title: Filtri title: Atsijātāji
new: new:
save: Saglabāt jauno filtru save: Saglabāt jauno filtru
title: Pievienot jaunu filtru title: Pievienot jaunu filtru
@ -1533,7 +1536,7 @@ lv:
in_progress: Procesā in_progress: Procesā
scheduled: Ieplānots scheduled: Ieplānots
unconfirmed: Neapstiprināti unconfirmed: Neapstiprināti
status: Statuss status: Stāvoklis
success: Tavi dati tika sekmīgi augšupielādēti un tiks apstrādāti noteiktajā laikā success: Tavi dati tika sekmīgi augšupielādēti un tiks apstrādāti noteiktajā laikā
time_started: Sākuma laiks time_started: Sākuma laiks
titles: titles:
@ -1770,7 +1773,7 @@ lv:
remove_selected_domains: Noņemt visus sekotājus no atlasītajiem domēniem remove_selected_domains: Noņemt visus sekotājus no atlasītajiem domēniem
remove_selected_followers: Noņemt atlasītos sekotājus remove_selected_followers: Noņemt atlasītos sekotājus
remove_selected_follows: Pārtraukt sekošanu atlasītajiem lietotājiem remove_selected_follows: Pārtraukt sekošanu atlasītajiem lietotājiem
status: Konta statuss status: Konta stāvoklis
remote_follow: remote_follow:
missing_resource: Nevarēja atrast tavam kontam nepieciešamo novirzīšanas URL missing_resource: Nevarēja atrast tavam kontam nepieciešamo novirzīšanas URL
reports: reports:
@ -1841,20 +1844,20 @@ lv:
appearance: Izskats appearance: Izskats
authorized_apps: Pilnvarotās lietotnes authorized_apps: Pilnvarotās lietotnes
back: Atgriezties Mastodon back: Atgriezties Mastodon
delete: Konta dzēšana delete: Konta izdzēšana
development: Izstrāde development: Izstrāde
edit_profile: Labot profilu edit_profile: Labot profilu
export: Izgūt export: Izgūt
featured_tags: Piedāvātie tēmturi featured_tags: Piedāvātie tēmturi
import: Imports import: Imports
import_and_export: Imports un eksports import_and_export: Ievietošana un izgūšana
migrate: Konta migrācija migrate: Konta migrācija
notifications: E-pasta paziņojumi notifications: E-pasta paziņojumi
preferences: Iestatījumi preferences: Iestatījumi
profile: Profils profile: Profils
relationships: Sekojamie un sekotāji relationships: Sekojamie un sekotāji
severed_relationships: Pārtrauktās attiecības severed_relationships: Pārtrauktās attiecības
statuses_cleanup: Automātiska ziņu dzēšana statuses_cleanup: Automatizēta ierakstu izdzēšana
strikes: Satura pārraudzības aizrādījumi strikes: Satura pārraudzības aizrādījumi
two_factor_authentication: Divpakāpju autentifikācija two_factor_authentication: Divpakāpju autentifikācija
webauthn_authentication: Drošības atslēgas webauthn_authentication: Drošības atslēgas

View File

@ -267,6 +267,7 @@ nn:
demote_user_html: "%{name} degraderte brukaren %{target}" demote_user_html: "%{name} degraderte brukaren %{target}"
destroy_announcement_html: "%{name} sletta kunngjeringa %{target}" destroy_announcement_html: "%{name} sletta kunngjeringa %{target}"
destroy_canonical_email_block_html: "%{name} avblokkerte e-post med hash %{target}" destroy_canonical_email_block_html: "%{name} avblokkerte e-post med hash %{target}"
destroy_collection_html: "%{name} fjerna samlinga av %{target}"
destroy_custom_emoji_html: "%{name} sletta emojien %{target}" destroy_custom_emoji_html: "%{name} sletta emojien %{target}"
destroy_domain_allow_html: "%{name} forbydde føderasjon med domenet %{target}" destroy_domain_allow_html: "%{name} forbydde føderasjon med domenet %{target}"
destroy_domain_block_html: "%{name} avblokkerte domenet %{target}" destroy_domain_block_html: "%{name} avblokkerte domenet %{target}"
@ -306,6 +307,7 @@ nn:
unsilence_account_html: "%{name} fjerna grensa på kontoen til %{target}" unsilence_account_html: "%{name} fjerna grensa på kontoen til %{target}"
unsuspend_account_html: "%{name} oppheva utvisinga av %{target} sin konto" unsuspend_account_html: "%{name} oppheva utvisinga av %{target} sin konto"
update_announcement_html: "%{name} oppdaterte kunngjeringa %{target}" update_announcement_html: "%{name} oppdaterte kunngjeringa %{target}"
update_collection_html: "%{name} oppdaterte samlinga av %{target}"
update_custom_emoji_html: "%{name} oppdaterte emojien %{target}" update_custom_emoji_html: "%{name} oppdaterte emojien %{target}"
update_domain_block_html: "%{name} oppdaterte domeneblokkeringa for %{target}" update_domain_block_html: "%{name} oppdaterte domeneblokkeringa for %{target}"
update_ip_block_html: "%{name} endret regel for IP %{target}" update_ip_block_html: "%{name} endret regel for IP %{target}"

View File

@ -52,7 +52,7 @@ el:
irreversible: Οι φιλτραρισμένες αναρτήσεις θα εξαφανιστούν αμετάκλητα, ακόμη και αν το φίλτρο αργότερα αφαιρεθεί irreversible: Οι φιλτραρισμένες αναρτήσεις θα εξαφανιστούν αμετάκλητα, ακόμη και αν το φίλτρο αργότερα αφαιρεθεί
locale: Η γλώσσα χρήσης, των email και των ειδοποιήσεων push locale: Η γλώσσα χρήσης, των email και των ειδοποιήσεων push
password: Χρησιμοποίησε τουλάχιστον 8 χαρακτήρες password: Χρησιμοποίησε τουλάχιστον 8 χαρακτήρες
phrase: Θα ταιριάζει ανεξαρτήτως πεζών/κεφαλαίων ή προειδοποίησης περιεχομένου μιας ανάρτησης phrase: Θα αντιστοιχηθεί ανεξαρτήτως πεζών/κεφαλαίων ενός κειμένου ή προειδοποίησης περιεχομένου μιας ανάρτησης
scopes: Ποια API θα επιτρέπεται να χρησιμοποιήσει η εφαρμογή. Αν επιλέξεις κάποιο υψηλό εύρος εφαρμογής, δε χρειάζεται να επιλέξεις και το καθένα ξεχωριστά. scopes: Ποια API θα επιτρέπεται να χρησιμοποιήσει η εφαρμογή. Αν επιλέξεις κάποιο υψηλό εύρος εφαρμογής, δε χρειάζεται να επιλέξεις και το καθένα ξεχωριστά.
setting_advanced_layout: Εμφάνιση του Mastodon ως διάταξη πολλαπλών στηλών, επιτρέποντάς σας να δείτε το χρονοδιάγραμμα, τις ειδοποιήσεις και μια τρίτη στήλη της επιλογής σας. Δεν συνιστάται για μικρότερες οθόνες. setting_advanced_layout: Εμφάνιση του Mastodon ως διάταξη πολλαπλών στηλών, επιτρέποντάς σας να δείτε το χρονοδιάγραμμα, τις ειδοποιήσεις και μια τρίτη στήλη της επιλογής σας. Δεν συνιστάται για μικρότερες οθόνες.
setting_aggregate_reblogs: Απόκρυψη των νέων αναρτήσεων για τις αναρτήσεις που έχουν ενισχυθεί πρόσφατα (επηρεάζει μόνο τις νέες ενισχύσεις) setting_aggregate_reblogs: Απόκρυψη των νέων αναρτήσεων για τις αναρτήσεις που έχουν ενισχυθεί πρόσφατα (επηρεάζει μόνο τις νέες ενισχύσεις)
@ -70,7 +70,7 @@ el:
setting_use_blurhash: Οι χρωματισμοί βασίζονται στα χρώματα του κρυμμένου πολυμέσου αλλά θολώνουν τις λεπτομέρειες setting_use_blurhash: Οι χρωματισμοί βασίζονται στα χρώματα του κρυμμένου πολυμέσου αλλά θολώνουν τις λεπτομέρειες
setting_use_pending_items: Εμφάνιση ενημερώσεων ροής μετά από κλικ αντί για αυτόματη κύλισή τους setting_use_pending_items: Εμφάνιση ενημερώσεων ροής μετά από κλικ αντί για αυτόματη κύλισή τους
username: Μπορείς να χρησιμοποιήσεις γράμματα, αριθμούς και κάτω παύλες username: Μπορείς να χρησιμοποιήσεις γράμματα, αριθμούς και κάτω παύλες
whole_word: Όταν η λέξη ή η φράση κλειδί είναι μόνο αλφαριθμητική, θα εφαρμοστεί μόνο αν ταιριάζει με ολόκληρη τη λέξη whole_word: Όταν η λέξη-κλειδί ή η φράση είναι μόνο αλφαριθμητική, θα εφαρμοστεί μόνο αν αντιστοιχεί με ολόκληρη τη λέξη
domain_allow: domain_allow:
domain: Ο τομέας αυτός θα επιτρέπεται να ανακτά δεδομένα από αυτό τον διακομιστή και τα εισερχόμενα δεδομένα θα επεξεργάζονται και θα αποθηκεύονται domain: Ο τομέας αυτός θα επιτρέπεται να ανακτά δεδομένα από αυτό τον διακομιστή και τα εισερχόμενα δεδομένα θα επεξεργάζονται και θα αποθηκεύονται
email_domain_block: email_domain_block:
@ -79,7 +79,7 @@ el:
featured_tag: featured_tag:
name: 'Εδώ είναι μερικές από τις ετικέτες που χρησιμοποίησες περισσότερο πρόσφατα:' name: 'Εδώ είναι μερικές από τις ετικέτες που χρησιμοποίησες περισσότερο πρόσφατα:'
filters: filters:
action: Επιλέξτε ποια ενέργεια θα εκτελεστεί όταν μια ανάρτηση ταιριάζει με το φίλτρο action: Επιλέξτε ποια ενέργεια θα εκτελεστεί όταν μια ανάρτηση αντιστοιχεί με το φίλτρο
actions: actions:
blur: Απόκρυψη πολυμέσων πίσω από μια προειδοποίηση, χωρίς να κρύβεται το ίδιο το κείμενο blur: Απόκρυψη πολυμέσων πίσω από μια προειδοποίηση, χωρίς να κρύβεται το ίδιο το κείμενο
hide: Πλήρης αποκρυψη του φιλτραρισμένου περιεχομένου, συμπεριφέρεται σαν να μην υπήρχε hide: Πλήρης αποκρυψη του φιλτραρισμένου περιεχομένου, συμπεριφέρεται σαν να μην υπήρχε
@ -235,7 +235,7 @@ el:
note: Βιογραφικό note: Βιογραφικό
otp_attempt: Κωδικός δυο παραγόντων otp_attempt: Κωδικός δυο παραγόντων
password: Συνθηματικό password: Συνθηματικό
phrase: Λέξη ή φράση κλειδί phrase: Λέξη-κλειδί ή φράση
setting_advanced_layout: Ενεργοποίηση προηγμένης λειτουργίας χρήσης setting_advanced_layout: Ενεργοποίηση προηγμένης λειτουργίας χρήσης
setting_aggregate_reblogs: Ομαδοποίηση προωθήσεων στις ροές setting_aggregate_reblogs: Ομαδοποίηση προωθήσεων στις ροές
setting_always_send_emails: Πάντα να αποστέλλονται ειδοποίησεις μέσω email setting_always_send_emails: Πάντα να αποστέλλονται ειδοποίησεις μέσω email

View File

@ -28,7 +28,7 @@ lv:
none: Šis ir izmantojams, lai nosūtītu lietotājam brīdinājumu bez jebkādu citu darbību izraisīšanas. none: Šis ir izmantojams, lai nosūtītu lietotājam brīdinājumu bez jebkādu citu darbību izraisīšanas.
sensitive: Visus šī lietotāja informācijas nesēju pielikumus uzspiesti atzīmēt kā jūtīgus. sensitive: Visus šī lietotāja informācijas nesēju pielikumus uzspiesti atzīmēt kā jūtīgus.
silence: Neļaut lietotājam veikt ierakstus ar publisku redzamību, paslēpt viņa ierakstus un paziņojumus no cilvēkiem, kas tam neseko. Tiek aizvērti visi ziņojumi par šo kontu. silence: Neļaut lietotājam veikt ierakstus ar publisku redzamību, paslēpt viņa ierakstus un paziņojumus no cilvēkiem, kas tam neseko. Tiek aizvērti visi ziņojumi par šo kontu.
suspend: Novērs jebkādu mijiedarbību no šī konta vai uz to un dzēs tā saturu. Atgriežams 30 dienu laikā. Tiek aizvērti visi šī konta pārskati. suspend: Novērst jebkādu mijiedarbību ar kontu vai no tā un izdzēst tā saturu. Atsaucams 30 dienu laikā. Tiks aizvērti visi šī konta pārskati.
warning_preset_id: Neobligāts. Tu joprojām vari pievienot pielāgotu tekstu sākotnējās iestatīšanas beigās warning_preset_id: Neobligāts. Tu joprojām vari pievienot pielāgotu tekstu sākotnējās iestatīšanas beigās
announcement: announcement:
all_day: Atzīmējot šo opciju, tiks parādīti tikai laika diapazona datumi all_day: Atzīmējot šo opciju, tiks parādīti tikai laika diapazona datumi

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