Merge commit '3d8d5f6dc7625d9638cc2e3387247442225d4e3f' into glitch-soc/merge-upstream
This commit is contained in:
3
Gemfile
3
Gemfile
@@ -129,9 +129,6 @@ group :test do
|
||||
# Adds RSpec Error/Warning annotations to GitHub PRs on the Files tab
|
||||
gem 'rspec-github', '~> 3.0', require: false
|
||||
|
||||
# RSpec helpers for email specs
|
||||
gem 'email_spec'
|
||||
|
||||
# Extra RSpec extension methods and helpers for sidekiq
|
||||
gem 'rspec-sidekiq', '~> 5.0'
|
||||
|
||||
|
||||
15
Gemfile.lock
15
Gemfile.lock
@@ -224,10 +224,6 @@ GEM
|
||||
base64
|
||||
faraday (>= 1, < 3)
|
||||
multi_json
|
||||
email_spec (2.3.0)
|
||||
htmlentities (~> 4.3.3)
|
||||
launchy (>= 2.1, < 4.0)
|
||||
mail (~> 2.7)
|
||||
email_validator (2.2.4)
|
||||
activemodel
|
||||
erb (6.0.2)
|
||||
@@ -311,7 +307,7 @@ GEM
|
||||
hiredis-client (0.26.4)
|
||||
redis-client (= 0.26.4)
|
||||
hkdf (0.3.0)
|
||||
htmlentities (4.3.4)
|
||||
htmlentities (4.4.2)
|
||||
http (5.3.1)
|
||||
addressable (~> 2.8)
|
||||
http-cookie (~> 1.0)
|
||||
@@ -414,12 +410,12 @@ GEM
|
||||
rexml
|
||||
link_header (0.0.8)
|
||||
lint_roller (1.1.0)
|
||||
linzer (0.7.7)
|
||||
cgi (~> 0.4.2)
|
||||
linzer (0.7.8)
|
||||
cgi (>= 0.4.2, < 0.6.0)
|
||||
forwardable (~> 1.3, >= 1.3.3)
|
||||
logger (~> 1.7, >= 1.7.0)
|
||||
net-http (~> 0.6.0)
|
||||
openssl (~> 3.0, >= 3.0.0)
|
||||
net-http (>= 0.6, < 0.10)
|
||||
openssl (>= 3, < 5)
|
||||
rack (>= 2.2, < 4.0)
|
||||
starry (~> 0.2)
|
||||
stringio (~> 3.1, >= 3.1.2)
|
||||
@@ -977,7 +973,6 @@ DEPENDENCIES
|
||||
discard (~> 1.2)
|
||||
doorkeeper (~> 5.6)
|
||||
dotenv
|
||||
email_spec
|
||||
fabrication
|
||||
faker (~> 3.2)
|
||||
faraday-httpclient
|
||||
|
||||
@@ -18,6 +18,8 @@ class AccountsController < ApplicationController
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
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
|
||||
|
||||
format.rss do
|
||||
|
||||
@@ -26,6 +26,8 @@ class StatusesController < ApplicationController
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
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
|
||||
|
||||
format.json do
|
||||
|
||||
@@ -50,6 +50,10 @@ const meta = {
|
||||
type: 'boolean',
|
||||
description: 'Whether to display the account menu or not',
|
||||
},
|
||||
withBorder: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to display the bottom border or not',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
name: 'Test User',
|
||||
@@ -60,6 +64,7 @@ const meta = {
|
||||
defaultAction: 'mute',
|
||||
withBio: false,
|
||||
withMenu: true,
|
||||
withBorder: true,
|
||||
},
|
||||
parameters: {
|
||||
state: {
|
||||
@@ -103,6 +108,12 @@ export const NoMenu: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const NoBorder: Story = {
|
||||
args: {
|
||||
withBorder: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const Blocked: Story = {
|
||||
args: {
|
||||
defaultAction: 'block',
|
||||
|
||||
@@ -73,6 +73,7 @@ interface AccountProps {
|
||||
defaultAction?: 'block' | 'mute';
|
||||
withBio?: boolean;
|
||||
withMenu?: boolean;
|
||||
withBorder?: boolean;
|
||||
extraAccountInfo?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
@@ -85,6 +86,7 @@ export const Account: React.FC<AccountProps> = ({
|
||||
defaultAction,
|
||||
withBio,
|
||||
withMenu = true,
|
||||
withBorder = true,
|
||||
extraAccountInfo,
|
||||
children,
|
||||
}) => {
|
||||
@@ -290,6 +292,7 @@ export const Account: React.FC<AccountProps> = ({
|
||||
<div
|
||||
className={classNames('account', {
|
||||
'account--minimal': minimal,
|
||||
'account--without-border': !withBorder,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -138,6 +138,8 @@ export const FollowButton: React.FC<{
|
||||
: messages.follow;
|
||||
|
||||
let label;
|
||||
let disabled =
|
||||
relationship?.blocked_by || account?.suspended || !!account?.moved;
|
||||
|
||||
if (!signedIn) {
|
||||
label = intl.formatMessage(followMessage);
|
||||
@@ -147,12 +149,16 @@ export const FollowButton: React.FC<{
|
||||
label = <LoadingIndicator />;
|
||||
} else if (relationship.muting && withUnmute) {
|
||||
label = intl.formatMessage(messages.unmute);
|
||||
disabled = false;
|
||||
} else if (relationship.following) {
|
||||
label = intl.formatMessage(messages.unfollow);
|
||||
disabled = false;
|
||||
} else if (relationship.blocking) {
|
||||
label = intl.formatMessage(messages.unblock);
|
||||
disabled = false;
|
||||
} else if (relationship.requested) {
|
||||
label = intl.formatMessage(messages.followRequestCancel);
|
||||
disabled = false;
|
||||
} else if (relationship.followed_by && !account?.locked) {
|
||||
label = intl.formatMessage(messages.followBack);
|
||||
} else {
|
||||
@@ -187,11 +193,7 @@ export const FollowButton: React.FC<{
|
||||
return (
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
disabled={
|
||||
relationship?.blocked_by ||
|
||||
(!(relationship?.following || relationship?.requested) &&
|
||||
(account?.suspended || !!account?.moved))
|
||||
}
|
||||
disabled={disabled}
|
||||
secondary={following || relationship?.blocking}
|
||||
compact={compact}
|
||||
className={classNames(className, { 'button--destructive': following })}
|
||||
|
||||
@@ -10,6 +10,7 @@ export {
|
||||
type ComboboxItemState,
|
||||
} from './combobox_field';
|
||||
export { CopyLinkField } from './copy_link_field';
|
||||
export { EmojiTextInputField, EmojiTextAreaField } from './emoji_text_field';
|
||||
export { RadioButtonField, RadioButton } from './radio_button_field';
|
||||
export { ToggleField, Toggle } from './toggle_field';
|
||||
export { SelectField, Select } from './select_field';
|
||||
|
||||
@@ -36,7 +36,7 @@ export const ItemList = forwardRef<
|
||||
emptyMessage?: React.ReactNode;
|
||||
}
|
||||
>(({ 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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import type { ModalType } from '@/mastodon/actions/modal';
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { Avatar } from '@/mastodon/components/avatar';
|
||||
import { Button } from '@/mastodon/components/button';
|
||||
import { DismissibleCallout } from '@/mastodon/components/callout/dismissible';
|
||||
import { CustomEmojiProvider } from '@/mastodon/components/emoji/context';
|
||||
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||
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 { EditButton } from './components/edit_button';
|
||||
import { AccountFieldActions } from './components/field_actions';
|
||||
import { AccountEditSection } from './components/section';
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
@@ -54,6 +56,14 @@ export const messages = defineMessages({
|
||||
defaultMessage:
|
||||
'Add your pronouns, external links, or anything else you’d 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: {
|
||||
id: 'account_edit.featured_hashtags.title',
|
||||
defaultMessage: 'Featured hashtags',
|
||||
@@ -101,6 +111,9 @@ export const AccountEdit: FC = () => {
|
||||
const handleBioEdit = useCallback(() => {
|
||||
handleOpenModal('ACCOUNT_EDIT_BIO');
|
||||
}, [handleOpenModal]);
|
||||
const handleCustomFieldsVerifiedHelp = useCallback(() => {
|
||||
handleOpenModal('ACCOUNT_EDIT_VERIFY_LINKS');
|
||||
}, [handleOpenModal]);
|
||||
const handleProfileDisplayEdit = useCallback(() => {
|
||||
handleOpenModal('ACCOUNT_EDIT_PROFILE_DISPLAY');
|
||||
}, [handleOpenModal]);
|
||||
@@ -123,6 +136,7 @@ export const AccountEdit: FC = () => {
|
||||
const headerSrc = autoPlayGif ? profile.header : profile.headerStatic;
|
||||
const hasName = !!profile.displayName;
|
||||
const hasBio = !!profile.bio;
|
||||
const hasFields = profile.fields.length > 0;
|
||||
const hasTags = profile.featuredTags.length > 0;
|
||||
|
||||
return (
|
||||
@@ -171,8 +185,48 @@ export const AccountEdit: FC = () => {
|
||||
<AccountEditSection
|
||||
title={messages.customFieldsTitle}
|
||||
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
|
||||
title={messages.featuredHashtagsTitle}
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import { useCallback, useId, useRef, useState } from 'react';
|
||||
import type { ChangeEventHandler, FC } from 'react';
|
||||
import { useCallback, useId, useState } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { TextArea } from '@/mastodon/components/form_fields';
|
||||
import { insertEmojiAtPosition } from '@/mastodon/features/emoji/utils';
|
||||
import { EmojiTextAreaField } from '@/mastodon/components/form_fields';
|
||||
import type { BaseConfirmationModalProps } 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 { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
import { CharCounter } from './char_counter';
|
||||
import { EmojiPicker } from './emoji_picker';
|
||||
|
||||
const messages = defineMessages({
|
||||
addTitle: {
|
||||
id: 'account_edit.bio_modal.add_title',
|
||||
@@ -30,30 +24,23 @@ const messages = defineMessages({
|
||||
},
|
||||
});
|
||||
|
||||
const MAX_BIO_LENGTH = 500;
|
||||
|
||||
export const BioModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
|
||||
const intl = useIntl();
|
||||
const titleId = useId();
|
||||
const counterId = useId();
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const { profile: { bio } = {}, isPending } = useAppSelector(
|
||||
(state) => state.profileEdit,
|
||||
);
|
||||
const [newBio, setNewBio] = useState(bio ?? '');
|
||||
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
|
||||
(event) => {
|
||||
setNewBio(event.currentTarget.value);
|
||||
},
|
||||
[],
|
||||
const maxLength = useAppSelector(
|
||||
(state) =>
|
||||
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 handleSave = useCallback(() => {
|
||||
@@ -70,27 +57,18 @@ export const BioModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
|
||||
onConfirm={handleSave}
|
||||
onClose={onClose}
|
||||
updating={isPending}
|
||||
disabled={newBio.length > MAX_BIO_LENGTH}
|
||||
disabled={!!maxLength && newBio.length > maxLength}
|
||||
noFocusButton
|
||||
>
|
||||
<div className={classes.inputWrapper}>
|
||||
<TextArea
|
||||
value={newBio}
|
||||
ref={textAreaRef}
|
||||
onChange={handleChange}
|
||||
className={classes.inputText}
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={counterId}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
|
||||
autoFocus
|
||||
autoSize
|
||||
/>
|
||||
<EmojiPicker onPick={handlePickEmoji} />
|
||||
</div>
|
||||
<CharCounter
|
||||
currentLength={newBio.length}
|
||||
maxLength={MAX_BIO_LENGTH}
|
||||
id={counterId}
|
||||
<EmojiTextAreaField
|
||||
label=''
|
||||
value={newBio}
|
||||
onChange={setNewBio}
|
||||
aria-labelledby={titleId}
|
||||
maxLength={maxLength}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
|
||||
autoFocus
|
||||
autoSize
|
||||
/>
|
||||
</ConfirmationModal>
|
||||
);
|
||||
@@ -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 can’t 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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -1,20 +1,14 @@
|
||||
import { useCallback, useId, useRef, useState } from 'react';
|
||||
import type { ChangeEventHandler, FC } from 'react';
|
||||
import { useCallback, useId, useState } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { TextInput } from '@/mastodon/components/form_fields';
|
||||
import { insertEmojiAtPosition } from '@/mastodon/features/emoji/utils';
|
||||
import { EmojiTextInputField } from '@/mastodon/components/form_fields';
|
||||
import type { BaseConfirmationModalProps } 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 { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
import { CharCounter } from './char_counter';
|
||||
import { EmojiPicker } from './emoji_picker';
|
||||
|
||||
const messages = defineMessages({
|
||||
addTitle: {
|
||||
id: 'account_edit.name_modal.add_title',
|
||||
@@ -30,30 +24,24 @@ const messages = defineMessages({
|
||||
},
|
||||
});
|
||||
|
||||
const MAX_NAME_LENGTH = 30;
|
||||
|
||||
export const NameModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
|
||||
const intl = useIntl();
|
||||
const titleId = useId();
|
||||
const counterId = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { profile: { displayName } = {}, isPending } = useAppSelector(
|
||||
(state) => state.profileEdit,
|
||||
);
|
||||
const [newName, setNewName] = useState(displayName ?? '');
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
(event) => {
|
||||
setNewName(event.currentTarget.value);
|
||||
},
|
||||
[],
|
||||
const maxLength = useAppSelector(
|
||||
(state) =>
|
||||
state.server.getIn([
|
||||
'server',
|
||||
'configuration',
|
||||
'accounts',
|
||||
'max_display_name_length',
|
||||
]) as number | undefined,
|
||||
);
|
||||
const handlePickEmoji = useCallback((emoji: string) => {
|
||||
setNewName((prev) => {
|
||||
const position = inputRef.current?.selectionStart ?? prev.length;
|
||||
return insertEmojiAtPosition(prev, emoji, position);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const [newName, setNewName] = useState(displayName ?? '');
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleSave = useCallback(() => {
|
||||
@@ -70,27 +58,18 @@ export const NameModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
|
||||
onConfirm={handleSave}
|
||||
onClose={onClose}
|
||||
updating={isPending}
|
||||
disabled={newName.length > MAX_NAME_LENGTH}
|
||||
disabled={!!maxLength && newName.length > maxLength}
|
||||
noCloseOnConfirm
|
||||
noFocusButton
|
||||
>
|
||||
<div className={classes.inputWrapper}>
|
||||
<TextInput
|
||||
value={newName}
|
||||
ref={inputRef}
|
||||
onChange={handleChange}
|
||||
className={classes.inputText}
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={counterId}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
|
||||
autoFocus
|
||||
/>
|
||||
<EmojiPicker onPick={handlePickEmoji} />
|
||||
</div>
|
||||
<CharCounter
|
||||
currentLength={newName.length}
|
||||
maxLength={MAX_NAME_LENGTH}
|
||||
id={counterId}
|
||||
<EmojiTextInputField
|
||||
value={newName}
|
||||
onChange={setNewName}
|
||||
aria-labelledby={titleId}
|
||||
maxLength={maxLength}
|
||||
label=''
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
|
||||
autoFocus
|
||||
/>
|
||||
</ConfirmationModal>
|
||||
);
|
||||
@@ -12,7 +12,8 @@ import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import type { DialogModalProps } from '../../ui/components/dialog_modal';
|
||||
import { DialogModal } from '../../ui/components/dialog_modal';
|
||||
import { messages } from '../index';
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
export const ProfileDisplayModal: FC<DialogModalProps> = ({ onClose }) => {
|
||||
const intl = useIntl();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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. Here’s 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><a></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 you’ve already added your website as a custom field, you’ll need to delete and re-add it to trigger verification.'
|
||||
tagName='p'
|
||||
/>
|
||||
</li>
|
||||
</ol>
|
||||
</DialogModal>
|
||||
);
|
||||
};
|
||||
@@ -24,6 +24,38 @@
|
||||
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
|
||||
|
||||
.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 {
|
||||
@@ -195,14 +185,3 @@ textarea.inputText {
|
||||
.sectionSubtitle {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
// Counter component
|
||||
|
||||
.counter {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.counterError {
|
||||
color: var(--color-text-error);
|
||||
}
|
||||
|
||||
@@ -82,10 +82,11 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
||||
const { collections, status } = useAppSelector((state) =>
|
||||
selectAccountCollections(state, accountId ?? null),
|
||||
);
|
||||
const publicCollections = collections.filter(
|
||||
// This filter only applies when viewing your own profile, where the endpoint
|
||||
// returns all collections, but we hide unlisted ones here to avoid confusion
|
||||
(item) => item.discoverable,
|
||||
const listedCollections = collections.filter(
|
||||
// Hide unlisted and empty collections to avoid confusion
|
||||
// (Unlisted collections will only be part of the payload
|
||||
// when viewing your own profile.)
|
||||
(item) => item.discoverable && !!item.item_count,
|
||||
);
|
||||
|
||||
if (accountId === null) {
|
||||
@@ -124,7 +125,7 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
||||
{accountId && (
|
||||
<AccountHeader accountId={accountId} hideTabs={forceEmptyState} />
|
||||
)}
|
||||
{publicCollections.length > 0 && status === 'idle' && (
|
||||
{listedCollections.length > 0 && status === 'idle' && (
|
||||
<>
|
||||
<h4 className='column-subheading'>
|
||||
<FormattedMessage
|
||||
@@ -133,13 +134,13 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
||||
/>
|
||||
</h4>
|
||||
<ItemList>
|
||||
{publicCollections.map((item, index) => (
|
||||
{listedCollections.map((item, index) => (
|
||||
<CollectionListItem
|
||||
key={item.id}
|
||||
collection={item}
|
||||
withoutBorder={index === publicCollections.length - 1}
|
||||
withoutBorder={index === listedCollections.length - 1}
|
||||
positionInList={index + 1}
|
||||
listSize={publicCollections.length}
|
||||
listSize={listedCollections.length}
|
||||
/>
|
||||
))}
|
||||
</ItemList>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -6,11 +6,9 @@ import { Helmet } from 'react-helmet';
|
||||
import { useLocation, useParams } from 'react-router';
|
||||
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { useRelationship } from '@/mastodon/hooks/useRelationship';
|
||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
|
||||
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
|
||||
import { Account } from 'mastodon/components/account';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
@@ -19,26 +17,19 @@ import {
|
||||
LinkedDisplayName,
|
||||
} from 'mastodon/components/display_name';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import {
|
||||
Article,
|
||||
ItemList,
|
||||
Scrollable,
|
||||
} from 'mastodon/components/scrollable_list/components';
|
||||
import { Scrollable } from 'mastodon/components/scrollable_list/components';
|
||||
import { Tag } from 'mastodon/components/tags/tag';
|
||||
import { useAccount } from 'mastodon/hooks/useAccount';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import { fetchCollection } from 'mastodon/reducers/slices/collections';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { CollectionAccountsList } from './collection_list';
|
||||
import { CollectionMetaData } from './collection_list_item';
|
||||
import { CollectionMenu } from './collection_menu';
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
const messages = defineMessages({
|
||||
empty: {
|
||||
id: 'collections.accounts.empty_title',
|
||||
defaultMessage: 'This collection is empty',
|
||||
},
|
||||
loading: {
|
||||
id: 'collections.detail.loading',
|
||||
defaultMessage: 'Loading collection…',
|
||||
@@ -47,10 +38,6 @@ const messages = defineMessages({
|
||||
id: 'collections.detail.share',
|
||||
defaultMessage: 'Share this collection',
|
||||
},
|
||||
accounts: {
|
||||
id: 'collections.detail.accounts_heading',
|
||||
defaultMessage: 'Accounts',
|
||||
},
|
||||
});
|
||||
|
||||
export const AuthorNote: React.FC<{ id: string; previewMode?: boolean }> = ({
|
||||
@@ -149,33 +136,10 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
|
||||
collection={collection}
|
||||
className={classes.metaData}
|
||||
/>
|
||||
<h2 className='sr-only'>{intl.formatMessage(messages.accounts)}</h2>
|
||||
</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<{
|
||||
multiColumn?: boolean;
|
||||
}> = ({ multiColumn }) => {
|
||||
@@ -185,7 +149,6 @@ export const CollectionDetailPage: React.FC<{
|
||||
const collection = useAppSelector((state) =>
|
||||
id ? state.collections.collections[id] : undefined,
|
||||
);
|
||||
|
||||
const isLoading = !!id && !collection;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -208,24 +171,7 @@ export const CollectionDetailPage: React.FC<{
|
||||
|
||||
<Scrollable>
|
||||
{collection && <CollectionHeader collection={collection} />}
|
||||
<ItemList
|
||||
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>
|
||||
<CollectionAccountsList collection={collection} isLoading={isLoading} />
|
||||
</Scrollable>
|
||||
|
||||
<Helmet>
|
||||
|
||||
@@ -57,6 +57,18 @@
|
||||
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 {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
@@ -76,3 +88,18 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import { Account } from 'mastodon/components/account';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { Badge } from 'mastodon/components/badge';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { Callout } from 'mastodon/components/callout';
|
||||
import { DisplayName } from 'mastodon/components/display_name';
|
||||
import { EmptyState } from 'mastodon/components/empty_state';
|
||||
import { FormStack, Combobox } from 'mastodon/components/form_fields';
|
||||
@@ -40,7 +39,6 @@ import { getCollectionEditorState } from './state';
|
||||
import classes from './styles.module.scss';
|
||||
import { WizardStepHeader } from './wizard_step_header';
|
||||
|
||||
const MIN_ACCOUNT_COUNT = 1;
|
||||
const MAX_ACCOUNT_COUNT = 25;
|
||||
|
||||
function isOlderThanAWeek(date?: string): boolean {
|
||||
@@ -164,9 +162,6 @@ export const CollectionAccounts: React.FC<{
|
||||
);
|
||||
|
||||
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 {
|
||||
accountIds: suggestedAccountIds,
|
||||
@@ -319,17 +314,13 @@ export const CollectionAccounts: React.FC<{
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
history.push(`/collections/new/details`, {
|
||||
account_ids: accountIds,
|
||||
});
|
||||
}
|
||||
},
|
||||
[canSubmit, id, history, accountIds],
|
||||
[id, history, accountIds],
|
||||
);
|
||||
|
||||
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}>
|
||||
<ItemList
|
||||
className={classes.scrollableInner}
|
||||
@@ -425,7 +406,7 @@ export const CollectionAccounts: React.FC<{
|
||||
>
|
||||
<AddedAccountItem
|
||||
accountId={accountId}
|
||||
isRemovable={!isEditMode || !hasMinAccounts}
|
||||
isRemovable={!isEditMode}
|
||||
onRemove={handleRemoveAccountItem}
|
||||
/>
|
||||
</Article>
|
||||
@@ -435,39 +416,25 @@ export const CollectionAccounts: React.FC<{
|
||||
</FormStack>
|
||||
{!isEditMode && (
|
||||
<div className={classes.stickyFooter}>
|
||||
{hasTooFewAccounts ? (
|
||||
<Callout icon={false} className={classes.submitDisabledCallout}>
|
||||
<FormattedMessage
|
||||
id='collections.hints.add_more_accounts'
|
||||
defaultMessage='Add at least {count, plural, one {# account} other {# accounts}} to continue'
|
||||
values={{ count: MIN_ACCOUNT_COUNT }}
|
||||
/>
|
||||
</Callout>
|
||||
) : (
|
||||
<div className={classes.actionWrapper}>
|
||||
<FormattedMessage
|
||||
id='collections.hints.accounts_counter'
|
||||
defaultMessage='{count} / {max} accounts'
|
||||
values={{ count: accountIds.length, max: MAX_ACCOUNT_COUNT }}
|
||||
>
|
||||
{(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 className={classes.actionWrapper}>
|
||||
<FormattedMessage
|
||||
id='collections.hints.accounts_counter'
|
||||
defaultMessage='{count} / {max} accounts'
|
||||
values={{ count: accountIds.length, max: MAX_ACCOUNT_COUNT }}
|
||||
>
|
||||
{(text) => <div className={classes.itemCountReadout}>{text}</div>}
|
||||
</FormattedMessage>
|
||||
<Button type='submit'>
|
||||
{id ? (
|
||||
<FormattedMessage id='lists.save' defaultMessage='Save' />
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='collections.continue'
|
||||
defaultMessage='Continue'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Helmet } from 'react-helmet';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||
import CollectionsFilledIcon from '@/material-icons/400-24px/category-fill.svg?react';
|
||||
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
@@ -73,8 +73,8 @@ export const Collections: React.FC<{
|
||||
>
|
||||
<ColumnHeader
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
icon='list-ul'
|
||||
iconComponent={ListAltIcon}
|
||||
icon='collections'
|
||||
iconComponent={CollectionsFilledIcon}
|
||||
multiColumn={multiColumn}
|
||||
extraButton={
|
||||
<Link
|
||||
|
||||
@@ -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 BookmarksActiveIcon from '@/material-icons/400-24px/bookmarks-fill.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 HomeIcon from '@/material-icons/400-24px/home.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 { AnnualReportNavItem } from '../annual_report/nav_item';
|
||||
import { areCollectionsEnabled } from '../collections/utils';
|
||||
|
||||
import { DisabledAccountBanner } from './components/disabled_account_banner';
|
||||
import { FollowedTagsPanel } from './components/followed_tags_panel';
|
||||
@@ -71,6 +74,10 @@ const messages = defineMessages({
|
||||
direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' },
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
|
||||
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
collections: {
|
||||
id: 'navigation_bar.collections',
|
||||
defaultMessage: 'Collections',
|
||||
},
|
||||
preferences: {
|
||||
id: 'navigation_bar.preferences',
|
||||
defaultMessage: 'Preferences',
|
||||
@@ -325,6 +332,16 @@ export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({
|
||||
activeIconComponent={BookmarksActiveIcon}
|
||||
text={intl.formatMessage(messages.bookmarks)}
|
||||
/>
|
||||
{areCollectionsEnabled() && (
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/collections'
|
||||
icon='collections'
|
||||
iconComponent={CollectionsIcon}
|
||||
activeIconComponent={CollectionsActiveIcon}
|
||||
text={intl.formatMessage(messages.collections)}
|
||||
/>
|
||||
)}
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/conversations'
|
||||
|
||||
@@ -13,23 +13,26 @@ export interface BaseConfirmationModalProps {
|
||||
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<
|
||||
{
|
||||
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
|
||||
ConfirmationModalProps & BaseConfirmationModalProps
|
||||
> = ({
|
||||
title,
|
||||
titleId,
|
||||
@@ -42,6 +45,7 @@ export const ConfirmationModal: React.FC<
|
||||
onSecondary,
|
||||
extraContent,
|
||||
children,
|
||||
className,
|
||||
updating,
|
||||
disabled,
|
||||
noCloseOnConfirm = false,
|
||||
@@ -62,7 +66,7 @@ export const ConfirmationModal: React.FC<
|
||||
|
||||
return (
|
||||
<ModalShell>
|
||||
<ModalShellBody>
|
||||
<ModalShellBody className={className}>
|
||||
<h1 id={titleId}>{title}</h1>
|
||||
{message && <p>{message}</p>}
|
||||
|
||||
|
||||
@@ -15,11 +15,10 @@ interface DialogModalProps {
|
||||
title: ReactNode;
|
||||
onClose: () => void;
|
||||
description?: ReactNode;
|
||||
formClassName?: string;
|
||||
wrapperClassName?: string;
|
||||
children?: ReactNode;
|
||||
noCancelButton?: boolean;
|
||||
onSave?: () => void;
|
||||
saveLabel?: ReactNode;
|
||||
buttons?: ReactNode;
|
||||
}
|
||||
|
||||
export const DialogModal: FC<DialogModalProps> = ({
|
||||
@@ -27,16 +26,13 @@ export const DialogModal: FC<DialogModalProps> = ({
|
||||
title,
|
||||
onClose,
|
||||
description,
|
||||
formClassName,
|
||||
wrapperClassName,
|
||||
children,
|
||||
noCancelButton = false,
|
||||
onSave,
|
||||
saveLabel,
|
||||
buttons,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const showButtons = !noCancelButton || onSave;
|
||||
|
||||
return (
|
||||
<div className={classNames('modal-root__modal dialog-modal', className)}>
|
||||
<div className='dialog-modal__header'>
|
||||
@@ -61,13 +57,16 @@ export const DialogModal: FC<DialogModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={classNames('dialog-modal__content__form', formClassName)}
|
||||
className={classNames(
|
||||
'dialog-modal__content__form',
|
||||
wrapperClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showButtons && (
|
||||
{(buttons || !noCancelButton) && (
|
||||
<div className='dialog-modal__content__actions'>
|
||||
{!noCancelButton && (
|
||||
<Button onClick={onClose} secondary>
|
||||
@@ -77,16 +76,7 @@ export const DialogModal: FC<DialogModalProps> = ({
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{onSave && (
|
||||
<Button onClick={onClose}>
|
||||
{saveLabel ?? (
|
||||
<FormattedMessage
|
||||
id='confirmation_modal.cancel'
|
||||
defaultMessage='Cancel'
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{buttons}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -94,11 +94,20 @@ export const MODAL_COMPONENTS = {
|
||||
'COMPOSE_PRIVACY': () => Promise.resolve({ default: VisibilityModal }),
|
||||
'ACCOUNT_NOTE': () => import('@/mastodon/features/account_timeline/modals/note_modal').then(module => ({ default: module.AccountNoteModal })),
|
||||
'ACCOUNT_FIELD_OVERFLOW': () => import('@/mastodon/features/account_timeline/modals/field_modal').then(module => ({ default: module.AccountFieldModal })),
|
||||
'ACCOUNT_EDIT_NAME': () => import('@/mastodon/features/account_edit/components/name_modal').then(module => ({ default: module.NameModal })),
|
||||
'ACCOUNT_EDIT_BIO': () => import('@/mastodon/features/account_edit/components/bio_modal').then(module => ({ default: module.BioModal })),
|
||||
'ACCOUNT_EDIT_PROFILE_DISPLAY': () => import('@/mastodon/features/account_edit/components/profile_display_modal').then(module => ({ default: module.ProfileDisplayModal })),
|
||||
'ACCOUNT_EDIT_NAME': accountEditModal('NameModal'),
|
||||
'ACCOUNT_EDIT_BIO': accountEditModal('BioModal'),
|
||||
'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 {
|
||||
|
||||
static propTypes = {
|
||||
|
||||
@@ -276,6 +276,8 @@
|
||||
"callout.dismiss": "Afvis",
|
||||
"carousel.current": "<sr>Dias</sr> {current, number} / {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_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",
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
"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.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_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",
|
||||
@@ -276,6 +276,8 @@
|
||||
"callout.dismiss": "Verwerfen",
|
||||
"carousel.current": "<sr>Seite</sr> {current, number}/{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_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",
|
||||
|
||||
@@ -276,6 +276,8 @@
|
||||
"callout.dismiss": "Παράβλεψη",
|
||||
"carousel.current": "<sr>Διαφάνεια</sr> {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_modal.description": "Η δημιουργία λογαριασμού στον {domain} προς το παρόν δεν είναι δυνατή, αλλά λάβε υπόψη ότι δεν χρειάζεσαι λογαριασμό ειδικά στον {domain} για να χρησιμοποιήσεις το Mastodon.",
|
||||
"closed_registrations_modal.find_another_server": "Βρες άλλον διακομιστή",
|
||||
@@ -575,7 +577,7 @@
|
||||
"filter_modal.select_filter.subtitle": "Χρησιμοποιήστε μια υπάρχουσα κατηγορία ή δημιουργήστε μια νέα",
|
||||
"filter_modal.select_filter.title": "Φιλτράρισμα αυτής της ανάρτησης",
|
||||
"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.title": "Φιλτραρισμένες ειδοποιήσεις",
|
||||
"firehose.all": "Όλα",
|
||||
@@ -1045,7 +1047,7 @@
|
||||
"search.quick_action.go_to_account": "Μετάβαση στο προφίλ {x}",
|
||||
"search.quick_action.go_to_hashtag": "Μετάβαση στην ετικέτα {x}",
|
||||
"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_popout.full_text_search_disabled_message": "Μη διαθέσιμο στο {domain}.",
|
||||
"search_popout.full_text_search_logged_out_message": "Διαθέσιμο μόνο όταν συνδεθείς.",
|
||||
|
||||
@@ -276,6 +276,8 @@
|
||||
"callout.dismiss": "Dismiss",
|
||||
"carousel.current": "<sr>Slide</sr> {current, number} / {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_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",
|
||||
|
||||
@@ -149,16 +149,28 @@
|
||||
"account_edit.button.add": "Add {item}",
|
||||
"account_edit.button.delete": "Delete {item}",
|
||||
"account_edit.button.edit": "Edit {item}",
|
||||
"account_edit.char_counter": "{currentLength}/{maxLength} characters",
|
||||
"account_edit.column_button": "Done",
|
||||
"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 you’d 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.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.title": "Display name",
|
||||
"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.title": "Featured hashtags",
|
||||
"account_edit.field_delete_modal.confirm": "Are you sure you want to delete this custom field? This action can’t 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.edit_title": "Edit display name",
|
||||
"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.title": "Profile tab settings",
|
||||
"account_edit.save": "Save",
|
||||
"account_edit.verified_modal.details": "Add credibility to your Mastodon profile by verifying links to personal websites. Here’s 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 you’ve already added your website as a custom field, you’ll 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.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 page’s Activity view.",
|
||||
@@ -308,15 +327,16 @@
|
||||
"collections.delete_collection": "Delete collection",
|
||||
"collections.description_length_hint": "100 characters limit",
|
||||
"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_you": "Curated by you",
|
||||
"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.edit_details": "Edit details",
|
||||
"collections.error_loading_collections": "There was an error when trying to load your collections.",
|
||||
"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.manage_accounts": "Manage accounts",
|
||||
"collections.mark_as_sensitive": "Mark as sensitive",
|
||||
@@ -772,6 +792,7 @@
|
||||
"navigation_bar.automated_deletion": "Automated post deletion",
|
||||
"navigation_bar.blocks": "Blocked users",
|
||||
"navigation_bar.bookmarks": "Bookmarks",
|
||||
"navigation_bar.collections": "Collections",
|
||||
"navigation_bar.direct": "Private mentions",
|
||||
"navigation_bar.domain_blocks": "Blocked domains",
|
||||
"navigation_bar.favourites": "Favorites",
|
||||
|
||||
@@ -531,7 +531,7 @@
|
||||
"loading_indicator.label": "Ŝargado…",
|
||||
"media_gallery.hide": "Kaŝi",
|
||||
"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.indefinite": "Ĝis mi malsilentas ilin",
|
||||
"mute_modal.show_options": "Montri agordojn",
|
||||
|
||||
@@ -276,6 +276,8 @@
|
||||
"callout.dismiss": "Descartar",
|
||||
"carousel.current": "<sr>Diapositiva</sr> {current, number} / {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_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",
|
||||
|
||||
@@ -276,6 +276,8 @@
|
||||
"callout.dismiss": "Avvís",
|
||||
"carousel.current": "<sr>Glæra</sr> {current, number} / {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_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",
|
||||
|
||||
@@ -276,6 +276,8 @@
|
||||
"callout.dismiss": "Desbotar",
|
||||
"carousel.current": "<sr>Filmina</sr> {current, number} / {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_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",
|
||||
|
||||
@@ -276,6 +276,8 @@
|
||||
"callout.dismiss": "לבטל",
|
||||
"carousel.current": "<sr>שקופית</sr>{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_modal.description": "יצירת חשבון על שרת {domain} איננה אפשרית כרגע, אבל זכרו שאינכן זקוקות לחשבון על {domain} כדי להשתמש במסטודון.",
|
||||
"closed_registrations_modal.find_another_server": "חיפוש שרת אחר",
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"account_edit.char_counter": "{currentLength}/{maxLength} karakter",
|
||||
"account_edit.column_button": "Kész",
|
||||
"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.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",
|
||||
@@ -167,9 +168,17 @@
|
||||
"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.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.save": "Mentés",
|
||||
"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",
|
||||
"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",
|
||||
@@ -267,11 +276,20 @@
|
||||
"callout.dismiss": "Elvetés",
|
||||
"carousel.current": "{current, number}. <sr>dia</sr> / {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_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.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",
|
||||
"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.accounts.empty_description": "Adj hozzá legfeljebb {count} követett fiókot",
|
||||
"collections.accounts.empty_title": "Ez a gyűjtemény üres",
|
||||
@@ -290,6 +308,8 @@
|
||||
"collections.delete_collection": "Gyűjtemény törlése",
|
||||
"collections.description_length_hint": "100 karakteres korlát",
|
||||
"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.share": "Gyűjtemény megosztása",
|
||||
"collections.edit_details": "Részletek szerkesztése",
|
||||
@@ -304,11 +324,15 @@
|
||||
"collections.name_length_hint": "40 karakteres korlát",
|
||||
"collections.new_collection": "Új gyűjtemény",
|
||||
"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.report_collection": "Gyűjtemény jelenté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.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.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_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",
|
||||
@@ -397,6 +421,9 @@
|
||||
"confirmations.discard_draft.post.title": "Elveted a piszkozatot?",
|
||||
"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.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.message": "Követned kell {name} felhasználót, hogy hozzáadhasd a listához.",
|
||||
"confirmations.follow_to_list.title": "Felhasználó követése?",
|
||||
@@ -440,6 +467,7 @@
|
||||
"conversation.open": "Beszélgetés megtekintése",
|
||||
"conversation.with": "Velük: {names}",
|
||||
"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.copy_to_clipboard": "Másolás vágólapra",
|
||||
"directory.federated": "Az ismert födiverzumból",
|
||||
@@ -657,6 +685,7 @@
|
||||
"keyboard_shortcuts.direct": "Személyes említések oszlop megnyitása",
|
||||
"keyboard_shortcuts.down": "Mozgás lefelé a listában",
|
||||
"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.favourites": "Kedvencek lista 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_status": "bejegyzéssel",
|
||||
"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.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?",
|
||||
@@ -990,6 +1020,8 @@
|
||||
"report.rules.title": "Mely szabályok lettek megsértve?",
|
||||
"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.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.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:",
|
||||
@@ -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.sign_in": "Bejelentkezés",
|
||||
"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_domain": "Moderációs felület megnyitása {domain} esetében",
|
||||
"status.admin_status": "Bejegyzés megnyitása a moderációs felületen",
|
||||
|
||||
@@ -276,6 +276,8 @@
|
||||
"callout.dismiss": "Vísa frá",
|
||||
"carousel.current": "<sr>Skyggna</sr> {current, number} / {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_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",
|
||||
|
||||
@@ -276,6 +276,8 @@
|
||||
"callout.dismiss": "Ignora",
|
||||
"carousel.current": "<sr>Diapositiva</sr> {current, number} / {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_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",
|
||||
|
||||
@@ -44,9 +44,11 @@
|
||||
"account.familiar_followers_two": "Fylgt av {name1} og {name2}",
|
||||
"account.featured": "Utvald",
|
||||
"account.featured.accounts": "Profilar",
|
||||
"account.featured.collections": "Samlingar",
|
||||
"account.featured.hashtags": "Emneknaggar",
|
||||
"account.featured_tags.last_status_at": "Sist nytta {date}",
|
||||
"account.featured_tags.last_status_never": "Ingen innlegg",
|
||||
"account.field_overflow": "Vis heile innhaldet",
|
||||
"account.filters.all": "All aktivitet",
|
||||
"account.filters.boosts_toggle": "Vis framhevingar",
|
||||
"account.filters.posts_boosts": "Innlegg og framhevingar",
|
||||
@@ -159,9 +161,19 @@
|
||||
"account_edit.featured_hashtags.title": "Utvalde emneknaggar",
|
||||
"account_edit.name_modal.add_title": "Legg til 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.title": "Innstillingar for profilfane",
|
||||
"account_edit.save": "Lagre",
|
||||
"account_edit_tags.add_tag": "Legg til #{tagName}",
|
||||
"account_edit_tags.column_title": "Rediger utvalde emneknaggar",
|
||||
"account_edit_tags.help_text": "Utvalde emneknaggar hjelper folk å oppdaga og samhandla med profilen din. Dei blir viste som filter på aktivitetsoversikta på profilsida di.",
|
||||
"account_edit_tags.search_placeholder": "Skriv ein emneknagg…",
|
||||
@@ -269,6 +281,13 @@
|
||||
"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.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.accounts.empty_description": "Legg til opp til {count} kontoar du fylgjer",
|
||||
"collections.accounts.empty_title": "Denne samlinga er tom",
|
||||
@@ -305,11 +324,13 @@
|
||||
"collections.no_collections_yet": "Du har ingen samlingar enno.",
|
||||
"collections.old_last_post_note": "Sist lagt ut for over ei veke sidan",
|
||||
"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_max_reached": "Du har nådd grensa for kor mange kontoar du kan leggja til",
|
||||
"collections.sensitive": "Ømtolig",
|
||||
"collections.topic_hint": "Legg til ein emneknagg som hjelper andre å forstå hovudemnet for denne 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_hint": "Kan koma opp i søkjeresultat og andre stader der tilrådingar syner.",
|
||||
"collections.visibility_title": "Vising",
|
||||
@@ -443,9 +464,10 @@
|
||||
"conversation.mark_as_read": "Marker som lesen",
|
||||
"conversation.open": "Sjå samtale",
|
||||
"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.copy_to_clipboard": "Kopier til utklyppstavla",
|
||||
"copypaste.copy_to_clipboard": "Kopier til utklippstavla",
|
||||
"directory.federated": "Frå den kjende allheimen",
|
||||
"directory.local": "Berre frå {domain}",
|
||||
"directory.new_arrivals": "Nyleg tilkomne",
|
||||
@@ -661,6 +683,7 @@
|
||||
"keyboard_shortcuts.direct": "Opne spalta med private omtalar",
|
||||
"keyboard_shortcuts.down": "Flytt nedover i lista",
|
||||
"keyboard_shortcuts.enter": "Opne innlegg",
|
||||
"keyboard_shortcuts.explore": "Opne «populært»-tidslina",
|
||||
"keyboard_shortcuts.favourite": "Marker innlegget som favoritt",
|
||||
"keyboard_shortcuts.favourites": "Opne favorittlista",
|
||||
"keyboard_shortcuts.federated": "Opne den samla tidslina",
|
||||
@@ -973,6 +996,7 @@
|
||||
"report.category.title_account": "profil",
|
||||
"report.category.title_status": "innlegg",
|
||||
"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.forward": "Vidaresend til {target}",
|
||||
"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.statuses.subtitle": "Velg det som gjeld",
|
||||
"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.target": "Rapporterer {target}",
|
||||
"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.sign_in": "Logg inn",
|
||||
"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_domain": "Opna moderatorgrensesnittet for {domain}",
|
||||
"status.admin_status": "Opne denne statusen i moderasjonsgrensesnittet",
|
||||
|
||||
@@ -268,6 +268,8 @@
|
||||
"callout.dismiss": "Descartar",
|
||||
"carousel.current": "<sr>Slide</sr> {current, number} / {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_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",
|
||||
|
||||
@@ -274,6 +274,8 @@
|
||||
"callout.dismiss": "Hidhe tej",
|
||||
"carousel.current": "<sr>Diapozitivi</sr> {current, number} / {max, number}",
|
||||
"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_modal.description": "Krijimi i një llogarie te {domain} aktualisht është i pamundur, por kini parasysh se s’keni 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",
|
||||
|
||||
@@ -200,6 +200,8 @@
|
||||
"callout.dismiss": "Avfärda",
|
||||
"carousel.current": "<sr>Bild</sr>{current, number} / {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_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",
|
||||
|
||||
@@ -276,6 +276,8 @@
|
||||
"callout.dismiss": "Bỏ qua",
|
||||
"carousel.current": "<sr>Slide</sr> {current, number} / {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_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",
|
||||
|
||||
@@ -276,6 +276,8 @@
|
||||
"callout.dismiss": "忽略",
|
||||
"carousel.current": "<sr>幻灯片</sr> {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_modal.description": "你目前无法在 {domain} 上创建账号,但请注意,使用 Mastodon 并非需要专门在 {domain} 上注册账号。",
|
||||
"closed_registrations_modal.find_another_server": "查找其他服务器",
|
||||
|
||||
@@ -276,6 +276,8 @@
|
||||
"callout.dismiss": "關閉",
|
||||
"carousel.current": "<sr>頁面</sr> {current, number} / {max, 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_modal.description": "目前無法於 {domain} 建立新帳號,但也請別忘了,您並不一定需要有 {domain} 伺服器的帳號,也能使用 Mastodon。",
|
||||
"closed_registrations_modal.find_another_server": "尋找另一個伺服器",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
apiPostFeaturedTag,
|
||||
} from '@/mastodon/api/accounts';
|
||||
import { apiGetSearch } from '@/mastodon/api/search';
|
||||
import type { ApiAccountFieldJSON } from '@/mastodon/api_types/accounts';
|
||||
import type {
|
||||
ApiProfileJSON,
|
||||
ApiProfileUpdateParams,
|
||||
@@ -23,20 +24,25 @@ import type {
|
||||
import type { AppDispatch } from '@/mastodon/store';
|
||||
import {
|
||||
createAppAsyncThunk,
|
||||
createAppSelector,
|
||||
createDataLoadingThunk,
|
||||
} from '@/mastodon/store/typed_functions';
|
||||
import { hashObjectArray } from '@/mastodon/utils/hash';
|
||||
import type { SnakeToCamelCase } from '@/mastodon/utils/types';
|
||||
|
||||
type ProfileData = {
|
||||
[Key in keyof Omit<
|
||||
ApiProfileJSON,
|
||||
'note' | 'featured_tags'
|
||||
'note' | 'fields' | 'featured_tags'
|
||||
> as SnakeToCamelCase<Key>]: ApiProfileJSON[Key];
|
||||
} & {
|
||||
bio: ApiProfileJSON['note'];
|
||||
fields: FieldData[];
|
||||
featuredTags: TagData[];
|
||||
};
|
||||
|
||||
export type FieldData = ApiAccountFieldJSON & { id: string };
|
||||
|
||||
export type TagData = {
|
||||
[Key in keyof Omit<
|
||||
ApiFeaturedTagJSON,
|
||||
@@ -186,7 +192,7 @@ const transformProfile = (result: ApiProfileJSON): ProfileData => ({
|
||||
id: result.id,
|
||||
displayName: result.display_name,
|
||||
bio: result.note,
|
||||
fields: result.fields,
|
||||
fields: hashObjectArray(result.fields),
|
||||
avatar: result.avatar,
|
||||
avatarStatic: result.avatar_static,
|
||||
avatarDescription: result.avatar_description,
|
||||
@@ -218,6 +224,83 @@ export const patchProfile = createDataLoadingThunk(
|
||||
{ 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(
|
||||
`${profileEditSlice.name}/fetchFeaturedTags`,
|
||||
apiGetCurrentFeaturedTags,
|
||||
|
||||
48
app/javascript/mastodon/utils/hash.test.ts
Normal file
48
app/javascript/mastodon/utils/hash.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
39
app/javascript/mastodon/utils/hash.ts
Normal file
39
app/javascript/mastodon/utils/hash.ts
Normal 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>;
|
||||
});
|
||||
}
|
||||
1
app/javascript/material-icons/400-24px/category-fill.svg
Normal file
1
app/javascript/material-icons/400-24px/category-fill.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="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 |
1
app/javascript/material-icons/400-24px/category.svg
Normal file
1
app/javascript/material-icons/400-24px/category.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="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 |
@@ -2009,7 +2009,10 @@ body > [data-popper-placement] {
|
||||
|
||||
.account {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
|
||||
&:not(&--without-border) {
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.account__display-name {
|
||||
flex: 1 1 auto;
|
||||
|
||||
@@ -410,6 +410,11 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
|
||||
|
||||
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.perform_at(poll.expires_at + 5.minutes, poll.id)
|
||||
end
|
||||
|
||||
@@ -4,7 +4,7 @@ class ResolveURLService < BaseService
|
||||
include JsonLdHelper
|
||||
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)
|
||||
@url = url
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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.
|
||||
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:
|
||||
one: El code_challenge_method debe ser %{challenge_methods}.
|
||||
other: El 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.
|
||||
one: "«code_challenge_method» debe ser %{challenge_methods}."
|
||||
other: "«code_challenge_method» debe ser uno de %{challenge_methods}."
|
||||
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_redirect_uri: La dirección web de redireccionamiento incluida no es válida.
|
||||
invalid_request:
|
||||
|
||||
@@ -83,6 +83,10 @@ hu:
|
||||
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.
|
||||
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_redirect_uri: Az átirányító URI nem valós.
|
||||
invalid_request:
|
||||
|
||||
@@ -83,6 +83,10 @@ pt-PT:
|
||||
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.
|
||||
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_redirect_uri: A URI de redirecionamento incluída não é válida.
|
||||
invalid_request:
|
||||
|
||||
@@ -1468,10 +1468,10 @@ el:
|
||||
add_keyword: Προσθήκη λέξης-κλειδιού
|
||||
keywords: Λέξεις-κλειδιά
|
||||
statuses: Μεμονωμένες αναρτήσεις
|
||||
statuses_hint_html: Αυτό το φίλτρο εφαρμόζεται για την επιλογή μεμονωμένων αναρτήσεων, ανεξάρτητα από το αν ταιριάζουν με τις λέξεις - κλειδιά παρακάτω. <a href="%{path}">Επισκόπηση ή αφαίρεση αναρτήσεων από το φίλτρο</a>.
|
||||
statuses_hint_html: Αυτό το φίλτρο εφαρμόζεται για την επιλογή μεμονωμένων αναρτήσεων, ανεξάρτητα από το αν αντιστοιχούν με τις λέξεις-κλειδιά παρακάτω. <a href="%{path}">Επισκόπηση ή αφαίρεση αναρτήσεων από το φίλτρο</a>.
|
||||
title: Επεξεργασία φίλτρου
|
||||
errors:
|
||||
deprecated_api_multiple_keywords: Αυτές οι παράμετροι δεν μπορούν να αλλάξουν από αυτήν την εφαρμογή επειδή ισχύουν για περισσότερες από μία λέξεις - κλειδιά φίλτρου. Χρησιμοποίησε μια πιο πρόσφατη εφαρμογή ή την ιστοσελίδα.
|
||||
deprecated_api_multiple_keywords: Αυτές οι παράμετροι δεν μπορούν να αλλάξουν από αυτήν την εφαρμογή επειδή ισχύουν για περισσότερες από μία λέξεις-κλειδιά φίλτρου. Χρησιμοποίησε μια πιο πρόσφατη εφαρμογή ή την ιστοσελίδα.
|
||||
invalid_context: Δόθηκε κενό ή μη έγκυρο περιεχόμενο
|
||||
index:
|
||||
contexts: Φίλτρα σε %{contexts}
|
||||
@@ -1480,8 +1480,8 @@ el:
|
||||
expires_in: Λήγει σε %{distance}
|
||||
expires_on: Λήγει στις %{date}
|
||||
keywords:
|
||||
one: "%{count} λέξη - κλειδί"
|
||||
other: "%{count} λέξεις - κλειδιά"
|
||||
one: "%{count} λέξη-κλειδί"
|
||||
other: "%{count} λέξεις-κλειδιά"
|
||||
statuses:
|
||||
one: "%{count} ανάρτηση"
|
||||
other: "%{count} αναρτήσεις"
|
||||
@@ -1505,8 +1505,8 @@ el:
|
||||
one: "<strong>%{count}</strong> στοιχείο σε αυτή τη σελίδα είναι επιλεγμένο."
|
||||
other: Όλα τα <strong>%{count}</strong> στοιχεία σε αυτή τη σελίδα είναι επιλεγμένα.
|
||||
all_matching_items_selected_html:
|
||||
one: "<strong>%{count}</strong> στοιχείο που ταιριάζει με την αναζήτησή σου είναι επιλεγμένο."
|
||||
other: Όλα τα <strong>%{count}</strong> στοιχεία που ταιριάζουν στην αναζήτησή σου είναι επιλεγμένα.
|
||||
one: "<strong>%{count}</strong> στοιχείο που αντιστοιχεί με την αναζήτησή σου είναι επιλεγμένο."
|
||||
other: Όλα τα <strong>%{count}</strong> στοιχεία που αντιστοιχούν με την αναζήτησή σου είναι επιλεγμένα.
|
||||
cancel: Άκυρο
|
||||
changes_saved_msg: Οι αλλαγές αποθηκεύτηκαν!
|
||||
confirm: Επιβεβαίωση
|
||||
@@ -1517,8 +1517,8 @@ el:
|
||||
order_by: Ταξινόμηση κατά
|
||||
save_changes: Αποθήκευση αλλαγών
|
||||
select_all_matching_items:
|
||||
one: Επέλεξε %{count} στοιχείο που ταιριάζει με την αναζήτησή σου.
|
||||
other: Επέλεξε και τα %{count} αντικείμενα που ταιριάζουν στην αναζήτησή σου.
|
||||
one: Επέλεξε %{count} στοιχείο που αντιστοιχεί με την αναζήτησή σου.
|
||||
other: Επέλεξε όλα τα %{count} αντικείμενα που αντιστοιχούν με την αναζήτησή σου.
|
||||
today: σήμερα
|
||||
validation_errors:
|
||||
one: Κάτι δεν πάει καλά! Για κοίταξε το παρακάτω σφάλμα
|
||||
@@ -1988,7 +1988,7 @@ el:
|
||||
unlisted_long: Κρυμμένη από τα αποτελέσματα αναζήτησης Mastodon, τις τάσεις και τις δημόσιες ροές
|
||||
statuses_cleanup:
|
||||
enabled: Αυτόματη διαγραφή παλιών αναρτήσεων
|
||||
enabled_hint: Διαγράφει αυτόματα τις αναρτήσεις σου μόλις φτάσουν σε ένα καθορισμένο όριο ηλικίας, εκτός αν ταιριάζουν με μία από τις παρακάτω εξαιρέσεις
|
||||
enabled_hint: Διαγράφει αυτόματα τις αναρτήσεις σου μόλις φτάσουν σε ένα καθορισμένο όριο ηλικίας, εκτός αν αντιστοιχούν με μία από τις παρακάτω εξαιρέσεις
|
||||
exceptions: Εξαιρέσεις
|
||||
explanation: Η αυτόματη διαγραφή εκτελείται με χαμηλή προτεραιότητα. Μπορεί να υπάρξει καθυστέρηση μεταξύ της επίτευξης του ορίου ηλικίας και της διαγραφής.
|
||||
ignore_favs: Αγνόηση αγαπημένων
|
||||
@@ -2027,7 +2027,7 @@ el:
|
||||
errors:
|
||||
too_late: Είναι πολύ αργά για να κάνεις έφεση σε αυτό το παράπτωμα
|
||||
tags:
|
||||
does_not_match_previous_name: δεν ταιριάζει με το προηγούμενο όνομα
|
||||
does_not_match_previous_name: δεν αντιστοιχεί με το προηγούμενο όνομα
|
||||
terms_of_service:
|
||||
title: Όροι Παροχής Υπηρεσιών
|
||||
terms_of_service_interstitial:
|
||||
|
||||
@@ -146,8 +146,8 @@ hu:
|
||||
security_measures:
|
||||
only_password: Csak jelszó
|
||||
password_and_2fa: Jelszó és kétlépcsős hitelesítés
|
||||
sensitive: Kényes
|
||||
sensitized: Kényesnek jelölve
|
||||
sensitive: Érzékeny
|
||||
sensitized: Érzékenynek jelölve
|
||||
shared_inbox_url: Megosztott bejövő üzenetek URL
|
||||
show:
|
||||
created_reports: Létrehozott jelentések
|
||||
@@ -165,7 +165,7 @@ hu:
|
||||
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
|
||||
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_suspension: Felfüggesztés visszavonása
|
||||
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"
|
||||
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_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_domain_allow_html: "%{name} letiltotta a föderációt a %{target} domainnel"
|
||||
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"
|
||||
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_collection_html: "%{name} frissítette %{target} felhasználó gyűjteményét"
|
||||
update_custom_emoji_html: "%{name} frissítette az emodzsit: %{target}"
|
||||
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"
|
||||
@@ -689,6 +691,7 @@ hu:
|
||||
cancel: Mégse
|
||||
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
|
||||
collections: Gyûjtemények (%{count})
|
||||
comment:
|
||||
none: Egyik sem
|
||||
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!
|
||||
skip_to_actions: Tovább az intézkedésekhez
|
||||
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
|
||||
summary:
|
||||
action_preambles:
|
||||
|
||||
@@ -69,7 +69,7 @@ lv:
|
||||
domain: Domēns
|
||||
edit: Labot
|
||||
email: E-pasts
|
||||
email_status: E-pasta statuss
|
||||
email_status: E-pasta stāvoklis
|
||||
enable: Atsaldēt
|
||||
enable_sign_in_token_auth: Iespējot autentificēšanos ar e-pasta pilnvaru
|
||||
enabled: Iespējots
|
||||
@@ -84,10 +84,10 @@ lv:
|
||||
joined: Pievienojies
|
||||
location:
|
||||
all: Visi
|
||||
local: Vietējie
|
||||
remote: Attālinātie
|
||||
local: Vietēji
|
||||
remote: Attāli
|
||||
title: Atrašanās vieta
|
||||
login_status: Pieteikšanās statuss
|
||||
login_status: Pieteikšanās stāvoklis
|
||||
media_attachments: Multivides pielikumi
|
||||
memorialize: Pārvērst atmiņās
|
||||
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
|
||||
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_through_html: Atrisināts, izmantojot %{domain}
|
||||
title: Bloķētie e-pasta domēni
|
||||
resolved_through_html: Atrisināts no %{domain}
|
||||
title: Liegtie e-pasta domēni
|
||||
export_domain_allows:
|
||||
new:
|
||||
title: Importēt domēnu atļaujas
|
||||
@@ -514,7 +514,7 @@ lv:
|
||||
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ā."
|
||||
language: Valodai
|
||||
status: Statuss
|
||||
status: Stāvoklis
|
||||
suppress: Apspiest sekošanas rekomendāciju
|
||||
suppressed: Apspiestie
|
||||
title: Sekošanas ieteikumi
|
||||
@@ -615,8 +615,8 @@ lv:
|
||||
'94670856': 3 gadi
|
||||
new:
|
||||
title: Izveidot jaunu IP noteikumu
|
||||
no_ip_block_selected: Neviens IP noteikums netika mainīts, jo netika atlasīts
|
||||
title: IP noteikumi
|
||||
no_ip_block_selected: Neviena IP kārtula netika mainīta, jo neviena netika atlasīta
|
||||
title: IP kārtulas
|
||||
relationships:
|
||||
title: "%{acct} attiecības"
|
||||
relays:
|
||||
@@ -633,7 +633,7 @@ lv:
|
||||
save_and_enable: Saglabāt un iespējot
|
||||
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
|
||||
status: Statuss
|
||||
status: Stāvoklis
|
||||
title: Releji
|
||||
report_notes:
|
||||
created_msg: Ziņojuma piezīme sekmīgi izveidota.
|
||||
@@ -697,34 +697,34 @@ lv:
|
||||
reported_account: Ziņotais konts
|
||||
reported_by: Ziņoja
|
||||
reported_with_application: Ziņots no lietotnes
|
||||
resolved: Atrisināts
|
||||
resolved: Atrisināti
|
||||
resolved_msg: Ziņojums sekmīgi atrisināts.
|
||||
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
|
||||
summary:
|
||||
action_preambles:
|
||||
delete_html: 'Jūs gatavojaties <strong>noņemt</strong> dažas no lietotāja <strong>@%{acct}</strong> ziņām. 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:'
|
||||
silence_html: 'Jūs gatavojaties <strong>ierobežot</strong> <strong>@%{acct}</strong> kontu. Tas:'
|
||||
suspend_html: 'Jūs gatavojaties <strong>apturēt</strong> <strong>@%{acct}</strong> kontu. Tas:'
|
||||
delete_html: 'Tu grasies <strong>noņemt</strong> dažus no <strong>@%{acct}</strong> ierakstiem. 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: 'Tu grasies <strong>ierobežot</strong> <strong>@%{acct}</strong> kontu. Tas:'
|
||||
suspend_html: 'Tu grasies <strong>apturēt</strong> <strong>@%{acct}</strong> kontu. Tas:'
|
||||
actions:
|
||||
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
|
||||
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_reports_html: Atzīmējiet <strong>visus</strong> pārskatus 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
|
||||
close_reports_html: atzīmēs <strong>visus</strong> ziņojumus par <strong>@%{acct}</strong> kā atrisinātus;
|
||||
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:"
|
||||
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
|
||||
warning_placeholder: Izvēles papildu pamatojums satura pārraudzības darbībai.
|
||||
target_origin: Konta, par kuru ziņots, izcelsme
|
||||
title: Ziņojumi
|
||||
unassign: Atsaukt
|
||||
unknown_action_msg: 'Nezināms konts: %{action}'
|
||||
unresolved: Neatrisinātie
|
||||
unresolved: Neatrisināti
|
||||
updated_at: Atjaunināts
|
||||
view_profile: Skatīt profilu
|
||||
roles:
|
||||
@@ -839,7 +839,7 @@ lv:
|
||||
domain_blocks:
|
||||
all: Visiem
|
||||
disabled: Nevienam
|
||||
users: Vietējiem reģistrētiem lietotājiem
|
||||
users: Vietējiem lietotājiem, kuri ir pieteikušies
|
||||
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.
|
||||
preamble: Kontrolē, kurš var izveidot kontu tavā serverī.
|
||||
@@ -921,9 +921,9 @@ lv:
|
||||
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>'
|
||||
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:
|
||||
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:
|
||||
message_html: Elasticsearch indeksa kartējumi ir novecojuši. Lūdzu, palaid <code>tootctl search deploy --only=%{value}</code>
|
||||
elasticsearch_preset:
|
||||
@@ -1088,6 +1088,9 @@ lv:
|
||||
zero: Pēdējās nedēļas laikā izmantoja %{count} cilvēku
|
||||
title: Ieteikumi un pašlaik populāri
|
||||
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:
|
||||
add_new: Pievienot jaunu
|
||||
delete: Dzēst
|
||||
@@ -1112,7 +1115,7 @@ lv:
|
||||
new: Jauna tīmekļa aizķere
|
||||
rotate_secret: Pagriezt noslēpumu
|
||||
secret: Paraksta noslēpums
|
||||
status: Statuss
|
||||
status: Stāvoklis
|
||||
title: Tīmekļa āķi
|
||||
webhook: Tīmekļa āķis
|
||||
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.
|
||||
title: Atļauj tevi iestatīt %{domain}.
|
||||
status:
|
||||
account_status: Konta statuss
|
||||
account_status: Konta stāvoklis
|
||||
confirming: Gaida e-pasta adreses apstiprināšanas pabeigšanu.
|
||||
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.
|
||||
@@ -1376,10 +1379,10 @@ lv:
|
||||
in_progress: Notiek tava arhīva apkopošana...
|
||||
request: Pieprasi savu arhīvu
|
||||
size: Izmērs
|
||||
blocks: Bloķētie konti
|
||||
blocks: Tu liedzi
|
||||
bookmarks: Grāmatzīmes
|
||||
csv: CSV
|
||||
domain_blocks: Bloķētie domēni
|
||||
domain_blocks: Liegtie domēni
|
||||
lists: Saraksti
|
||||
mutes: Apklusinātie konti
|
||||
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.
|
||||
invalid_context: Nav, vai piegādāts nederīgs konteksts
|
||||
index:
|
||||
contexts: Filtri %{contexts}
|
||||
contexts: Atsijātāji %{contexts}
|
||||
delete: Dzēst
|
||||
empty: Tev nav filtru.
|
||||
expires_in: Beidzas %{distance}
|
||||
@@ -1422,7 +1425,7 @@ lv:
|
||||
one: paslēpta %{count} individuālā ziņa
|
||||
other: slēptas %{count} individuālās ziņas
|
||||
zero: "%{count} paslēptu ziņu"
|
||||
title: Filtri
|
||||
title: Atsijātāji
|
||||
new:
|
||||
save: Saglabāt jauno filtru
|
||||
title: Pievienot jaunu filtru
|
||||
@@ -1533,7 +1536,7 @@ lv:
|
||||
in_progress: Procesā
|
||||
scheduled: Ieplānots
|
||||
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ā
|
||||
time_started: Sākuma laiks
|
||||
titles:
|
||||
@@ -1770,7 +1773,7 @@ lv:
|
||||
remove_selected_domains: Noņemt visus sekotājus no atlasītajiem domēniem
|
||||
remove_selected_followers: Noņemt atlasītos sekotājus
|
||||
remove_selected_follows: Pārtraukt sekošanu atlasītajiem lietotājiem
|
||||
status: Konta statuss
|
||||
status: Konta stāvoklis
|
||||
remote_follow:
|
||||
missing_resource: Nevarēja atrast tavam kontam nepieciešamo novirzīšanas URL
|
||||
reports:
|
||||
@@ -1841,20 +1844,20 @@ lv:
|
||||
appearance: Izskats
|
||||
authorized_apps: Pilnvarotās lietotnes
|
||||
back: Atgriezties Mastodon
|
||||
delete: Konta dzēšana
|
||||
delete: Konta izdzēšana
|
||||
development: Izstrāde
|
||||
edit_profile: Labot profilu
|
||||
export: Izgūt
|
||||
featured_tags: Piedāvātie tēmturi
|
||||
import: Imports
|
||||
import_and_export: Imports un eksports
|
||||
import_and_export: Ievietošana un izgūšana
|
||||
migrate: Konta migrācija
|
||||
notifications: E-pasta paziņojumi
|
||||
preferences: Iestatījumi
|
||||
profile: Profils
|
||||
relationships: Sekojamie un sekotāji
|
||||
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
|
||||
two_factor_authentication: Divpakāpju autentifikācija
|
||||
webauthn_authentication: Drošības atslēgas
|
||||
|
||||
@@ -267,6 +267,7 @@ nn:
|
||||
demote_user_html: "%{name} degraderte brukaren %{target}"
|
||||
destroy_announcement_html: "%{name} sletta kunngjeringa %{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_domain_allow_html: "%{name} forbydde føderasjon med 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}"
|
||||
unsuspend_account_html: "%{name} oppheva utvisinga av %{target} sin konto"
|
||||
update_announcement_html: "%{name} oppdaterte kunngjeringa %{target}"
|
||||
update_collection_html: "%{name} oppdaterte samlinga av %{target}"
|
||||
update_custom_emoji_html: "%{name} oppdaterte emojien %{target}"
|
||||
update_domain_block_html: "%{name} oppdaterte domeneblokkeringa for %{target}"
|
||||
update_ip_block_html: "%{name} endret regel for IP %{target}"
|
||||
|
||||
@@ -52,7 +52,7 @@ el:
|
||||
irreversible: Οι φιλτραρισμένες αναρτήσεις θα εξαφανιστούν αμετάκλητα, ακόμη και αν το φίλτρο αργότερα αφαιρεθεί
|
||||
locale: Η γλώσσα χρήσης, των email και των ειδοποιήσεων push
|
||||
password: Χρησιμοποίησε τουλάχιστον 8 χαρακτήρες
|
||||
phrase: Θα ταιριάζει ανεξαρτήτως πεζών/κεφαλαίων ή προειδοποίησης περιεχομένου μιας ανάρτησης
|
||||
phrase: Θα αντιστοιχηθεί ανεξαρτήτως πεζών/κεφαλαίων ενός κειμένου ή προειδοποίησης περιεχομένου μιας ανάρτησης
|
||||
scopes: Ποια API θα επιτρέπεται να χρησιμοποιήσει η εφαρμογή. Αν επιλέξεις κάποιο υψηλό εύρος εφαρμογής, δε χρειάζεται να επιλέξεις και το καθένα ξεχωριστά.
|
||||
setting_advanced_layout: Εμφάνιση του Mastodon ως διάταξη πολλαπλών στηλών, επιτρέποντάς σας να δείτε το χρονοδιάγραμμα, τις ειδοποιήσεις και μια τρίτη στήλη της επιλογής σας. Δεν συνιστάται για μικρότερες οθόνες.
|
||||
setting_aggregate_reblogs: Απόκρυψη των νέων αναρτήσεων για τις αναρτήσεις που έχουν ενισχυθεί πρόσφατα (επηρεάζει μόνο τις νέες ενισχύσεις)
|
||||
@@ -70,7 +70,7 @@ el:
|
||||
setting_use_blurhash: Οι χρωματισμοί βασίζονται στα χρώματα του κρυμμένου πολυμέσου αλλά θολώνουν τις λεπτομέρειες
|
||||
setting_use_pending_items: Εμφάνιση ενημερώσεων ροής μετά από κλικ αντί για αυτόματη κύλισή τους
|
||||
username: Μπορείς να χρησιμοποιήσεις γράμματα, αριθμούς και κάτω παύλες
|
||||
whole_word: Όταν η λέξη ή η φράση κλειδί είναι μόνο αλφαριθμητική, θα εφαρμοστεί μόνο αν ταιριάζει με ολόκληρη τη λέξη
|
||||
whole_word: Όταν η λέξη-κλειδί ή η φράση είναι μόνο αλφαριθμητική, θα εφαρμοστεί μόνο αν αντιστοιχεί με ολόκληρη τη λέξη
|
||||
domain_allow:
|
||||
domain: Ο τομέας αυτός θα επιτρέπεται να ανακτά δεδομένα από αυτό τον διακομιστή και τα εισερχόμενα δεδομένα θα επεξεργάζονται και θα αποθηκεύονται
|
||||
email_domain_block:
|
||||
@@ -79,7 +79,7 @@ el:
|
||||
featured_tag:
|
||||
name: 'Εδώ είναι μερικές από τις ετικέτες που χρησιμοποίησες περισσότερο πρόσφατα:'
|
||||
filters:
|
||||
action: Επιλέξτε ποια ενέργεια θα εκτελεστεί όταν μια ανάρτηση ταιριάζει με το φίλτρο
|
||||
action: Επιλέξτε ποια ενέργεια θα εκτελεστεί όταν μια ανάρτηση αντιστοιχεί με το φίλτρο
|
||||
actions:
|
||||
blur: Απόκρυψη πολυμέσων πίσω από μια προειδοποίηση, χωρίς να κρύβεται το ίδιο το κείμενο
|
||||
hide: Πλήρης αποκρυψη του φιλτραρισμένου περιεχομένου, συμπεριφέρεται σαν να μην υπήρχε
|
||||
@@ -235,7 +235,7 @@ el:
|
||||
note: Βιογραφικό
|
||||
otp_attempt: Κωδικός δυο παραγόντων
|
||||
password: Συνθηματικό
|
||||
phrase: Λέξη ή φράση κλειδί
|
||||
phrase: Λέξη-κλειδί ή φράση
|
||||
setting_advanced_layout: Ενεργοποίηση προηγμένης λειτουργίας χρήσης
|
||||
setting_aggregate_reblogs: Ομαδοποίηση προωθήσεων στις ροές
|
||||
setting_always_send_emails: Πάντα να αποστέλλονται ειδοποίησεις μέσω email
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
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
|
||||
announcement:
|
||||
all_day: Atzīmējot šo opciju, tiks parādīti tikai laika diapazona datumi
|
||||
|
||||
@@ -224,6 +224,7 @@ pt-PT:
|
||||
email: Endereço de correio electrónico
|
||||
expires_in: Expira em
|
||||
fields: Metadados de perfil
|
||||
filter_action: Ação do filtro
|
||||
header: Imagem de cabeçalho
|
||||
honeypot: "%{label} (não preencher)"
|
||||
inbox_url: URL da caixa de entrada do repetidor
|
||||
|
||||
@@ -14,12 +14,16 @@ RSpec.describe AdminMailer do
|
||||
end
|
||||
|
||||
it 'renders the email' do
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(deliver_to(recipient.user_email))
|
||||
.and(deliver_from('notifications@localhost'))
|
||||
.and(have_subject(I18n.t('admin_mailer.new_report.subject', instance: Rails.configuration.x.local_domain, id: report.id)))
|
||||
.and(have_body_text("Mike,\r\n\r\nJohn has reported Mike\r\n\r\nView: #{admin_report_url(report)}\r\n"))
|
||||
expect { mail.deliver }
|
||||
.to send_email(
|
||||
to: recipient.user_email,
|
||||
from: 'notifications@localhost',
|
||||
subject: I18n.t('admin_mailer.new_report.subject', instance: Rails.configuration.x.local_domain, id: report.id)
|
||||
)
|
||||
expect(mail.body)
|
||||
.to match('Mike,')
|
||||
.and match('John has reported Mike')
|
||||
.and match("View: #{admin_report_url(report)}")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -33,12 +37,14 @@ RSpec.describe AdminMailer do
|
||||
end
|
||||
|
||||
it 'renders the email' do
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(deliver_to(recipient.user_email))
|
||||
.and(deliver_from('notifications@localhost'))
|
||||
.and(have_subject(I18n.t('admin_mailer.new_appeal.subject', instance: Rails.configuration.x.local_domain, username: appeal.account.username)))
|
||||
.and(have_body_text("#{appeal.account.username} is appealing a moderation decision by #{appeal.strike.account.username}"))
|
||||
expect { mail.deliver }
|
||||
.to send_email(
|
||||
to: recipient.user_email,
|
||||
from: 'notifications@localhost',
|
||||
subject: I18n.t('admin_mailer.new_appeal.subject', instance: Rails.configuration.x.local_domain, username: appeal.account.username)
|
||||
)
|
||||
expect(mail.body)
|
||||
.to match("#{appeal.account.username} is appealing a moderation decision by #{appeal.strike.account.username}")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -52,12 +58,14 @@ RSpec.describe AdminMailer do
|
||||
end
|
||||
|
||||
it 'renders the email' do
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(deliver_to(recipient.user_email))
|
||||
.and(deliver_from('notifications@localhost'))
|
||||
.and(have_subject(I18n.t('admin_mailer.new_pending_account.subject', instance: Rails.configuration.x.local_domain, username: user.account.username)))
|
||||
.and(have_body_text('The details of the new account are below. You can approve or reject this application.'))
|
||||
expect { mail.deliver }
|
||||
.to send_email(
|
||||
to: recipient.user_email,
|
||||
from: 'notifications@localhost',
|
||||
subject: I18n.t('admin_mailer.new_pending_account.subject', instance: Rails.configuration.x.local_domain, username: user.account.username)
|
||||
)
|
||||
expect(mail.body)
|
||||
.to match('The details of the new account are below. You can approve or reject this application.')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -76,15 +84,17 @@ RSpec.describe AdminMailer do
|
||||
end
|
||||
|
||||
it 'renders the email' do
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(deliver_to(recipient.user_email))
|
||||
.and(deliver_from('notifications@localhost'))
|
||||
.and(have_subject(I18n.t('admin_mailer.new_trends.subject', instance: Rails.configuration.x.local_domain)))
|
||||
.and(have_body_text('The following items need a review before they can be displayed publicly'))
|
||||
.and(have_body_text(ActivityPub::TagManager.instance.url_for(status)))
|
||||
.and(have_body_text(link.title))
|
||||
.and(have_body_text(tag.display_name))
|
||||
expect { mail.deliver }
|
||||
.to send_email(
|
||||
to: recipient.user_email,
|
||||
from: 'notifications@localhost',
|
||||
subject: I18n.t('admin_mailer.new_trends.subject', instance: Rails.configuration.x.local_domain)
|
||||
)
|
||||
expect(mail.body)
|
||||
.to match('The following items need a review before they can be displayed publicly')
|
||||
.and match(ActivityPub::TagManager.instance.url_for(status))
|
||||
.and match(link.title)
|
||||
.and match(tag.display_name)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -97,12 +107,14 @@ RSpec.describe AdminMailer do
|
||||
end
|
||||
|
||||
it 'renders the email' do
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(deliver_to(recipient.user_email))
|
||||
.and(deliver_from('notifications@localhost'))
|
||||
.and(have_subject(I18n.t('admin_mailer.new_software_updates.subject', instance: Rails.configuration.x.local_domain)))
|
||||
.and(have_body_text('New Mastodon versions have been released, you may want to update!'))
|
||||
expect { mail.deliver }
|
||||
.to send_email(
|
||||
to: recipient.user_email,
|
||||
from: 'notifications@localhost',
|
||||
subject: I18n.t('admin_mailer.new_software_updates.subject', instance: Rails.configuration.x.local_domain)
|
||||
)
|
||||
expect(mail.body)
|
||||
.to match('New Mastodon versions have been released, you may want to update!')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -115,15 +127,18 @@ RSpec.describe AdminMailer do
|
||||
end
|
||||
|
||||
it 'renders the email' do
|
||||
expect { mail.deliver }
|
||||
.to send_email(
|
||||
to: recipient.user_email,
|
||||
from: 'notifications@localhost',
|
||||
subject: I18n.t('admin_mailer.new_critical_software_updates.subject', instance: Rails.configuration.x.local_domain)
|
||||
)
|
||||
expect(mail.body)
|
||||
.to match('New critical versions of Mastodon have been released, you may want to update as soon as possible!')
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(deliver_to(recipient.user_email))
|
||||
.and(deliver_from('notifications@localhost'))
|
||||
.and(have_subject(I18n.t('admin_mailer.new_critical_software_updates.subject', instance: Rails.configuration.x.local_domain)))
|
||||
.and(have_body_text('New critical versions of Mastodon have been released, you may want to update as soon as possible!'))
|
||||
.and(have_header('Importance', 'high'))
|
||||
.and(have_header('Priority', 'urgent'))
|
||||
.and(have_header('X-Priority', '1'))
|
||||
.to have_header('Importance', 'high')
|
||||
.and have_header('Priority', 'urgent')
|
||||
.and have_header('X-Priority', '1')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -136,12 +151,14 @@ RSpec.describe AdminMailer do
|
||||
end
|
||||
|
||||
it 'renders the email' do
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(deliver_to(recipient.user_email))
|
||||
.and(deliver_from('notifications@localhost'))
|
||||
.and(have_subject(I18n.t('admin_mailer.auto_close_registrations.subject', instance: Rails.configuration.x.local_domain)))
|
||||
.and(have_body_text('have been automatically switched'))
|
||||
expect { mail.deliver }
|
||||
.to send_email(
|
||||
to: recipient.user_email,
|
||||
from: 'notifications@localhost',
|
||||
subject: I18n.t('admin_mailer.auto_close_registrations.subject', instance: Rails.configuration.x.local_domain)
|
||||
)
|
||||
expect(mail.body)
|
||||
.to match('have been automatically switched')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -38,12 +38,15 @@ RSpec.describe NotificationMailer do
|
||||
it_behaves_like 'localized subject', 'notification_mailer.mention.subject', name: 'bob'
|
||||
|
||||
it 'renders the email' do
|
||||
expect { mail.deliver }
|
||||
.to send_email(
|
||||
subject: 'You were mentioned by bob'
|
||||
)
|
||||
expect(mail.text_part.body)
|
||||
.to match('You were mentioned by bob')
|
||||
.and match('The body of the foreign status')
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_subject('You were mentioned by bob'))
|
||||
.and(have_body_text('You were mentioned by bob'))
|
||||
.and(have_body_text('The body of the foreign status'))
|
||||
.and have_thread_headers
|
||||
.to have_thread_headers
|
||||
.and have_standard_headers('mention').for(receiver)
|
||||
end
|
||||
|
||||
@@ -59,12 +62,15 @@ RSpec.describe NotificationMailer do
|
||||
it_behaves_like 'localized subject', 'notification_mailer.quote.subject', name: 'bob'
|
||||
|
||||
it 'renders the email' do
|
||||
expect { mail.deliver }
|
||||
.to send_email(
|
||||
subject: 'bob quoted your post'
|
||||
)
|
||||
expect(mail.text_part.body)
|
||||
.to match('Your post was quoted by bob')
|
||||
.and match('The body of the foreign status')
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_subject('bob quoted your post'))
|
||||
.and(have_body_text('Your post was quoted by bob'))
|
||||
.and(have_body_text('The body of the foreign status'))
|
||||
.and have_thread_headers
|
||||
.to have_thread_headers
|
||||
.and have_standard_headers('quote').for(receiver)
|
||||
end
|
||||
|
||||
@@ -80,11 +86,14 @@ RSpec.describe NotificationMailer do
|
||||
it_behaves_like 'localized subject', 'notification_mailer.follow.subject', name: 'bob'
|
||||
|
||||
it 'renders the email' do
|
||||
expect { mail.deliver }
|
||||
.to send_email(
|
||||
subject: 'bob is now following you'
|
||||
)
|
||||
expect(mail.text_part.body)
|
||||
.to match('bob is now following you')
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_subject('bob is now following you'))
|
||||
.and(have_body_text('bob is now following you'))
|
||||
.and have_standard_headers('follow').for(receiver)
|
||||
.to have_standard_headers('follow').for(receiver)
|
||||
end
|
||||
|
||||
it_behaves_like 'delivery to non functional user'
|
||||
@@ -98,12 +107,15 @@ RSpec.describe NotificationMailer do
|
||||
it_behaves_like 'localized subject', 'notification_mailer.favourite.subject', name: 'bob'
|
||||
|
||||
it 'renders the email' do
|
||||
expect { mail.deliver }
|
||||
.to send_email(
|
||||
subject: 'bob favorited your post'
|
||||
)
|
||||
expect(mail.text_part.body)
|
||||
.to match('Your post was favorited by bob')
|
||||
.and match('The body of the own status')
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_subject('bob favorited your post'))
|
||||
.and(have_body_text('Your post was favorited by bob'))
|
||||
.and(have_body_text('The body of the own status'))
|
||||
.and have_thread_headers
|
||||
.to have_thread_headers
|
||||
.and have_standard_headers('favourite').for(receiver)
|
||||
end
|
||||
|
||||
@@ -119,12 +131,15 @@ RSpec.describe NotificationMailer do
|
||||
it_behaves_like 'localized subject', 'notification_mailer.reblog.subject', name: 'bob'
|
||||
|
||||
it 'renders the email' do
|
||||
expect { mail.deliver }
|
||||
.to send_email(
|
||||
subject: 'bob boosted your post'
|
||||
)
|
||||
expect(mail.text_part.body)
|
||||
.to match('Your post was boosted by bob')
|
||||
.and match('The body of the own status')
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_subject('bob boosted your post'))
|
||||
.and(have_body_text('Your post was boosted by bob'))
|
||||
.and(have_body_text('The body of the own status'))
|
||||
.and have_thread_headers
|
||||
.to have_thread_headers
|
||||
.and have_standard_headers('reblog').for(receiver)
|
||||
end
|
||||
|
||||
@@ -140,11 +155,14 @@ RSpec.describe NotificationMailer do
|
||||
it_behaves_like 'localized subject', 'notification_mailer.follow_request.subject', name: 'bob'
|
||||
|
||||
it 'renders the email' do
|
||||
expect { mail.deliver }
|
||||
.to send_email(
|
||||
subject: 'Pending follower: bob'
|
||||
)
|
||||
expect(mail.text_part.body)
|
||||
.to match('bob has requested to follow you')
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_subject('Pending follower: bob'))
|
||||
.and(have_body_text('bob has requested to follow you'))
|
||||
.and have_standard_headers('follow_request').for(receiver)
|
||||
.to have_standard_headers('follow_request').for(receiver)
|
||||
end
|
||||
|
||||
it_behaves_like 'delivery to non functional user'
|
||||
|
||||
@@ -8,8 +8,8 @@ RSpec.describe UserMailer do
|
||||
before { receiver.account.update(memorial: true) }
|
||||
|
||||
it 'does not deliver mail' do
|
||||
emails = capture_emails { mail.deliver_now }
|
||||
expect(emails).to be_empty
|
||||
expect { mail.deliver_now }
|
||||
.to_not send_email
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -59,11 +59,10 @@ RSpec.describe UserMailer do
|
||||
it 'renders confirmation instructions' do
|
||||
receiver.update!(locale: nil)
|
||||
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_body_text(I18n.t('devise.mailer.confirmation_instructions.title')))
|
||||
.and(have_body_text('spec'))
|
||||
.and(have_body_text(Rails.configuration.x.local_domain))
|
||||
expect(mail.text_part.body)
|
||||
.to match(I18n.t('devise.mailer.confirmation_instructions.title'))
|
||||
.and match('spec')
|
||||
.and match(Rails.configuration.x.local_domain)
|
||||
end
|
||||
|
||||
it_behaves_like 'localized subject',
|
||||
@@ -78,11 +77,10 @@ RSpec.describe UserMailer do
|
||||
it 'renders reconfirmation instructions' do
|
||||
receiver.update!(email: 'new-email@example.com', locale: nil)
|
||||
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_body_text(I18n.t('devise.mailer.reconfirmation_instructions.title')))
|
||||
.and(have_body_text('spec'))
|
||||
.and(have_body_text(Rails.configuration.x.local_domain))
|
||||
expect(mail.text_part.body)
|
||||
.to match(I18n.t('devise.mailer.reconfirmation_instructions.title'))
|
||||
.and match('spec')
|
||||
.and match(Rails.configuration.x.local_domain)
|
||||
end
|
||||
|
||||
it_behaves_like 'localized subject',
|
||||
@@ -97,10 +95,9 @@ RSpec.describe UserMailer do
|
||||
it 'renders reset password instructions' do
|
||||
receiver.update!(locale: nil)
|
||||
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_body_text(I18n.t('devise.mailer.reset_password_instructions.title')))
|
||||
.and(have_body_text('spec'))
|
||||
expect(mail.text_part.body)
|
||||
.to match(I18n.t('devise.mailer.reset_password_instructions.title'))
|
||||
.and match('spec')
|
||||
end
|
||||
|
||||
it_behaves_like 'localized subject',
|
||||
@@ -114,9 +111,8 @@ RSpec.describe UserMailer do
|
||||
it 'renders password change notification' do
|
||||
receiver.update!(locale: nil)
|
||||
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_body_text(I18n.t('devise.mailer.password_change.title')))
|
||||
expect(mail.text_part.body)
|
||||
.to match(I18n.t('devise.mailer.password_change.title'))
|
||||
end
|
||||
|
||||
it_behaves_like 'localized subject',
|
||||
@@ -130,9 +126,8 @@ RSpec.describe UserMailer do
|
||||
it 'renders email change notification' do
|
||||
receiver.update!(locale: nil)
|
||||
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_body_text(I18n.t('devise.mailer.email_changed.title')))
|
||||
expect(mail.text_part.body)
|
||||
.to match(I18n.t('devise.mailer.email_changed.title'))
|
||||
end
|
||||
|
||||
it_behaves_like 'localized subject',
|
||||
@@ -149,10 +144,9 @@ RSpec.describe UserMailer do
|
||||
it 'renders warning notification' do
|
||||
receiver.update!(locale: nil)
|
||||
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_body_text(I18n.t('user_mailer.warning.title.suspend', acct: receiver.account.acct)))
|
||||
.and(have_body_text(strike.text))
|
||||
expect(mail.text_part.body)
|
||||
.to match(I18n.t('user_mailer.warning.title.suspend', acct: receiver.account.acct))
|
||||
.and match(strike.text)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -163,9 +157,8 @@ RSpec.describe UserMailer do
|
||||
it 'renders webauthn credential deleted notification' do
|
||||
receiver.update!(locale: nil)
|
||||
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_body_text(I18n.t('devise.mailer.webauthn_credential.deleted.title')))
|
||||
expect(mail.text_part.body)
|
||||
.to match(I18n.t('devise.mailer.webauthn_credential.deleted.title'))
|
||||
end
|
||||
|
||||
it_behaves_like 'localized subject',
|
||||
@@ -182,9 +175,8 @@ RSpec.describe UserMailer do
|
||||
it 'renders suspicious sign in notification' do
|
||||
receiver.update!(locale: nil)
|
||||
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_body_text(I18n.t('user_mailer.suspicious_sign_in.explanation')))
|
||||
expect(mail.text_part.body)
|
||||
.to match(I18n.t('user_mailer.suspicious_sign_in.explanation'))
|
||||
end
|
||||
|
||||
it_behaves_like 'localized subject',
|
||||
@@ -200,9 +192,8 @@ RSpec.describe UserMailer do
|
||||
it 'renders failed 2FA notification' do
|
||||
receiver.update!(locale: nil)
|
||||
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_body_text(I18n.t('user_mailer.failed_2fa.explanation')))
|
||||
expect(mail.text_part.body)
|
||||
.to match(I18n.t('user_mailer.failed_2fa.explanation'))
|
||||
end
|
||||
|
||||
it_behaves_like 'localized subject',
|
||||
@@ -214,10 +205,10 @@ RSpec.describe UserMailer do
|
||||
let(:mail) { described_class.appeal_approved(receiver, appeal) }
|
||||
|
||||
it 'renders appeal_approved notification' do
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_subject(I18n.t('user_mailer.appeal_approved.subject', date: I18n.l(appeal.created_at))))
|
||||
.and(have_body_text(I18n.t('user_mailer.appeal_approved.title')))
|
||||
expect { mail.deliver }
|
||||
.to send_email(subject: I18n.t('user_mailer.appeal_approved.subject', date: I18n.l(appeal.created_at)))
|
||||
expect(mail.text_part.body)
|
||||
.to match(I18n.t('user_mailer.appeal_approved.title'))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -226,10 +217,10 @@ RSpec.describe UserMailer do
|
||||
let(:mail) { described_class.appeal_rejected(receiver, appeal) }
|
||||
|
||||
it 'renders appeal_rejected notification' do
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_subject(I18n.t('user_mailer.appeal_rejected.subject', date: I18n.l(appeal.created_at))))
|
||||
.and(have_body_text(I18n.t('user_mailer.appeal_rejected.title')))
|
||||
expect { mail.deliver }
|
||||
.to send_email(subject: I18n.t('user_mailer.appeal_rejected.subject', date: I18n.l(appeal.created_at)))
|
||||
expect(mail.text_part.body)
|
||||
.to match(I18n.t('user_mailer.appeal_rejected.title'))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -237,10 +228,10 @@ RSpec.describe UserMailer do
|
||||
let(:mail) { described_class.two_factor_enabled(receiver) }
|
||||
|
||||
it 'renders two_factor_enabled mail' do
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_subject(I18n.t('devise.mailer.two_factor_enabled.subject')))
|
||||
.and(have_body_text(I18n.t('devise.mailer.two_factor_enabled.explanation')))
|
||||
expect { mail.deliver }
|
||||
.to send_email(subject: I18n.t('devise.mailer.two_factor_enabled.subject'))
|
||||
expect(mail.text_part.body)
|
||||
.to match(I18n.t('devise.mailer.two_factor_enabled.explanation'))
|
||||
end
|
||||
|
||||
it_behaves_like 'delivery to memorialized user'
|
||||
@@ -250,10 +241,10 @@ RSpec.describe UserMailer do
|
||||
let(:mail) { described_class.two_factor_disabled(receiver) }
|
||||
|
||||
it 'renders two_factor_disabled mail' do
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_subject(I18n.t('devise.mailer.two_factor_disabled.subject')))
|
||||
.and(have_body_text(I18n.t('devise.mailer.two_factor_disabled.explanation')))
|
||||
expect { mail.deliver }
|
||||
.to send_email(subject: I18n.t('devise.mailer.two_factor_disabled.subject'))
|
||||
expect(mail.text_part.body)
|
||||
.to match(I18n.t('devise.mailer.two_factor_disabled.explanation'))
|
||||
end
|
||||
|
||||
it_behaves_like 'delivery to memorialized user'
|
||||
@@ -263,10 +254,10 @@ RSpec.describe UserMailer do
|
||||
let(:mail) { described_class.webauthn_enabled(receiver) }
|
||||
|
||||
it 'renders webauthn_enabled mail' do
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_subject(I18n.t('devise.mailer.webauthn_enabled.subject')))
|
||||
.and(have_body_text(I18n.t('devise.mailer.webauthn_enabled.explanation')))
|
||||
expect { mail.deliver }
|
||||
.to send_email(subject: I18n.t('devise.mailer.webauthn_enabled.subject'))
|
||||
expect(mail.text_part.body)
|
||||
.to match(I18n.t('devise.mailer.webauthn_enabled.explanation'))
|
||||
end
|
||||
|
||||
it_behaves_like 'delivery to memorialized user'
|
||||
@@ -276,10 +267,10 @@ RSpec.describe UserMailer do
|
||||
let(:mail) { described_class.webauthn_disabled(receiver) }
|
||||
|
||||
it 'renders webauthn_disabled mail' do
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_subject(I18n.t('devise.mailer.webauthn_disabled.subject')))
|
||||
.and(have_body_text(I18n.t('devise.mailer.webauthn_disabled.explanation')))
|
||||
expect { mail.deliver }
|
||||
.to send_email(subject: I18n.t('devise.mailer.webauthn_disabled.subject'))
|
||||
expect(mail.text_part.body)
|
||||
.to match(I18n.t('devise.mailer.webauthn_disabled.explanation'))
|
||||
end
|
||||
|
||||
it_behaves_like 'delivery to memorialized user'
|
||||
@@ -289,10 +280,10 @@ RSpec.describe UserMailer do
|
||||
let(:mail) { described_class.two_factor_recovery_codes_changed(receiver) }
|
||||
|
||||
it 'renders two_factor_recovery_codes_changed mail' do
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_subject(I18n.t('devise.mailer.two_factor_recovery_codes_changed.subject')))
|
||||
.and(have_body_text(I18n.t('devise.mailer.two_factor_recovery_codes_changed.explanation')))
|
||||
expect { mail.deliver }
|
||||
.to send_email(subject: I18n.t('devise.mailer.two_factor_recovery_codes_changed.subject'))
|
||||
expect(mail.text_part.body)
|
||||
.to match(I18n.t('devise.mailer.two_factor_recovery_codes_changed.explanation'))
|
||||
end
|
||||
|
||||
it_behaves_like 'delivery to memorialized user'
|
||||
@@ -303,10 +294,10 @@ RSpec.describe UserMailer do
|
||||
let(:mail) { described_class.webauthn_credential_added(receiver, credential) }
|
||||
|
||||
it 'renders webauthn_credential_added mail' do
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_subject(I18n.t('devise.mailer.webauthn_credential.added.subject')))
|
||||
.and(have_body_text(I18n.t('devise.mailer.webauthn_credential.added.explanation')))
|
||||
expect { mail.deliver }
|
||||
.to send_email(subject: I18n.t('devise.mailer.webauthn_credential.added.subject'))
|
||||
expect(mail.text_part.body)
|
||||
.to match(I18n.t('devise.mailer.webauthn_credential.added.explanation'))
|
||||
end
|
||||
|
||||
it_behaves_like 'delivery to memorialized user'
|
||||
@@ -322,10 +313,10 @@ RSpec.describe UserMailer do
|
||||
end
|
||||
|
||||
it 'renders welcome mail' do
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_subject(I18n.t('user_mailer.welcome.subject')))
|
||||
.and(have_body_text(I18n.t('user_mailer.welcome.explanation')))
|
||||
expect { mail.deliver }
|
||||
.to send_email(subject: I18n.t('user_mailer.welcome.subject'))
|
||||
expect(mail.text_part.body)
|
||||
.to match(I18n.t('user_mailer.welcome.explanation'))
|
||||
end
|
||||
|
||||
it_behaves_like 'delivery to memorialized user'
|
||||
@@ -336,10 +327,10 @@ RSpec.describe UserMailer do
|
||||
let(:mail) { described_class.backup_ready(receiver, backup) }
|
||||
|
||||
it 'renders backup_ready mail' do
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_subject(I18n.t('user_mailer.backup_ready.subject')))
|
||||
.and(have_body_text(I18n.t('user_mailer.backup_ready.explanation')))
|
||||
expect { mail.deliver }
|
||||
.to send_email(subject: I18n.t('user_mailer.backup_ready.subject'))
|
||||
expect(mail.text_part.body)
|
||||
.to match(I18n.t('user_mailer.backup_ready.explanation'))
|
||||
end
|
||||
|
||||
it_behaves_like 'delivery to memorialized user'
|
||||
@@ -350,10 +341,10 @@ RSpec.describe UserMailer do
|
||||
let(:mail) { described_class.terms_of_service_changed(receiver, terms) }
|
||||
|
||||
it 'renders terms_of_service_changed mail' do
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_subject(I18n.t('user_mailer.terms_of_service_changed.subject')))
|
||||
.and(have_body_text(I18n.t('user_mailer.terms_of_service_changed.changelog')))
|
||||
expect { mail.deliver }
|
||||
.to send_email(subject: I18n.t('user_mailer.terms_of_service_changed.subject'))
|
||||
expect(mail.text_part.body)
|
||||
.to match(I18n.t('user_mailer.terms_of_service_changed.changelog'))
|
||||
end
|
||||
|
||||
it_behaves_like 'optional bulk mailer settings'
|
||||
@@ -364,10 +355,10 @@ RSpec.describe UserMailer do
|
||||
let(:mail) { described_class.announcement_published(receiver, announcement) }
|
||||
|
||||
it 'renders announcement_published mail' do
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_subject(I18n.t('user_mailer.announcement_published.subject')))
|
||||
.and(have_body_text(I18n.t('user_mailer.announcement_published.description', domain: local_domain_uri.host)))
|
||||
expect { mail.deliver }
|
||||
.to send_email(subject: I18n.t('user_mailer.announcement_published.subject'))
|
||||
expect(mail.text_part.body)
|
||||
.to match(I18n.t('user_mailer.announcement_published.description', domain: local_domain_uri.host))
|
||||
end
|
||||
|
||||
it_behaves_like 'optional bulk mailer settings'
|
||||
|
||||
@@ -43,7 +43,6 @@ require 'webmock/rspec'
|
||||
require 'paperclip/matchers'
|
||||
require 'capybara/rspec'
|
||||
require 'chewy/rspec'
|
||||
require 'email_spec/rspec'
|
||||
require 'pundit/rspec'
|
||||
require 'test_prof/recipes/rspec/before_all'
|
||||
|
||||
@@ -58,8 +57,6 @@ Sidekiq.default_configuration.logger = nil
|
||||
|
||||
DatabaseCleaner.strategy = [:deletion]
|
||||
|
||||
Chewy.settings[:enabled] = false
|
||||
|
||||
Devise::Test::ControllerHelpers.module_eval do
|
||||
alias_method :original_sign_in, :sign_in
|
||||
|
||||
|
||||
@@ -6,10 +6,20 @@ RSpec.describe 'Accounts show response' do
|
||||
let(:account) { Fabricate(:account) }
|
||||
|
||||
context 'with numeric-based identifiers' do
|
||||
it 'returns http success' do
|
||||
get "/ap/users/#{account.id}"
|
||||
context 'with JSON format' do
|
||||
it 'returns http success' do
|
||||
get "/ap/users/#{account.id}", headers: { 'ACCEPT' => 'application/json' }
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(response).to have_http_status(200)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with HTML format' do
|
||||
it 'redirects to success' do
|
||||
get "/ap/users/#{account.id}", as: 'html'
|
||||
|
||||
expect(response).to redirect_to("/@#{account.username}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -136,6 +136,48 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an implicit update of a poll that has already expired' do
|
||||
let(:account) { Fabricate(:account, domain: 'example.com') }
|
||||
let!(:expiration) { 10.days.ago.utc }
|
||||
let!(:status) do
|
||||
Fabricate(:status,
|
||||
text: 'Hello world',
|
||||
account: account,
|
||||
poll_attributes: {
|
||||
options: %w(Foo Bar),
|
||||
account: account,
|
||||
multiple: false,
|
||||
hide_totals: false,
|
||||
expires_at: expiration,
|
||||
})
|
||||
end
|
||||
|
||||
let(:payload) do
|
||||
{
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
id: 'https://example.com/foo',
|
||||
type: 'Question',
|
||||
content: 'Hello world',
|
||||
endTime: expiration.iso8601,
|
||||
oneOf: [
|
||||
poll_option_json('Foo', 4),
|
||||
poll_option_json('Bar', 3),
|
||||
],
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
travel_to(expiration - 1.day) do
|
||||
Fabricate(:poll_vote, poll: status.poll)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not re-trigger notifications' do
|
||||
expect { subject.call(status, json, json) }
|
||||
.to_not enqueue_sidekiq_job(PollExpirationNotifyWorker)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the status changes a poll despite being not explicitly marked as updated' do
|
||||
let(:account) { Fabricate(:account, domain: 'example.com') }
|
||||
let!(:expiration) { 10.days.from_now.utc }
|
||||
|
||||
@@ -17,8 +17,8 @@ RSpec::Matchers.define :have_thread_headers do
|
||||
match(notify_expectation_failures: true) do |mail|
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_header('In-Reply-To', conversation_header_regex))
|
||||
.and(have_header('References', conversation_header_regex))
|
||||
.and have_header('In-Reply-To', conversation_header_regex)
|
||||
.and have_header('References', conversation_header_regex)
|
||||
end
|
||||
|
||||
def conversation_header_regex = /<conversation-\d+.\d\d\d\d-\d\d-\d\d@#{Regexp.quote(Rails.configuration.x.local_domain)}>/
|
||||
@@ -32,12 +32,21 @@ RSpec::Matchers.define :have_standard_headers do |type|
|
||||
match(notify_expectation_failures: true) do |mail|
|
||||
expect(mail)
|
||||
.to be_present
|
||||
.and(have_header('To', "#{@user.account.username} <#{@user.email}>"))
|
||||
.and(have_header('List-ID', "<#{type}.#{@user.account.username}.#{Rails.configuration.x.local_domain}>"))
|
||||
.and(have_header('List-Unsubscribe', %r{<https://#{Rails.configuration.x.local_domain}/unsubscribe\?token=.+>}))
|
||||
.and(have_header('List-Unsubscribe', /&type=#{type}/))
|
||||
.and(have_header('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click'))
|
||||
.and(deliver_to("#{@user.account.username} <#{@user.email}>"))
|
||||
.and(deliver_from(Rails.configuration.action_mailer.default_options[:from]))
|
||||
.and have_header('To', "#{@user.account.username} <#{@user.email}>")
|
||||
.and have_header('List-ID', "<#{type}.#{@user.account.username}.#{Rails.configuration.x.local_domain}>")
|
||||
.and have_header('List-Unsubscribe', %r{<https://#{Rails.configuration.x.local_domain}/unsubscribe\?token=.+>})
|
||||
.and have_header('List-Unsubscribe', /&type=#{type}/)
|
||||
.and have_header('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click')
|
||||
expect(mail.to)
|
||||
.to contain_exactly(@user.email)
|
||||
expect(mail.from)
|
||||
.to contain_exactly(Rails.configuration.action_mailer.default_options[:from])
|
||||
end
|
||||
end
|
||||
|
||||
RSpec::Matchers.define :have_header do |name, value|
|
||||
match(notify_expectation_failures: true) do |mail|
|
||||
expect(mail.header[name].value)
|
||||
.to match(value)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -45,11 +45,14 @@ end
|
||||
RSpec.configure do |config|
|
||||
config.before :suite do
|
||||
if search_examples_present?
|
||||
Chewy.settings[:enabled] = true
|
||||
# Configure chewy to use `urgent` strategy to index documents
|
||||
Chewy.strategy(:urgent)
|
||||
|
||||
# Create search data
|
||||
search_data_manager.prepare_test_data
|
||||
else
|
||||
Chewy.settings[:enabled] = false
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -22,4 +22,8 @@ module SystemHelpers
|
||||
def frontend_translations(key)
|
||||
FRONTEND_TRANSLATIONS[key]
|
||||
end
|
||||
|
||||
def email_links(email)
|
||||
URI.extract(email.text_part.to_s, %w(http https))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -14,13 +14,10 @@ RSpec.describe 'Admin Announcement Mail Distributions' do
|
||||
expect(page)
|
||||
.to have_title(I18n.t('admin.announcements.preview.title'))
|
||||
|
||||
emails = capture_emails do
|
||||
expect do
|
||||
expect { click_on I18n.t('admin.terms_of_service.preview.send_to_all', count: 1, display_count: 1) }
|
||||
.to(change { announcement.reload.notification_sent_at })
|
||||
end
|
||||
expect(emails.first)
|
||||
.to be_present
|
||||
.and(deliver_to(user.email))
|
||||
end.to send_email(to: user.email)
|
||||
expect(page)
|
||||
.to have_title(I18n.t('admin.announcements.title'))
|
||||
end
|
||||
|
||||
@@ -14,10 +14,8 @@ RSpec.describe 'Admin TermsOfService Tests' do
|
||||
expect(page)
|
||||
.to have_title(I18n.t('admin.announcements.preview.title'))
|
||||
|
||||
emails = capture_emails { click_on I18n.t('admin.terms_of_service.preview.send_preview', email: user.email) }
|
||||
expect(emails.first)
|
||||
.to be_present
|
||||
.and(deliver_to(user.email))
|
||||
expect { click_on I18n.t('admin.terms_of_service.preview.send_preview', email: user.email) }
|
||||
.to send_email(to: user.email)
|
||||
expect(page)
|
||||
.to have_title(I18n.t('admin.announcements.title'))
|
||||
end
|
||||
|
||||
@@ -16,11 +16,8 @@ RSpec.describe 'Admin Change Emails' do
|
||||
.to have_title(I18n.t('admin.accounts.change_email.title', username: user.account.username))
|
||||
|
||||
fill_in 'user_unconfirmed_email', with: 'test@host.example'
|
||||
emails = capture_emails { process_change_email }
|
||||
expect(emails.first)
|
||||
.to be_present
|
||||
.and(deliver_to('test@host.example'))
|
||||
.and(have_subject(/Confirm email/))
|
||||
expect { process_change_email }
|
||||
.to send_email(to: 'test@host.example', subject: /Confirm email/)
|
||||
expect(page)
|
||||
.to have_title(user.account.pretty_acct)
|
||||
end
|
||||
|
||||
@@ -36,14 +36,11 @@ RSpec.describe 'Admin Confirmations' do
|
||||
it 'resends the confirmation mail' do
|
||||
visit admin_account_path(id: user.account.id)
|
||||
|
||||
emails = capture_emails { resend_confirmation }
|
||||
expect { resend_confirmation }
|
||||
.to send_email(to: user.email)
|
||||
expect(page)
|
||||
.to have_title(I18n.t('admin.accounts.title'))
|
||||
.and have_content(I18n.t('admin.accounts.resend_confirmation.success'))
|
||||
|
||||
expect(emails.first)
|
||||
.to be_present
|
||||
.and deliver_to(user.email)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -8,20 +8,11 @@ RSpec.describe 'Admin::Reset' do
|
||||
sign_in admin_user
|
||||
visit admin_account_path(account.id)
|
||||
|
||||
emails = capture_emails do
|
||||
expect do
|
||||
expect { submit_reset }
|
||||
.to change(Admin::ActionLog.where(target: account.user), :count).by(1)
|
||||
end
|
||||
|
||||
expect(emails.first)
|
||||
.to be_present
|
||||
.and(deliver_to(account.user.email))
|
||||
.and(have_subject(password_change_subject))
|
||||
|
||||
expect(emails.last)
|
||||
.to be_present
|
||||
.and(deliver_to(account.user.email))
|
||||
.and(have_subject(reset_instructions_subject))
|
||||
.to send_email(to: account.user.email, subject: password_change_subject)
|
||||
.and send_email(to: account.user.email, subject: reset_instructions_subject)
|
||||
end.to change(Admin::ActionLog.where(target: account.user), :count).by(1)
|
||||
|
||||
expect(page)
|
||||
.to have_content(account.username)
|
||||
|
||||
@@ -14,13 +14,9 @@ RSpec.describe 'Admin TermsOfService Distributions' do
|
||||
expect(page)
|
||||
.to have_title(I18n.t('admin.terms_of_service.preview.title'))
|
||||
|
||||
emails = capture_emails do
|
||||
expect { click_on I18n.t('admin.terms_of_service.preview.send_to_all', count: 1, display_count: 1) }
|
||||
.to(change { terms_of_service.reload.notification_sent_at })
|
||||
end
|
||||
expect(emails.first)
|
||||
.to be_present
|
||||
.and(deliver_to(user.email))
|
||||
expect { click_on I18n.t('admin.terms_of_service.preview.send_to_all', count: 1, display_count: 1) }
|
||||
.to change { terms_of_service.reload.notification_sent_at }
|
||||
.and send_email(to: user.email)
|
||||
expect(page)
|
||||
.to have_title(I18n.t('admin.terms_of_service.title'))
|
||||
end
|
||||
|
||||
@@ -14,10 +14,8 @@ RSpec.describe 'Admin TermsOfService Tests' do
|
||||
expect(page)
|
||||
.to have_title(I18n.t('admin.terms_of_service.preview.title'))
|
||||
|
||||
emails = capture_emails { click_on I18n.t('admin.terms_of_service.preview.send_preview', email: user.email) }
|
||||
expect(emails.first)
|
||||
.to be_present
|
||||
.and(deliver_to(user.email))
|
||||
expect { click_on I18n.t('admin.terms_of_service.preview.send_preview', email: user.email) }
|
||||
.to send_email(to: user.email)
|
||||
expect(page)
|
||||
.to have_title(I18n.t('admin.terms_of_service.preview.title'))
|
||||
end
|
||||
|
||||
@@ -50,9 +50,8 @@ RSpec.describe 'Auth Passwords' do
|
||||
|
||||
def submit_email_reset
|
||||
fill_in 'user_email', with: user.email
|
||||
click_on I18n.t('auth.reset_password')
|
||||
open_last_email
|
||||
visit_in_email(I18n.t('devise.mailer.reset_password_instructions.action'))
|
||||
emails = capture_emails { click_on I18n.t('auth.reset_password') }
|
||||
visit email_links(emails.first).first
|
||||
end
|
||||
|
||||
def set_new_password
|
||||
|
||||
@@ -27,17 +27,9 @@ RSpec.describe 'Dispute Appeals' do
|
||||
|
||||
# Valid with text
|
||||
fill_in 'appeal_text', with: 'It wasnt me this time!'
|
||||
emails = capture_emails do
|
||||
expect { submit_form }
|
||||
.to change(Appeal, :count).by(1)
|
||||
end
|
||||
expect(emails)
|
||||
.to contain_exactly(
|
||||
have_attributes(
|
||||
to: contain_exactly(admin.email),
|
||||
subject: eq(new_appeal_subject)
|
||||
)
|
||||
)
|
||||
expect { submit_form }
|
||||
.to change(Appeal, :count).by(1)
|
||||
.and send_email(to: admin.email, subject: new_appeal_subject)
|
||||
expect(page)
|
||||
.to have_content(I18n.t('disputes.strikes.appealed_msg'))
|
||||
end
|
||||
|
||||
@@ -24,17 +24,12 @@ RSpec.describe 'Settings TwoFactorAuthenticationMethods' do
|
||||
|
||||
# Fill in challenge form
|
||||
fill_in 'form_challenge_current_password', with: user.password
|
||||
emails = capture_emails do
|
||||
expect { click_on I18n.t('challenge.confirm') }
|
||||
.to change { user.reload.otp_required_for_login }.to(false)
|
||||
end
|
||||
expect { click_on I18n.t('challenge.confirm') }
|
||||
.to change { user.reload.otp_required_for_login }.to(false)
|
||||
.and send_email(to: user.email, subject: I18n.t('devise.mailer.two_factor_disabled.subject'))
|
||||
|
||||
expect(page)
|
||||
.to have_content(I18n.t('two_factor_authentication.disabled_success'))
|
||||
expect(emails.first)
|
||||
.to be_present
|
||||
.and(deliver_to(user.email))
|
||||
.and(have_subject(I18n.t('devise.mailer.two_factor_disabled.subject')))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12359,8 +12359,8 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"rollup-plugin-visualizer@npm:^6.0.3":
|
||||
version: 6.0.5
|
||||
resolution: "rollup-plugin-visualizer@npm:6.0.5"
|
||||
version: 6.0.11
|
||||
resolution: "rollup-plugin-visualizer@npm:6.0.11"
|
||||
dependencies:
|
||||
open: "npm:^8.0.0"
|
||||
picomatch: "npm:^4.0.2"
|
||||
@@ -12376,7 +12376,7 @@ __metadata:
|
||||
optional: true
|
||||
bin:
|
||||
rollup-plugin-visualizer: dist/bin/cli.js
|
||||
checksum: 10c0/3824626e97d5033fbb3aa1bbe93c8c17a8569bc47e33c941bde6b90404f2cae70b26fec1b623bd393c3e076338014196c91726ed2c96218edc67e1f21676f7ef
|
||||
checksum: 10c0/a8461e3b1178791e5834617c0e59b89a2832c0a371632e45c8c6934d17baa39f597e74cece5eaecd244f5b3dd0fab14c695f5860de3f3b0ac25e50a221442817
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user