Merge commit '3d8d5f6dc7625d9638cc2e3387247442225d4e3f' into glitch-soc/merge-upstream

This commit is contained in:
Claire
2026-03-05 18:21:07 +01:00
91 changed files with 1551 additions and 690 deletions

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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',

View File

@@ -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

View File

@@ -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 })}

View File

@@ -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';

View File

@@ -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>;
}

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import type { ModalType } from '@/mastodon/actions/modal';
import { openModal } from '@/mastodon/actions/modal';
import { 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 youd like to share.',
},
customFieldsName: {
id: 'account_edit.custom_fields.name',
defaultMessage: 'field',
},
customFieldsTipTitle: {
id: 'account_edit.custom_fields.tip_title',
defaultMessage: 'Tip: Adding verified links',
},
featuredHashtagsTitle: {
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}

View File

@@ -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>
);

View File

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

View File

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

View File

@@ -1,20 +1,14 @@
import { useCallback, useId, useRef, useState } from 'react';
import 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>
);

View File

@@ -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();

View File

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

View File

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

View File

@@ -24,6 +24,38 @@
border: 1px solid var(--color-border-primary);
}
.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);
}

View File

@@ -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>

View File

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

View File

@@ -6,11 +6,9 @@ import { Helmet } from 'react-helmet';
import { useLocation, useParams } from 'react-router';
import { 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>

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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

View File

@@ -14,6 +14,8 @@ import AddIcon from '@/material-icons/400-24px/add.svg?react';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import 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'

View File

@@ -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>}

View File

@@ -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>

View File

@@ -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 = {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "Διαθέσιμο μόνο όταν συνδεθείς.",

View File

@@ -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",

View File

@@ -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 youd like to share.",
"account_edit.custom_fields.tip_content": "You can easily add credibility to your Mastodon account by verifying links to any websites you own.",
"account_edit.custom_fields.tip_title": "Tip: Adding verified links",
"account_edit.custom_fields.title": "Custom fields",
"account_edit.custom_fields.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 cant be undone.",
"account_edit.field_delete_modal.delete_button": "Delete",
"account_edit.field_delete_modal.title": "Delete custom field?",
"account_edit.field_edit_modal.add_title": "Add custom field",
"account_edit.field_edit_modal.edit_title": "Edit custom field",
"account_edit.field_edit_modal.name_hint": "E.g. “Personal website”",
"account_edit.field_edit_modal.name_label": "Label",
"account_edit.field_edit_modal.value_hint": "E.g. “example.me”",
"account_edit.field_edit_modal.value_label": "Value",
"account_edit.name_modal.add_title": "Add display name",
"account_edit.name_modal.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. Heres how it works:",
"account_edit.verified_modal.invisible_link.details": "Add the link to your header. The important part is rel=\"me\" which prevents impersonation on websites with user-generated content. You can even use a link tag in the header of the page instead of {tag}, but the HTML must be accessible without executing JavaScript.",
"account_edit.verified_modal.invisible_link.summary": "How do I make the link invisible?",
"account_edit.verified_modal.step1.header": "Copy the HTML code below and paste into the header of your website",
"account_edit.verified_modal.step2.details": "If youve already added your website as a custom field, youll need to delete and re-add it to trigger verification.",
"account_edit.verified_modal.step2.header": "Add your website as a custom field",
"account_edit.verified_modal.title": "How to add a verified link",
"account_edit_tags.add_tag": "Add #{tagName}",
"account_edit_tags.column_title": "Edit featured hashtags",
"account_edit_tags.help_text": "Featured hashtags help users discover and interact with your profile. They appear as filters on your Profile pages Activity view.",
@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "חיפוש שרת אחר",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 skeni nevojë për një llogari posaçërisht në {domain} që të përdorni Mastodon-in.",
"closed_registrations_modal.find_another_server": "Gjeni shërbyes tjetër",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "查找其他服务器",

View File

@@ -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": "尋找另一個伺服器",

View File

@@ -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,

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 265 B

View File

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

After

Width:  |  Height:  |  Size: 421 B

View File

@@ -2009,7 +2009,10 @@ body > [data-popper-placement] {
.account {
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;

View File

@@ -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

View File

@@ -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

View File

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

View File

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

View File

@@ -84,9 +84,9 @@ es-AR:
credential_flow_not_configured: Las credenciales de contraseña del propietario del recurso fallaron debido a que "Doorkeeper.configure.resource_owner_from_credentials" está sin configurar.
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:

View File

@@ -83,6 +83,10 @@ hu:
access_denied: Az erőforrás tulajdonosa vagy az engedélyező kiszolgáló elutasította a kérést.
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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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

View File

@@ -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}"

View File

@@ -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

View File

@@ -28,7 +28,7 @@ lv:
none: Šis ir izmantojams, lai nosūtītu lietotājam brīdinājumu bez jebkādu citu darbību izraisīšanas.
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

View File

@@ -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

View File

@@ -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

View File

@@ -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'

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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 }

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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