mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-29 03:00:33 +02:00
Merge commit 'c72ca33fac1ae1518371f5954ae9487692b17709' into glitch-soc/merge-upstream
This commit is contained in:
@@ -26,9 +26,10 @@ import { modes } from './modes';
|
|||||||
import '../app/javascript/styles/application.scss';
|
import '../app/javascript/styles/application.scss';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
const localeFiles = import.meta.glob('@/mastodon/locales/*.json', {
|
// Disabling locales in Storybook as it's breaking with Vite 8.
|
||||||
query: { as: 'json' },
|
// const localeFiles = import.meta.glob('@/mastodon/locales/*.json', {
|
||||||
});
|
// query: { as: 'json' },
|
||||||
|
// });
|
||||||
|
|
||||||
// Initialize MSW
|
// Initialize MSW
|
||||||
initialize({
|
initialize({
|
||||||
@@ -39,17 +40,17 @@ const preview: Preview = {
|
|||||||
// Auto-generate docs: https://storybook.js.org/docs/writing-docs/autodocs
|
// Auto-generate docs: https://storybook.js.org/docs/writing-docs/autodocs
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
globalTypes: {
|
globalTypes: {
|
||||||
locale: {
|
// locale: {
|
||||||
description: 'Locale for the story',
|
// description: 'Locale for the story',
|
||||||
toolbar: {
|
// toolbar: {
|
||||||
title: 'Locale',
|
// title: 'Locale',
|
||||||
icon: 'globe',
|
// icon: 'globe',
|
||||||
items: Object.keys(localeFiles).map((path) =>
|
// items: Object.keys(localeFiles).map((path) =>
|
||||||
path.replace('/mastodon/locales/', '').replace('.json', ''),
|
// path.replace('/mastodon/locales/', '').replace('.json', ''),
|
||||||
),
|
// ),
|
||||||
dynamicTitle: true,
|
// dynamicTitle: true,
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
theme: {
|
theme: {
|
||||||
description: 'Theme for the story',
|
description: 'Theme for the story',
|
||||||
toolbar: {
|
toolbar: {
|
||||||
|
|||||||
30
CHANGELOG.md
30
CHANGELOG.md
@@ -2,6 +2,36 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [4.5.8] - 2026-03-24
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Fix insufficient checks on quote authorizations ([GHSA-q4g8-82c5-9h33](https://github.com/mastodon/mastodon/security/advisories/GHSA-q4g8-82c5-9h33))
|
||||||
|
- Fix open redirect in legacy path handler ([GHSA-xqw8-4j56-5hj6](https://github.com/mastodon/mastodon/security/advisories/GHSA-xqw8-4j56-5hj6))
|
||||||
|
- Updated dependencies
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Add for searching already-known private GtS posts (#38057 by @ClearlyClaire)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Change media description length limit for remote media attachments from 1500 to 10000 characters (#37921 by @ClearlyClaire)
|
||||||
|
- Change HTTP signatures to skip the `Accept` header (#38132 by @ClearlyClaire)
|
||||||
|
- Change numeric AP endpoints to redirect to short account URLs when HTML is requested (#38056 by @ClearlyClaire)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix some model definitions in `tootctl maintenance fix-duplicates` (#38214 by @ClearlyClaire)
|
||||||
|
- Fix overly strict checks for current username on account migration page (#38183 by @mjankowski)
|
||||||
|
- Fix OpenStack Swift Keystone token rate limiting (#38145 by @hugogameiro)
|
||||||
|
- Fix poll expiration notification being re-triggered on implicit updates (#38078 by @ClearlyClaire)
|
||||||
|
- Fix incorrect translation string in webauthn mailers (#38062 by @mjankowski)
|
||||||
|
- Fix “Unblock” and “Unmute” actions being disabled when blocked (#38075 by @ClearlyClaire)
|
||||||
|
- Fix username availability check being wrongly applied on race conditions (#37975 by @ClearlyClaire)
|
||||||
|
- Fix hover card unintentionally being shown in some cases (#38039 and #38112 by @diondiondion)
|
||||||
|
- Fix existing posts not being removed from lists when a list member is unfollowed (#38048 by @ClearlyClaire)
|
||||||
|
|
||||||
## [4.5.7] - 2026-02-24
|
## [4.5.7] - 2026-02-24
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|||||||
@@ -267,6 +267,15 @@ export const ColumnHeader: React.FC<Props> = ({
|
|||||||
const hasTitle = (hasIcon || backButton) && title;
|
const hasTitle = (hasIcon || backButton) && title;
|
||||||
const columnIndex = useColumnIndexContext();
|
const columnIndex = useColumnIndexContext();
|
||||||
|
|
||||||
|
const titleContents = (
|
||||||
|
<>
|
||||||
|
{!backButton && hasIcon && (
|
||||||
|
<Icon id={icon} icon={iconComponent} className='column-header__icon' />
|
||||||
|
)}
|
||||||
|
{title}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
const component = (
|
const component = (
|
||||||
<div className={wrapperClassName}>
|
<div className={wrapperClassName}>
|
||||||
<h1 className={buttonClassName}>
|
<h1 className={buttonClassName}>
|
||||||
@@ -274,21 +283,25 @@ export const ColumnHeader: React.FC<Props> = ({
|
|||||||
<>
|
<>
|
||||||
{backButton}
|
{backButton}
|
||||||
|
|
||||||
<button
|
{onClick && (
|
||||||
onClick={handleTitleClick}
|
<button
|
||||||
className='column-header__title'
|
onClick={handleTitleClick}
|
||||||
type='button'
|
className='column-header__title'
|
||||||
id={getColumnSkipLinkId(columnIndex)}
|
type='button'
|
||||||
>
|
id={getColumnSkipLinkId(columnIndex)}
|
||||||
{!backButton && hasIcon && (
|
>
|
||||||
<Icon
|
{titleContents}
|
||||||
id={icon}
|
</button>
|
||||||
icon={iconComponent}
|
)}
|
||||||
className='column-header__icon'
|
{!onClick && (
|
||||||
/>
|
<span
|
||||||
)}
|
className='column-header__title'
|
||||||
{title}
|
tabIndex={-1}
|
||||||
</button>
|
id={getColumnSkipLinkId(columnIndex)}
|
||||||
|
>
|
||||||
|
{titleContents}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
margin: 6px 0;
|
margin: 6px 0;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
|
display: block;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ export type RangeInputProps = Omit<
|
|||||||
markers?: { value: number; label: string }[] | number[];
|
markers?: { value: number; label: string }[] | number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props extends RangeInputProps, CommonFieldWrapperProps {}
|
interface Props extends RangeInputProps, CommonFieldWrapperProps {
|
||||||
|
inputPlacement?: 'inline-start' | 'inline-end'; // TODO: Move this to the common field wrapper props for other fields.
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A simple form field for single-line text.
|
* A simple form field for single-line text.
|
||||||
@@ -25,7 +27,16 @@ interface Props extends RangeInputProps, CommonFieldWrapperProps {}
|
|||||||
|
|
||||||
export const RangeInputField = forwardRef<HTMLInputElement, Props>(
|
export const RangeInputField = forwardRef<HTMLInputElement, Props>(
|
||||||
(
|
(
|
||||||
{ id, label, hint, status, required, wrapperClassName, ...otherProps },
|
{
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
status,
|
||||||
|
required,
|
||||||
|
wrapperClassName,
|
||||||
|
inputPlacement,
|
||||||
|
...otherProps
|
||||||
|
},
|
||||||
ref,
|
ref,
|
||||||
) => (
|
) => (
|
||||||
<FormFieldWrapper
|
<FormFieldWrapper
|
||||||
@@ -34,6 +45,7 @@ export const RangeInputField = forwardRef<HTMLInputElement, Props>(
|
|||||||
required={required}
|
required={required}
|
||||||
status={status}
|
status={status}
|
||||||
inputId={id}
|
inputId={id}
|
||||||
|
inputPlacement={inputPlacement}
|
||||||
className={wrapperClassName}
|
className={wrapperClassName}
|
||||||
>
|
>
|
||||||
{(inputProps) => <RangeInput {...otherProps} {...inputProps} ref={ref} />}
|
{(inputProps) => <RangeInput {...otherProps} {...inputProps} ref={ref} />}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { FC } from 'react';
|
|||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { Helmet } from 'react-helmet';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { Column } from '@/mastodon/components/column';
|
import { Column } from '@/mastodon/components/column';
|
||||||
@@ -36,22 +37,27 @@ export const AccountEditColumn: FC<{
|
|||||||
const { multiColumn } = useColumnsContext();
|
const { multiColumn } = useColumnsContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column bindToDocument={!multiColumn} className={classes.column}>
|
<>
|
||||||
<ColumnHeader
|
<Column bindToDocument={!multiColumn} className={classes.column}>
|
||||||
title={title}
|
<ColumnHeader
|
||||||
className={classes.columnHeader}
|
title={title}
|
||||||
showBackButton
|
className={classes.columnHeader}
|
||||||
extraButton={
|
showBackButton
|
||||||
<Link to={to} className='button'>
|
extraButton={
|
||||||
<FormattedMessage
|
<Link to={to} className='button'>
|
||||||
id='account_edit.column_button'
|
<FormattedMessage
|
||||||
defaultMessage='Done'
|
id='account_edit.column_button'
|
||||||
/>
|
defaultMessage='Done'
|
||||||
</Link>
|
/>
|
||||||
}
|
</Link>
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</Column>
|
</Column>
|
||||||
|
<Helmet>
|
||||||
|
<title>{title}</title>
|
||||||
|
</Helmet>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import type { FC, MouseEventHandler } from 'react';
|
import type { FC, MouseEventHandler } from 'react';
|
||||||
|
|
||||||
import type { MessageDescriptor } from 'react-intl';
|
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { Button } from '@/mastodon/components/button';
|
import { Button } from '@/mastodon/components/button';
|
||||||
@@ -12,43 +9,19 @@ import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
|||||||
|
|
||||||
import classes from '../styles.module.scss';
|
import classes from '../styles.module.scss';
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
add: {
|
|
||||||
id: 'account_edit.button.add',
|
|
||||||
defaultMessage: 'Add {item}',
|
|
||||||
},
|
|
||||||
edit: {
|
|
||||||
id: 'account_edit.button.edit',
|
|
||||||
defaultMessage: 'Edit {item}',
|
|
||||||
},
|
|
||||||
delete: {
|
|
||||||
id: 'account_edit.button.delete',
|
|
||||||
defaultMessage: 'Delete {item}',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export interface EditButtonProps {
|
export interface EditButtonProps {
|
||||||
onClick: MouseEventHandler;
|
onClick: MouseEventHandler;
|
||||||
item: string | MessageDescriptor;
|
label: string;
|
||||||
edit?: boolean;
|
|
||||||
icon?: boolean;
|
icon?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditButton: FC<EditButtonProps> = ({
|
export const EditButton: FC<EditButtonProps> = ({
|
||||||
onClick,
|
onClick,
|
||||||
item,
|
label,
|
||||||
edit = false,
|
icon = false,
|
||||||
icon = edit,
|
|
||||||
disabled,
|
disabled,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
const itemText = typeof item === 'string' ? item : intl.formatMessage(item);
|
|
||||||
const label = intl.formatMessage(messages[edit ? 'edit' : 'add'], {
|
|
||||||
item: itemText,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (icon) {
|
if (icon) {
|
||||||
return (
|
return (
|
||||||
<EditIconButton title={label} onClick={onClick} disabled={disabled} />
|
<EditIconButton title={label} onClick={onClick} disabled={disabled} />
|
||||||
@@ -83,18 +56,15 @@ export const EditIconButton: FC<{
|
|||||||
|
|
||||||
export const DeleteIconButton: FC<{
|
export const DeleteIconButton: FC<{
|
||||||
onClick: MouseEventHandler;
|
onClick: MouseEventHandler;
|
||||||
item: string;
|
label: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}> = ({ onClick, item, disabled }) => {
|
}> = ({ onClick, label, disabled }) => (
|
||||||
const intl = useIntl();
|
<IconButton
|
||||||
return (
|
icon='delete'
|
||||||
<IconButton
|
iconComponent={DeleteIcon}
|
||||||
icon='delete'
|
onClick={onClick}
|
||||||
iconComponent={DeleteIcon}
|
className={classNames(classes.editButton, classes.deleteButton)}
|
||||||
onClick={onClick}
|
title={label}
|
||||||
className={classNames(classes.editButton, classes.deleteButton)}
|
disabled={disabled}
|
||||||
title={intl.formatMessage(messages.delete, { item })}
|
/>
|
||||||
disabled={disabled}
|
);
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import { openModal } from '@/mastodon/actions/modal';
|
import { openModal } from '@/mastodon/actions/modal';
|
||||||
import { useAppDispatch } from '@/mastodon/store';
|
import { useAppDispatch } from '@/mastodon/store';
|
||||||
|
|
||||||
import { EditButton, DeleteIconButton } from './edit_button';
|
import { EditButton, DeleteIconButton } from './edit_button';
|
||||||
|
|
||||||
export const AccountFieldActions: FC<{ item: string; id: string }> = ({
|
const messages = defineMessages({
|
||||||
item,
|
edit: {
|
||||||
id,
|
id: 'account_edit.field_actions.edit',
|
||||||
}) => {
|
defaultMessage: 'Edit field',
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
id: 'account_edit.field_actions.delete',
|
||||||
|
defaultMessage: 'Delete field',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AccountFieldActions: FC<{ id: string }> = ({ id }) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const handleEdit = useCallback(() => {
|
const handleEdit = useCallback(() => {
|
||||||
dispatch(
|
dispatch(
|
||||||
@@ -28,10 +38,19 @@ export const AccountFieldActions: FC<{ item: string; id: string }> = ({
|
|||||||
);
|
);
|
||||||
}, [dispatch, id]);
|
}, [dispatch, id]);
|
||||||
|
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<EditButton item={item} edit onClick={handleEdit} />
|
<EditButton
|
||||||
<DeleteIconButton item={item} onClick={handleDelete} />
|
label={intl.formatMessage(messages.edit)}
|
||||||
|
icon
|
||||||
|
onClick={handleEdit}
|
||||||
|
/>
|
||||||
|
<DeleteIconButton
|
||||||
|
label={intl.formatMessage(messages.delete)}
|
||||||
|
onClick={handleDelete}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import classes from '../styles.module.scss';
|
import classes from '../styles.module.scss';
|
||||||
|
|
||||||
import { DeleteIconButton, EditButton } from './edit_button';
|
import { DeleteIconButton, EditButton } from './edit_button';
|
||||||
@@ -50,6 +52,17 @@ type AccountEditItemButtonsProps<Item extends AnyItem = AnyItem> = Pick<
|
|||||||
'onEdit' | 'onDelete' | 'disabled'
|
'onEdit' | 'onDelete' | 'disabled'
|
||||||
> & { item: Item };
|
> & { item: Item };
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
edit: {
|
||||||
|
id: 'account_edit.item_list.edit',
|
||||||
|
defaultMessage: 'Edit {name}',
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
id: 'account_edit.item_list.delete',
|
||||||
|
defaultMessage: 'Delete {name}',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const AccountEditItemButtons = <Item extends AnyItem>({
|
const AccountEditItemButtons = <Item extends AnyItem>({
|
||||||
item,
|
item,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -63,6 +76,8 @@ const AccountEditItemButtons = <Item extends AnyItem>({
|
|||||||
onDelete?.(item);
|
onDelete?.(item);
|
||||||
}, [item, onDelete]);
|
}, [item, onDelete]);
|
||||||
|
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
if (!onEdit && !onDelete) {
|
if (!onEdit && !onDelete) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -71,15 +86,15 @@ const AccountEditItemButtons = <Item extends AnyItem>({
|
|||||||
<div className={classes.itemListButtons}>
|
<div className={classes.itemListButtons}>
|
||||||
{onEdit && (
|
{onEdit && (
|
||||||
<EditButton
|
<EditButton
|
||||||
edit
|
icon
|
||||||
item={item.name}
|
label={intl.formatMessage(messages.edit, { name: item.name })}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={handleEdit}
|
onClick={handleEdit}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{onDelete && (
|
{onDelete && (
|
||||||
<DeleteIconButton
|
<DeleteIconButton
|
||||||
item={item.name}
|
label={intl.formatMessage(messages.delete, { name: item.name })}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { FC } from 'react';
|
|||||||
|
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { Callout } from '@/mastodon/components/callout';
|
||||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||||
import { Tag } from '@/mastodon/components/tags/tag';
|
import { Tag } from '@/mastodon/components/tags/tag';
|
||||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||||
@@ -28,17 +29,25 @@ import classes from './styles.module.scss';
|
|||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
columnTitle: {
|
columnTitle: {
|
||||||
id: 'account_edit_tags.column_title',
|
id: 'account_edit_tags.column_title',
|
||||||
defaultMessage: 'Edit featured hashtags',
|
defaultMessage: 'Edit Tags',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectTags = createAppSelector(
|
const selectTags = createAppSelector(
|
||||||
[(state) => state.profileEdit],
|
[
|
||||||
(profileEdit) => ({
|
(state) => state.profileEdit,
|
||||||
|
(state) =>
|
||||||
|
state.server.getIn(
|
||||||
|
['server', 'accounts', 'max_featured_tags'],
|
||||||
|
10,
|
||||||
|
) as number,
|
||||||
|
],
|
||||||
|
(profileEdit, maxTags) => ({
|
||||||
tags: profileEdit.profile?.featuredTags ?? [],
|
tags: profileEdit.profile?.featuredTags ?? [],
|
||||||
tagSuggestions: profileEdit.tagSuggestions ?? [],
|
tagSuggestions: profileEdit.tagSuggestions ?? [],
|
||||||
isLoading: !profileEdit.profile || !profileEdit.tagSuggestions,
|
isLoading: !profileEdit.profile || !profileEdit.tagSuggestions,
|
||||||
isPending: profileEdit.isPending,
|
isPending: profileEdit.isPending,
|
||||||
|
maxTags,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -47,7 +56,7 @@ export const AccountEditFeaturedTags: FC = () => {
|
|||||||
const account = useAccount(accountId);
|
const account = useAccount(accountId);
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const { tags, tagSuggestions, isLoading, isPending } =
|
const { tags, tagSuggestions, isLoading, isPending, maxTags } =
|
||||||
useAppSelector(selectTags);
|
useAppSelector(selectTags);
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@@ -67,6 +76,8 @@ export const AccountEditFeaturedTags: FC = () => {
|
|||||||
return <AccountEditEmptyColumn notFound={!accountId} />;
|
return <AccountEditEmptyColumn notFound={!accountId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canAddMoreTags = tags.length < maxTags;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccountEditColumn
|
<AccountEditColumn
|
||||||
title={intl.formatMessage(messages.columnTitle)}
|
title={intl.formatMessage(messages.columnTitle)}
|
||||||
@@ -79,9 +90,9 @@ export const AccountEditFeaturedTags: FC = () => {
|
|||||||
tagName='p'
|
tagName='p'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AccountEditTagSearch />
|
{canAddMoreTags && <AccountEditTagSearch />}
|
||||||
|
|
||||||
{tagSuggestions.length > 0 && (
|
{tagSuggestions.length > 0 && canAddMoreTags && (
|
||||||
<div className={classes.tagSuggestions}>
|
<div className={classes.tagSuggestions}>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='account_edit_tags.suggestions'
|
id='account_edit_tags.suggestions'
|
||||||
@@ -93,6 +104,15 @@ export const AccountEditFeaturedTags: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!canAddMoreTags && (
|
||||||
|
<Callout icon={false} className={classes.maxTagsWarning}>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit_tags.max_tags_reached'
|
||||||
|
defaultMessage='You have reached the maximum number of featured hashtags.'
|
||||||
|
/>
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading && <LoadingIndicator />}
|
{isLoading && <LoadingIndicator />}
|
||||||
|
|
||||||
<AccountEditItemList
|
<AccountEditItemList
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ export const messages = defineMessages({
|
|||||||
defaultMessage:
|
defaultMessage:
|
||||||
'Your display name is how your name appears on your profile and in timelines.',
|
'Your display name is how your name appears on your profile and in timelines.',
|
||||||
},
|
},
|
||||||
|
displayNameAddLabel: {
|
||||||
|
id: 'account_edit.display_name.add_label',
|
||||||
|
defaultMessage: 'Add display name',
|
||||||
|
},
|
||||||
|
displayNameEditLabel: {
|
||||||
|
id: 'account_edit.display_name.edit_label',
|
||||||
|
defaultMessage: 'Edit display name',
|
||||||
|
},
|
||||||
bioTitle: {
|
bioTitle: {
|
||||||
id: 'account_edit.bio.title',
|
id: 'account_edit.bio.title',
|
||||||
defaultMessage: 'Bio',
|
defaultMessage: 'Bio',
|
||||||
@@ -50,6 +58,14 @@ export const messages = defineMessages({
|
|||||||
id: 'account_edit.bio.placeholder',
|
id: 'account_edit.bio.placeholder',
|
||||||
defaultMessage: 'Add a short introduction to help others identify you.',
|
defaultMessage: 'Add a short introduction to help others identify you.',
|
||||||
},
|
},
|
||||||
|
bioAddLabel: {
|
||||||
|
id: 'account_edit.bio.label',
|
||||||
|
defaultMessage: 'Add bio',
|
||||||
|
},
|
||||||
|
bioEditLabel: {
|
||||||
|
id: 'account_edit.bio.edit_label',
|
||||||
|
defaultMessage: 'Edit bio',
|
||||||
|
},
|
||||||
customFieldsTitle: {
|
customFieldsTitle: {
|
||||||
id: 'account_edit.custom_fields.title',
|
id: 'account_edit.custom_fields.title',
|
||||||
defaultMessage: 'Custom fields',
|
defaultMessage: 'Custom fields',
|
||||||
@@ -59,9 +75,13 @@ export const messages = defineMessages({
|
|||||||
defaultMessage:
|
defaultMessage:
|
||||||
'Add your pronouns, external links, or anything else you’d like to share.',
|
'Add your pronouns, external links, or anything else you’d like to share.',
|
||||||
},
|
},
|
||||||
customFieldsName: {
|
customFieldsAddLabel: {
|
||||||
id: 'account_edit.custom_fields.name',
|
id: 'account_edit.custom_fields.add_label',
|
||||||
defaultMessage: 'field',
|
defaultMessage: 'Add field',
|
||||||
|
},
|
||||||
|
customFieldsEditLabel: {
|
||||||
|
id: 'account_edit.custom_fields.edit_label',
|
||||||
|
defaultMessage: 'Edit field',
|
||||||
},
|
},
|
||||||
customFieldsTipTitle: {
|
customFieldsTipTitle: {
|
||||||
id: 'account_edit.custom_fields.tip_title',
|
id: 'account_edit.custom_fields.tip_title',
|
||||||
@@ -76,9 +96,9 @@ export const messages = defineMessages({
|
|||||||
defaultMessage:
|
defaultMessage:
|
||||||
'Help others identify, and have quick access to, your favorite topics.',
|
'Help others identify, and have quick access to, your favorite topics.',
|
||||||
},
|
},
|
||||||
featuredHashtagsItem: {
|
featuredHashtagsEditLabel: {
|
||||||
id: 'account_edit.featured_hashtags.item',
|
id: 'account_edit.featured_hashtags.edit_label',
|
||||||
defaultMessage: 'hashtags',
|
defaultMessage: 'Add hashtags',
|
||||||
},
|
},
|
||||||
profileTabTitle: {
|
profileTabTitle: {
|
||||||
id: 'account_edit.profile_tab.title',
|
id: 'account_edit.profile_tab.title',
|
||||||
@@ -182,8 +202,12 @@ export const AccountEdit: FC = () => {
|
|||||||
buttons={
|
buttons={
|
||||||
<EditButton
|
<EditButton
|
||||||
onClick={handleNameEdit}
|
onClick={handleNameEdit}
|
||||||
item={messages.displayNameTitle}
|
label={intl.formatMessage(
|
||||||
edit={hasName}
|
hasName
|
||||||
|
? messages.displayNameEditLabel
|
||||||
|
: messages.displayNameAddLabel,
|
||||||
|
)}
|
||||||
|
icon={hasName}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -197,8 +221,10 @@ export const AccountEdit: FC = () => {
|
|||||||
buttons={
|
buttons={
|
||||||
<EditButton
|
<EditButton
|
||||||
onClick={handleBioEdit}
|
onClick={handleBioEdit}
|
||||||
item={messages.bioTitle}
|
label={intl.formatMessage(
|
||||||
edit={hasBio}
|
hasBio ? messages.bioEditLabel : messages.bioAddLabel,
|
||||||
|
)}
|
||||||
|
icon={hasBio}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -214,7 +240,7 @@ export const AccountEdit: FC = () => {
|
|||||||
description={messages.customFieldsPlaceholder}
|
description={messages.customFieldsPlaceholder}
|
||||||
showDescription={!hasFields}
|
showDescription={!hasFields}
|
||||||
buttons={
|
buttons={
|
||||||
<>
|
<div className={classes.fieldButtons}>
|
||||||
<Button
|
<Button
|
||||||
className={classes.editButton}
|
className={classes.editButton}
|
||||||
onClick={handleCustomFieldReorder}
|
onClick={handleCustomFieldReorder}
|
||||||
@@ -226,11 +252,11 @@ export const AccountEdit: FC = () => {
|
|||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
<EditButton
|
<EditButton
|
||||||
item={messages.customFieldsName}
|
label={intl.formatMessage(messages.customFieldsAddLabel)}
|
||||||
onClick={handleCustomFieldAdd}
|
onClick={handleCustomFieldAdd}
|
||||||
disabled={profile.fields.length >= maxFieldCount}
|
disabled={profile.fields.length >= maxFieldCount}
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{hasFields && (
|
{hasFields && (
|
||||||
@@ -240,10 +266,7 @@ export const AccountEdit: FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<AccountField {...field} {...htmlHandlers} />
|
<AccountField {...field} {...htmlHandlers} />
|
||||||
</div>
|
</div>
|
||||||
<AccountFieldActions
|
<AccountFieldActions id={field.id} />
|
||||||
item={intl.formatMessage(messages.customFieldsName)}
|
|
||||||
id={field.id}
|
|
||||||
/>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
@@ -278,8 +301,8 @@ export const AccountEdit: FC = () => {
|
|||||||
buttons={
|
buttons={
|
||||||
<EditButton
|
<EditButton
|
||||||
onClick={handleFeaturedTagsEdit}
|
onClick={handleFeaturedTagsEdit}
|
||||||
edit={hasTags}
|
icon={hasTags}
|
||||||
item={messages.featuredHashtagsItem}
|
label={intl.formatMessage(messages.featuredHashtagsEditLabel)}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
|
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import type { Map as ImmutableMap } from 'immutable';
|
import type { Map as ImmutableMap } from 'immutable';
|
||||||
|
|
||||||
|
import { closeModal } from '@/mastodon/actions/modal';
|
||||||
import { Button } from '@/mastodon/components/button';
|
import { Button } from '@/mastodon/components/button';
|
||||||
import { Callout } from '@/mastodon/components/callout';
|
import { Callout } from '@/mastodon/components/callout';
|
||||||
import { EmojiTextInputField } from '@/mastodon/components/form_fields';
|
import { EmojiTextInputField } from '@/mastodon/components/form_fields';
|
||||||
@@ -51,14 +58,19 @@ const messages = defineMessages({
|
|||||||
id: 'account_edit.field_edit_modal.value_hint',
|
id: 'account_edit.field_edit_modal.value_hint',
|
||||||
defaultMessage: 'E.g. “https://example.me”',
|
defaultMessage: 'E.g. “https://example.me”',
|
||||||
},
|
},
|
||||||
limitHeader: {
|
|
||||||
id: 'account_edit.field_edit_modal.limit_header',
|
|
||||||
defaultMessage: 'Recommended character limit exceeded',
|
|
||||||
},
|
|
||||||
save: {
|
save: {
|
||||||
id: 'account_edit.save',
|
id: 'account_edit.save',
|
||||||
defaultMessage: 'Save',
|
defaultMessage: 'Save',
|
||||||
},
|
},
|
||||||
|
discardMessage: {
|
||||||
|
id: 'account_edit.field_edit_modal.discard_message',
|
||||||
|
defaultMessage:
|
||||||
|
'You have unsaved changes. Are you sure you want to discard them?',
|
||||||
|
},
|
||||||
|
discardConfirm: {
|
||||||
|
id: 'account_edit.field_edit_modal.discard_confirm',
|
||||||
|
defaultMessage: 'Discard',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// We have two different values- the hard limit set by the server,
|
// We have two different values- the hard limit set by the server,
|
||||||
@@ -83,19 +95,39 @@ const selectEmojiCodes = createAppSelector(
|
|||||||
(emojis) => emojis.map((emoji) => emoji.get('shortcode')).toArray(),
|
(emojis) => emojis.map((emoji) => emoji.get('shortcode')).toArray(),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const EditFieldModal: FC<DialogModalProps & { fieldKey?: string }> = ({
|
interface ConfirmationMessage {
|
||||||
onClose,
|
message: string;
|
||||||
fieldKey,
|
confirm: string;
|
||||||
}) => {
|
props: { fieldKey?: string; lastLabel: string; lastValue: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModalRef {
|
||||||
|
getCloseConfirmationMessage: () => null | ConfirmationMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditFieldModal = forwardRef<
|
||||||
|
ModalRef,
|
||||||
|
DialogModalProps & {
|
||||||
|
fieldKey?: string;
|
||||||
|
lastLabel?: string;
|
||||||
|
lastValue?: string;
|
||||||
|
}
|
||||||
|
>(({ onClose, fieldKey, lastLabel, lastValue }, ref) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const field = useAppSelector((state) => selectFieldById(state, fieldKey));
|
const field = useAppSelector((state) => selectFieldById(state, fieldKey));
|
||||||
const [newLabel, setNewLabel] = useState(field?.name ?? '');
|
const oldLabel = lastLabel ?? field?.name;
|
||||||
const [newValue, setNewValue] = useState(field?.value ?? '');
|
const oldValue = lastValue ?? field?.value;
|
||||||
|
const [newLabel, setNewLabel] = useState(oldLabel ?? '');
|
||||||
|
const [newValue, setNewValue] = useState(oldValue ?? '');
|
||||||
|
const isDirty = newLabel !== oldLabel || newValue !== oldValue;
|
||||||
|
|
||||||
const { nameLimit, valueLimit } = useAppSelector(selectFieldLimits);
|
const { nameLimit, valueLimit } = useAppSelector(selectFieldLimits);
|
||||||
const isPending = useAppSelector((state) => state.profileEdit.isPending);
|
const isPending = useAppSelector((state) => state.profileEdit.isPending);
|
||||||
|
|
||||||
const disabled =
|
const disabled =
|
||||||
|
!newLabel.trim() ||
|
||||||
|
!newValue.trim() ||
|
||||||
|
!isDirty ||
|
||||||
!nameLimit ||
|
!nameLimit ||
|
||||||
!valueLimit ||
|
!valueLimit ||
|
||||||
newLabel.length > nameLimit ||
|
newLabel.length > nameLimit ||
|
||||||
@@ -122,11 +154,41 @@ export const EditFieldModal: FC<DialogModalProps & { fieldKey?: string }> = ({
|
|||||||
}
|
}
|
||||||
void dispatch(
|
void dispatch(
|
||||||
updateField({ id: fieldKey, name: newLabel, value: newValue }),
|
updateField({ id: fieldKey, name: newLabel, value: newValue }),
|
||||||
).then(onClose);
|
).then(() => {
|
||||||
}, [disabled, dispatch, fieldKey, isPending, newLabel, newValue, onClose]);
|
// Close without confirmation.
|
||||||
|
dispatch(
|
||||||
|
closeModal({
|
||||||
|
modalType: 'ACCOUNT_EDIT_FIELD_EDIT',
|
||||||
|
ignoreFocus: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [disabled, dispatch, fieldKey, isPending, newLabel, newValue]);
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
getCloseConfirmationMessage: () => {
|
||||||
|
if (!newLabel || !newValue || !isDirty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
message: intl.formatMessage(messages.discardMessage),
|
||||||
|
confirm: intl.formatMessage(messages.discardConfirm),
|
||||||
|
props: {
|
||||||
|
fieldKey,
|
||||||
|
lastLabel: newLabel,
|
||||||
|
lastValue: newValue,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[fieldKey, intl, isDirty, newLabel, newValue],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
|
noCloseOnConfirm
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={
|
title={
|
||||||
field
|
field
|
||||||
@@ -170,13 +232,10 @@ export const EditFieldModal: FC<DialogModalProps & { fieldKey?: string }> = ({
|
|||||||
|
|
||||||
{(newLabel.length > RECOMMENDED_LIMIT ||
|
{(newLabel.length > RECOMMENDED_LIMIT ||
|
||||||
newValue.length > RECOMMENDED_LIMIT) && (
|
newValue.length > RECOMMENDED_LIMIT) && (
|
||||||
<Callout
|
<Callout variant='warning'>
|
||||||
variant='warning'
|
|
||||||
title={intl.formatMessage(messages.limitHeader)}
|
|
||||||
>
|
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='account_edit.field_edit_modal.limit_message'
|
id='account_edit.field_edit_modal.limit_warning'
|
||||||
defaultMessage='Mobile users might not see your field in full.'
|
defaultMessage='Recommended character limit exceeded. Mobile users might not see your field in full.'
|
||||||
/>
|
/>
|
||||||
</Callout>
|
</Callout>
|
||||||
)}
|
)}
|
||||||
@@ -195,7 +254,8 @@ export const EditFieldModal: FC<DialogModalProps & { fieldKey?: string }> = ({
|
|||||||
)}
|
)}
|
||||||
</ConfirmationModal>
|
</ConfirmationModal>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
EditFieldModal.displayName = 'EditFieldModal';
|
||||||
|
|
||||||
export const DeleteFieldModal: FC<DialogModalProps & { fieldKey: string }> = ({
|
export const DeleteFieldModal: FC<DialogModalProps & { fieldKey: string }> = ({
|
||||||
onClose,
|
onClose,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useCallback, useState } from 'react';
|
|||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { CharacterCounter } from '@/mastodon/components/character_counter';
|
|
||||||
import { Details } from '@/mastodon/components/details';
|
import { Details } from '@/mastodon/components/details';
|
||||||
import { TextAreaField } from '@/mastodon/components/form_fields';
|
import { TextAreaField } from '@/mastodon/components/form_fields';
|
||||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||||
@@ -69,6 +68,7 @@ export const ImageAltModal: FC<
|
|||||||
imageSrc={imageSrc}
|
imageSrc={imageSrc}
|
||||||
altText={altText}
|
altText={altText}
|
||||||
onChange={setAltText}
|
onChange={setAltText}
|
||||||
|
hideTip={location === 'header'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ConfirmationModal>
|
</ConfirmationModal>
|
||||||
@@ -79,7 +79,8 @@ export const ImageAltTextField: FC<{
|
|||||||
imageSrc: string;
|
imageSrc: string;
|
||||||
altText: string;
|
altText: string;
|
||||||
onChange: (altText: string) => void;
|
onChange: (altText: string) => void;
|
||||||
}> = ({ imageSrc, altText, onChange }) => {
|
hideTip?: boolean;
|
||||||
|
}> = ({ imageSrc, altText, onChange, hideTip }) => {
|
||||||
const altLimit = useAppSelector(
|
const altLimit = useAppSelector(
|
||||||
(state) =>
|
(state) =>
|
||||||
state.server.getIn(
|
state.server.getIn(
|
||||||
@@ -99,49 +100,45 @@ export const ImageAltTextField: FC<{
|
|||||||
<>
|
<>
|
||||||
<img src={imageSrc} alt='' className={classes.altImage} />
|
<img src={imageSrc} alt='' className={classes.altImage} />
|
||||||
|
|
||||||
<div>
|
<TextAreaField
|
||||||
<TextAreaField
|
label={
|
||||||
label={
|
|
||||||
<FormattedMessage
|
|
||||||
id='account_edit.image_alt_modal.text_label'
|
|
||||||
defaultMessage='Alt text'
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
hint={
|
|
||||||
<FormattedMessage
|
|
||||||
id='account_edit.image_alt_modal.text_hint'
|
|
||||||
defaultMessage='Alt text helps screen reader users to understand your content.'
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
onChange={handleChange}
|
|
||||||
value={altText}
|
|
||||||
/>
|
|
||||||
<CharacterCounter
|
|
||||||
currentString={altText}
|
|
||||||
maxLength={altLimit}
|
|
||||||
className={classes.altCounter}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Details
|
|
||||||
summary={
|
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='account_edit.image_alt_modal.details_title'
|
id='account_edit.image_alt_modal.text_label'
|
||||||
defaultMessage='Tips: Alt text for profile photos'
|
defaultMessage='Alt text'
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
className={classes.altHint}
|
hint={
|
||||||
>
|
<FormattedMessage
|
||||||
<FormattedMessage
|
id='account_edit.image_alt_modal.text_hint'
|
||||||
id='account_edit.image_alt_modal.details_content'
|
defaultMessage='Alt text helps screen reader users to understand your content.'
|
||||||
defaultMessage='DO: <ul> <li>Describe yourself as pictured</li> <li>Use third person language (e.g. “Alex” instead of “me”)</li> <li>Be succinct – a few words is often enough</li> </ul> DON’T: <ul> <li>Start with “Photo of” – it’s redundant for screen readers</li> </ul> EXAMPLE: <ul> <li>“Alex wearing a green shirt and glasses”</li> </ul>'
|
/>
|
||||||
values={{
|
}
|
||||||
ul: (chunks) => <ul>{chunks}</ul>,
|
onChange={handleChange}
|
||||||
li: (chunks) => <li>{chunks}</li>,
|
value={altText}
|
||||||
}}
|
maxLength={altLimit}
|
||||||
tagName='div'
|
/>
|
||||||
/>
|
|
||||||
</Details>
|
{!hideTip && (
|
||||||
|
<Details
|
||||||
|
summary={
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit.image_alt_modal.details_title'
|
||||||
|
defaultMessage='Tips: Alt text for profile photos'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
className={classes.altHint}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit.image_alt_modal.details_content'
|
||||||
|
defaultMessage='DO: <ul> <li>Describe yourself as pictured</li> <li>Use third person language (e.g. “Alex” instead of “me”)</li> <li>Be succinct – a few words is often enough</li> </ul> DON’T: <ul> <li>Start with “Photo of” – it’s redundant for screen readers</li> </ul> EXAMPLE: <ul> <li>“Alex wearing a green shirt and glasses”</li> </ul>'
|
||||||
|
values={{
|
||||||
|
ul: (chunks) => <ul>{chunks}</ul>,
|
||||||
|
li: (chunks) => <li>{chunks}</li>,
|
||||||
|
}}
|
||||||
|
tagName='div'
|
||||||
|
/>
|
||||||
|
</Details>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { ChangeEventHandler, FC } from 'react';
|
import type { ChangeEventHandler, FC } from 'react';
|
||||||
|
|
||||||
import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import type { Area } from 'react-easy-crop';
|
import type { Area } from 'react-easy-crop';
|
||||||
import Cropper from 'react-easy-crop';
|
import Cropper from 'react-easy-crop';
|
||||||
|
|
||||||
import { setDragUploadEnabled } from '@/mastodon/actions/compose_typed';
|
import { setDragUploadEnabled } from '@/mastodon/actions/compose_typed';
|
||||||
import { Button } from '@/mastodon/components/button';
|
import { Button } from '@/mastodon/components/button';
|
||||||
import { RangeInput } from '@/mastodon/components/form_fields/range_input_field';
|
import { RangeInputField } from '@/mastodon/components/form_fields/range_input_field';
|
||||||
import {
|
import {
|
||||||
selectImageInfo,
|
selectImageInfo,
|
||||||
uploadImage,
|
uploadImage,
|
||||||
@@ -24,16 +24,42 @@ import classes from './styles.module.scss';
|
|||||||
|
|
||||||
import 'react-easy-crop/react-easy-crop.css';
|
import 'react-easy-crop/react-easy-crop.css';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
avatarAdd: {
|
||||||
|
id: 'account_edit.upload_modal.title_add.avatar',
|
||||||
|
defaultMessage: 'Add profile photo',
|
||||||
|
},
|
||||||
|
headerAdd: {
|
||||||
|
id: 'account_edit.upload_modal.title_add.header',
|
||||||
|
defaultMessage: 'Add cover photo',
|
||||||
|
},
|
||||||
|
avatarReplace: {
|
||||||
|
id: 'account_edit.upload_modal.title_replace.avatar',
|
||||||
|
defaultMessage: 'Replace profile photo',
|
||||||
|
},
|
||||||
|
headerReplace: {
|
||||||
|
id: 'account_edit.upload_modal.title_replace.header',
|
||||||
|
defaultMessage: 'Replace cover photo',
|
||||||
|
},
|
||||||
|
zoomLabel: {
|
||||||
|
id: 'account_edit.upload_modal.step_crop.zoom',
|
||||||
|
defaultMessage: 'Zoom',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const ImageUploadModal: FC<
|
export const ImageUploadModal: FC<
|
||||||
DialogModalProps & { location: ImageLocation }
|
DialogModalProps & { location: ImageLocation }
|
||||||
> = ({ onClose, location }) => {
|
> = ({ onClose, location }) => {
|
||||||
const { src: oldSrc } = useAppSelector((state) =>
|
const { src: oldSrc } = useAppSelector((state) =>
|
||||||
selectImageInfo(state, location),
|
selectImageInfo(state, location),
|
||||||
);
|
);
|
||||||
const hasImage = !!oldSrc;
|
const intl = useIntl();
|
||||||
const [step, setStep] = useState<'select' | 'crop' | 'alt'>('select');
|
const title = intl.formatMessage(
|
||||||
|
oldSrc ? messages[`${location}Replace`] : messages[`${location}Add`],
|
||||||
|
);
|
||||||
|
|
||||||
// State for individual steps.
|
// State for individual steps.
|
||||||
|
const [step, setStep] = useState<'select' | 'crop' | 'alt'>('select');
|
||||||
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||||
const [imageBlob, setImageBlob] = useState<Blob | null>(null);
|
const [imageBlob, setImageBlob] = useState<Blob | null>(null);
|
||||||
|
|
||||||
@@ -94,19 +120,7 @@ export const ImageUploadModal: FC<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogModal
|
<DialogModal
|
||||||
title={
|
title={title}
|
||||||
hasImage ? (
|
|
||||||
<FormattedMessage
|
|
||||||
id='account_edit.upload_modal.title_replace'
|
|
||||||
defaultMessage='Replace profile photo'
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FormattedMessage
|
|
||||||
id='account_edit.upload_modal.title_add'
|
|
||||||
defaultMessage='Add profile photo'
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
wrapperClassName={classes.uploadWrapper}
|
wrapperClassName={classes.uploadWrapper}
|
||||||
noCancelButton
|
noCancelButton
|
||||||
@@ -124,6 +138,7 @@ export const ImageUploadModal: FC<
|
|||||||
)}
|
)}
|
||||||
{step === 'alt' && imageBlob && (
|
{step === 'alt' && imageBlob && (
|
||||||
<StepAlt
|
<StepAlt
|
||||||
|
location={location}
|
||||||
imageBlob={imageBlob}
|
imageBlob={imageBlob}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
onComplete={handleSave}
|
onComplete={handleSave}
|
||||||
@@ -275,11 +290,6 @@ const StepUpload: FC<{
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const zoomLabel = defineMessage({
|
|
||||||
id: 'account_edit.upload_modal.step_crop.zoom',
|
|
||||||
defaultMessage: 'Zoom',
|
|
||||||
});
|
|
||||||
|
|
||||||
const StepCrop: FC<{
|
const StepCrop: FC<{
|
||||||
src: string;
|
src: string;
|
||||||
location: ImageLocation;
|
location: ImageLocation;
|
||||||
@@ -322,14 +332,15 @@ const StepCrop: FC<{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={classes.cropActions}>
|
<div className={classes.cropActions}>
|
||||||
<RangeInput
|
<RangeInputField
|
||||||
|
label={intl.formatMessage(messages.zoomLabel)}
|
||||||
min={1}
|
min={1}
|
||||||
max={3}
|
max={3}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
value={zoom}
|
value={zoom}
|
||||||
onChange={handleZoomChange}
|
onChange={handleZoomChange}
|
||||||
className={classes.zoomControl}
|
wrapperClassName={classes.zoomControl}
|
||||||
aria-label={intl.formatMessage(zoomLabel)}
|
inputPlacement='inline-end'
|
||||||
/>
|
/>
|
||||||
<Button onClick={onCancel} secondary>
|
<Button onClick={onCancel} secondary>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
@@ -352,7 +363,8 @@ const StepAlt: FC<{
|
|||||||
imageBlob: Blob;
|
imageBlob: Blob;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onComplete: (altText: string) => void;
|
onComplete: (altText: string) => void;
|
||||||
}> = ({ imageBlob, onCancel, onComplete }) => {
|
location: ImageLocation;
|
||||||
|
}> = ({ imageBlob, onCancel, onComplete, location }) => {
|
||||||
const [altText, setAltText] = useState('');
|
const [altText, setAltText] = useState('');
|
||||||
|
|
||||||
const handleComplete = useCallback(() => {
|
const handleComplete = useCallback(() => {
|
||||||
@@ -367,6 +379,7 @@ const StepAlt: FC<{
|
|||||||
imageSrc={imageSrc}
|
imageSrc={imageSrc}
|
||||||
altText={altText}
|
altText={altText}
|
||||||
onChange={setAltText}
|
onChange={setAltText}
|
||||||
|
hideTip={location === 'header'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={classes.cropActions}>
|
<div className={classes.cropActions}>
|
||||||
|
|||||||
@@ -62,24 +62,26 @@ export const ProfileDisplayModal: FC<DialogModalProps> = ({ onClose }) => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ToggleField
|
{profile.showMedia && (
|
||||||
checked={profile.showMediaReplies}
|
<ToggleField
|
||||||
onChange={handleToggleChange}
|
checked={profile.showMediaReplies}
|
||||||
disabled={!profile.showMedia || isPending}
|
onChange={handleToggleChange}
|
||||||
name='show_media_replies'
|
disabled={isPending}
|
||||||
label={
|
name='show_media_replies'
|
||||||
<FormattedMessage
|
label={
|
||||||
id='account_edit.profile_tab.show_media_replies.title'
|
<FormattedMessage
|
||||||
defaultMessage='Include replies on ‘Media’ tab'
|
id='account_edit.profile_tab.show_media_replies.title'
|
||||||
/>
|
defaultMessage='Include replies on ‘Media’ tab'
|
||||||
}
|
/>
|
||||||
hint={
|
}
|
||||||
<FormattedMessage
|
hint={
|
||||||
id='account_edit.profile_tab.show_media_replies.description'
|
<FormattedMessage
|
||||||
defaultMessage='When enabled, Media tab shows both your posts and replies to other people’s posts.'
|
id='account_edit.profile_tab.show_media_replies.description'
|
||||||
/>
|
defaultMessage='When enabled, Media tab shows both your posts and replies to other people’s posts.'
|
||||||
}
|
/>
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<ToggleField
|
<ToggleField
|
||||||
checked={profile.showFeatured}
|
checked={profile.showFeatured}
|
||||||
|
|||||||
@@ -113,10 +113,14 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.zoomControl {
|
.zoomControl {
|
||||||
|
margin-right: auto;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
input {
|
||||||
width: min(100%, 200px);
|
width: min(100%, 200px);
|
||||||
margin-right: auto;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,10 +132,6 @@
|
|||||||
border-radius: var(--avatar-border-radius);
|
border-radius: var(--avatar-border-radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
.altCounter {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.altHint {
|
.altHint {
|
||||||
ul {
|
ul {
|
||||||
padding-left: 1em;
|
padding-left: 1em;
|
||||||
|
|||||||
@@ -53,6 +53,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fieldButtons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: end;
|
||||||
|
|
||||||
|
@container (width < 500px) {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -87,7 +97,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.autoComplete,
|
.autoComplete,
|
||||||
.tagSuggestions {
|
.tagSuggestions,
|
||||||
|
.maxTagsWarning {
|
||||||
margin: 12px 0;
|
margin: 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,8 +111,11 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const noTags =
|
||||||
|
featuredTags.isEmpty() || isServerFeatureEnabled('profile_redesign');
|
||||||
|
|
||||||
if (
|
if (
|
||||||
featuredTags.isEmpty() &&
|
noTags &&
|
||||||
featuredAccountIds.isEmpty() &&
|
featuredAccountIds.isEmpty() &&
|
||||||
listedCollections.length === 0
|
listedCollections.length === 0
|
||||||
) {
|
) {
|
||||||
@@ -158,7 +161,7 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
|||||||
</ItemList>
|
</ItemList>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!featuredTags.isEmpty() && (
|
{!noTags && (
|
||||||
<>
|
<>
|
||||||
<h4 className='column-subheading'>
|
<h4 className='column-subheading'>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
|
|||||||
@@ -620,7 +620,7 @@ function redesignMenuItems({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Timeline options
|
// Timeline options
|
||||||
if (relationship && !relationship.muting) {
|
if (relationship?.following && !relationship.muting) {
|
||||||
items.push(
|
items.push(
|
||||||
{
|
{
|
||||||
text: intl.formatMessage(
|
text: intl.formatMessage(
|
||||||
|
|||||||
@@ -91,28 +91,24 @@ const RedesignNumberFields: FC<{ accountId: string }> = ({ accountId }) => {
|
|||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
|
<FormattedMessage id='account.followers' defaultMessage='Followers' />
|
||||||
<NavLink
|
<NavLink
|
||||||
exact
|
exact
|
||||||
to={`/@${account.acct}/followers`}
|
to={`/@${account.acct}/followers`}
|
||||||
title={intl.formatNumber(account.followers_count)}
|
title={intl.formatNumber(account.followers_count)}
|
||||||
>
|
>
|
||||||
<FormattedMessage id='account.followers' defaultMessage='Followers' />
|
<ShortNumber value={account.followers_count} />
|
||||||
<strong>
|
|
||||||
<ShortNumber value={account.followers_count} />
|
|
||||||
</strong>
|
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
|
<FormattedMessage id='account.following' defaultMessage='Following' />
|
||||||
<NavLink
|
<NavLink
|
||||||
exact
|
exact
|
||||||
to={`/@${account.acct}/following`}
|
to={`/@${account.acct}/following`}
|
||||||
title={intl.formatNumber(account.following_count)}
|
title={intl.formatNumber(account.following_count)}
|
||||||
>
|
>
|
||||||
<FormattedMessage id='account.following' defaultMessage='Following' />
|
<ShortNumber value={account.following_count} />
|
||||||
<strong>
|
|
||||||
<ShortNumber value={account.following_count} />
|
|
||||||
</strong>
|
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
@@ -320,23 +320,22 @@ svg.badgeIcon {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a,
|
||||||
color: inherit;
|
|
||||||
font-weight: unset;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:focus {
|
|
||||||
color: var(--color-text-brand-soft);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
strong {
|
strong {
|
||||||
display: block;
|
display: block;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.modalCloseButton {
|
.modalCloseButton {
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
import type { MessageDescriptor } from 'react-intl';
|
||||||
|
|
||||||
|
import { DisplayNameSimple } from '@/mastodon/components/display_name/simple';
|
||||||
|
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||||
|
|
||||||
|
import classes from '../styles.module.scss';
|
||||||
|
|
||||||
|
export const AccountListHeader: FC<{
|
||||||
|
accountId: string;
|
||||||
|
total?: number;
|
||||||
|
titleText: MessageDescriptor;
|
||||||
|
}> = ({ accountId, total, titleText }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const account = useAccount(accountId);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className={classes.title}>
|
||||||
|
{intl.formatMessage(titleText, {
|
||||||
|
name: <DisplayNameSimple account={account} />,
|
||||||
|
})}
|
||||||
|
</h1>
|
||||||
|
{!!total && (
|
||||||
|
<h2 className={classes.subtitle}>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_list.total'
|
||||||
|
defaultMessage='{total, plural, one {# account} other {# accounts}}'
|
||||||
|
values={{ total }}
|
||||||
|
/>
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -11,8 +11,6 @@ import { useAccount } from '@/mastodon/hooks/useAccount';
|
|||||||
import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
|
import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
|
||||||
import { useLayout } from '@/mastodon/hooks/useLayout';
|
import { useLayout } from '@/mastodon/hooks/useLayout';
|
||||||
|
|
||||||
import { AccountHeader } from '../../account_timeline/components/account_header';
|
|
||||||
|
|
||||||
import { RemoteHint } from './remote';
|
import { RemoteHint } from './remote';
|
||||||
|
|
||||||
export interface AccountList {
|
export interface AccountList {
|
||||||
@@ -25,6 +23,7 @@ interface AccountListProps {
|
|||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
append?: ReactNode;
|
append?: ReactNode;
|
||||||
emptyMessage: ReactNode;
|
emptyMessage: ReactNode;
|
||||||
|
header?: ReactNode;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
list?: AccountList | null;
|
list?: AccountList | null;
|
||||||
loadMore: () => void;
|
loadMore: () => void;
|
||||||
@@ -36,6 +35,7 @@ export const AccountList: FC<AccountListProps> = ({
|
|||||||
accountId,
|
accountId,
|
||||||
append,
|
append,
|
||||||
emptyMessage,
|
emptyMessage,
|
||||||
|
header,
|
||||||
footer,
|
footer,
|
||||||
list,
|
list,
|
||||||
loadMore,
|
loadMore,
|
||||||
@@ -90,7 +90,7 @@ export const AccountList: FC<AccountListProps> = ({
|
|||||||
hasMore={!forceEmptyState && list?.hasMore}
|
hasMore={!forceEmptyState && list?.hasMore}
|
||||||
isLoading={list?.isLoading ?? true}
|
isLoading={list?.isLoading ?? true}
|
||||||
onLoadMore={loadMore}
|
onLoadMore={loadMore}
|
||||||
prepend={<AccountHeader accountId={accountId} hideTabs />}
|
prepend={header}
|
||||||
alwaysPrepend
|
alwaysPrepend
|
||||||
append={append ?? <RemoteHint domain={domain} url={account.url} />}
|
append={append ?? <RemoteHint domain={domain} url={account.url} />}
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { defineMessage, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { useDebouncedCallback } from 'use-debounce';
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
|
|
||||||
@@ -14,8 +14,14 @@ import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
|||||||
|
|
||||||
import type { EmptyMessageProps } from './components/empty';
|
import type { EmptyMessageProps } from './components/empty';
|
||||||
import { BaseEmptyMessage } from './components/empty';
|
import { BaseEmptyMessage } from './components/empty';
|
||||||
|
import { AccountListHeader } from './components/header';
|
||||||
import { AccountList } from './components/list';
|
import { AccountList } from './components/list';
|
||||||
|
|
||||||
|
const titleText = defineMessage({
|
||||||
|
id: 'followers.title',
|
||||||
|
defaultMessage: 'Following {name}',
|
||||||
|
});
|
||||||
|
|
||||||
const Followers: FC = () => {
|
const Followers: FC = () => {
|
||||||
const accountId = useAccountId();
|
const accountId = useAccountId();
|
||||||
const account = useAccount(accountId);
|
const account = useAccount(accountId);
|
||||||
@@ -64,6 +70,15 @@ const Followers: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<AccountList
|
<AccountList
|
||||||
accountId={accountId}
|
accountId={accountId}
|
||||||
|
header={
|
||||||
|
accountId && (
|
||||||
|
<AccountListHeader
|
||||||
|
accountId={accountId}
|
||||||
|
titleText={titleText}
|
||||||
|
total={account?.followers_count}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
footer={footer}
|
footer={footer}
|
||||||
emptyMessage={<EmptyMessage account={account} />}
|
emptyMessage={<EmptyMessage account={account} />}
|
||||||
list={followerList}
|
list={followerList}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
.title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 20px 16px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 10px 16px;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { defineMessage, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { useDebouncedCallback } from 'use-debounce';
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
|
|
||||||
@@ -14,10 +14,16 @@ import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
|||||||
|
|
||||||
import type { EmptyMessageProps } from '../followers/components/empty';
|
import type { EmptyMessageProps } from '../followers/components/empty';
|
||||||
import { BaseEmptyMessage } from '../followers/components/empty';
|
import { BaseEmptyMessage } from '../followers/components/empty';
|
||||||
|
import { AccountListHeader } from '../followers/components/header';
|
||||||
import { AccountList } from '../followers/components/list';
|
import { AccountList } from '../followers/components/list';
|
||||||
|
|
||||||
import { RemoteHint } from './components/remote';
|
import { RemoteHint } from './components/remote';
|
||||||
|
|
||||||
|
const titleText = defineMessage({
|
||||||
|
id: 'following.title',
|
||||||
|
defaultMessage: 'Followed by {name}',
|
||||||
|
});
|
||||||
|
|
||||||
const Followers: FC = () => {
|
const Followers: FC = () => {
|
||||||
const accountId = useAccountId();
|
const accountId = useAccountId();
|
||||||
const account = useAccount(accountId);
|
const account = useAccount(accountId);
|
||||||
@@ -69,6 +75,15 @@ const Followers: FC = () => {
|
|||||||
accountId={accountId}
|
accountId={accountId}
|
||||||
append={domain && <RemoteHint domain={domain} url={account.url} />}
|
append={domain && <RemoteHint domain={domain} url={account.url} />}
|
||||||
emptyMessage={<EmptyMessage account={account} />}
|
emptyMessage={<EmptyMessage account={account} />}
|
||||||
|
header={
|
||||||
|
accountId && (
|
||||||
|
<AccountListHeader
|
||||||
|
accountId={accountId}
|
||||||
|
titleText={titleText}
|
||||||
|
total={account?.following_count}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
footer={footer}
|
footer={footer}
|
||||||
list={followingList}
|
list={followingList}
|
||||||
loadMore={loadMore}
|
loadMore={loadMore}
|
||||||
|
|||||||
@@ -210,7 +210,7 @@
|
|||||||
"account_edit.verified_modal.step2.header": "Add your website as a custom field",
|
"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.verified_modal.title": "How to add a verified link",
|
||||||
"account_edit_tags.add_tag": "Add #{tagName}",
|
"account_edit_tags.add_tag": "Add #{tagName}",
|
||||||
"account_edit_tags.column_title": "Edit featured hashtags",
|
"account_edit_tags.column_title": "Edit Tags",
|
||||||
"account_edit_tags.help_text": "Featured hashtags help users discover and interact with your profile. They appear as filters on your Profile page’s Activity view.",
|
"account_edit_tags.help_text": "Featured hashtags help users discover and interact with your profile. They appear as filters on your Profile page’s Activity view.",
|
||||||
"account_edit_tags.search_placeholder": "Enter a hashtag…",
|
"account_edit_tags.search_placeholder": "Enter a hashtag…",
|
||||||
"account_edit_tags.suggestions": "Suggestions:",
|
"account_edit_tags.suggestions": "Suggestions:",
|
||||||
|
|||||||
@@ -141,34 +141,39 @@
|
|||||||
"account.unmute": "Unmute @{name}",
|
"account.unmute": "Unmute @{name}",
|
||||||
"account.unmute_notifications_short": "Unmute notifications",
|
"account.unmute_notifications_short": "Unmute notifications",
|
||||||
"account.unmute_short": "Unmute",
|
"account.unmute_short": "Unmute",
|
||||||
|
"account_edit.bio.edit_label": "Edit bio",
|
||||||
|
"account_edit.bio.label": "bio",
|
||||||
"account_edit.bio.placeholder": "Add a short introduction to help others identify you.",
|
"account_edit.bio.placeholder": "Add a short introduction to help others identify you.",
|
||||||
"account_edit.bio.title": "Bio",
|
"account_edit.bio.title": "Bio",
|
||||||
"account_edit.bio_modal.add_title": "Add bio",
|
"account_edit.bio_modal.add_title": "Add bio",
|
||||||
"account_edit.bio_modal.edit_title": "Edit bio",
|
"account_edit.bio_modal.edit_title": "Edit bio",
|
||||||
"account_edit.button.add": "Add {item}",
|
|
||||||
"account_edit.button.delete": "Delete {item}",
|
|
||||||
"account_edit.button.edit": "Edit {item}",
|
|
||||||
"account_edit.column_button": "Done",
|
"account_edit.column_button": "Done",
|
||||||
"account_edit.column_title": "Edit Profile",
|
"account_edit.column_title": "Edit Profile",
|
||||||
"account_edit.custom_fields.name": "field",
|
"account_edit.custom_fields.add_label": "Add field",
|
||||||
|
"account_edit.custom_fields.edit_label": "Edit field",
|
||||||
"account_edit.custom_fields.placeholder": "Add your pronouns, external links, or anything else you’d like to share.",
|
"account_edit.custom_fields.placeholder": "Add your pronouns, external links, or anything else you’d like to share.",
|
||||||
"account_edit.custom_fields.reorder_button": "Reorder fields",
|
"account_edit.custom_fields.reorder_button": "Reorder fields",
|
||||||
"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_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.tip_title": "Tip: Adding verified links",
|
||||||
"account_edit.custom_fields.title": "Custom fields",
|
"account_edit.custom_fields.title": "Custom fields",
|
||||||
"account_edit.custom_fields.verified_hint": "How do I add a verified link?",
|
"account_edit.custom_fields.verified_hint": "How do I add a verified link?",
|
||||||
|
"account_edit.display_name.add_label": "Add display name",
|
||||||
|
"account_edit.display_name.edit_label": "Edit display name",
|
||||||
"account_edit.display_name.placeholder": "Your display name is how your name appears on your profile and in timelines.",
|
"account_edit.display_name.placeholder": "Your display name is how your name appears on your profile and in timelines.",
|
||||||
"account_edit.display_name.title": "Display name",
|
"account_edit.display_name.title": "Display name",
|
||||||
"account_edit.featured_hashtags.item": "hashtags",
|
"account_edit.featured_hashtags.edit_label": "Add hashtags",
|
||||||
"account_edit.featured_hashtags.placeholder": "Help others identify, and have quick access to, your favorite topics.",
|
"account_edit.featured_hashtags.placeholder": "Help others identify, and have quick access to, your favorite topics.",
|
||||||
"account_edit.featured_hashtags.title": "Featured hashtags",
|
"account_edit.featured_hashtags.title": "Featured hashtags",
|
||||||
|
"account_edit.field_actions.delete": "Delete field",
|
||||||
|
"account_edit.field_actions.edit": "Edit field",
|
||||||
"account_edit.field_delete_modal.confirm": "Are you sure you want to delete this custom field? This action can’t be undone.",
|
"account_edit.field_delete_modal.confirm": "Are you sure you want to delete this custom field? This action can’t be undone.",
|
||||||
"account_edit.field_delete_modal.delete_button": "Delete",
|
"account_edit.field_delete_modal.delete_button": "Delete",
|
||||||
"account_edit.field_delete_modal.title": "Delete custom field?",
|
"account_edit.field_delete_modal.title": "Delete custom field?",
|
||||||
"account_edit.field_edit_modal.add_title": "Add custom field",
|
"account_edit.field_edit_modal.add_title": "Add custom field",
|
||||||
|
"account_edit.field_edit_modal.discard_confirm": "Discard",
|
||||||
|
"account_edit.field_edit_modal.discard_message": "You have unsaved changes. Are you sure you want to discard them?",
|
||||||
"account_edit.field_edit_modal.edit_title": "Edit custom field",
|
"account_edit.field_edit_modal.edit_title": "Edit custom field",
|
||||||
"account_edit.field_edit_modal.limit_header": "Recommended character limit exceeded",
|
"account_edit.field_edit_modal.limit_warning": "Recommended character limit exceeded. Mobile users might not see your field in full.",
|
||||||
"account_edit.field_edit_modal.limit_message": "Mobile users might not see your field in full.",
|
|
||||||
"account_edit.field_edit_modal.link_emoji_warning": "We recommend against the use of custom emoji in combination with urls. Custom fields containing both will display as text only instead of as a link, in order to prevent user confusion.",
|
"account_edit.field_edit_modal.link_emoji_warning": "We recommend against the use of custom emoji in combination with urls. Custom fields containing both will display as text only instead of as a link, in order to prevent user confusion.",
|
||||||
"account_edit.field_edit_modal.name_hint": "E.g. “Personal website”",
|
"account_edit.field_edit_modal.name_hint": "E.g. “Personal website”",
|
||||||
"account_edit.field_edit_modal.name_label": "Label",
|
"account_edit.field_edit_modal.name_label": "Label",
|
||||||
@@ -197,6 +202,8 @@
|
|||||||
"account_edit.image_edit.alt_edit_button": "Edit alt text",
|
"account_edit.image_edit.alt_edit_button": "Edit alt text",
|
||||||
"account_edit.image_edit.remove_button": "Remove image",
|
"account_edit.image_edit.remove_button": "Remove image",
|
||||||
"account_edit.image_edit.replace_button": "Replace image",
|
"account_edit.image_edit.replace_button": "Replace image",
|
||||||
|
"account_edit.item_list.delete": "Delete {name}",
|
||||||
|
"account_edit.item_list.edit": "Edit {name}",
|
||||||
"account_edit.name_modal.add_title": "Add display name",
|
"account_edit.name_modal.add_title": "Add display name",
|
||||||
"account_edit.name_modal.edit_title": "Edit display name",
|
"account_edit.name_modal.edit_title": "Edit display name",
|
||||||
"account_edit.profile_tab.button_label": "Customize",
|
"account_edit.profile_tab.button_label": "Customize",
|
||||||
@@ -219,8 +226,10 @@
|
|||||||
"account_edit.upload_modal.step_upload.dragging": "Drop to upload",
|
"account_edit.upload_modal.step_upload.dragging": "Drop to upload",
|
||||||
"account_edit.upload_modal.step_upload.header": "Choose an image",
|
"account_edit.upload_modal.step_upload.header": "Choose an image",
|
||||||
"account_edit.upload_modal.step_upload.hint": "WEBP, PNG, GIF or JPG format, up to {limit}MB.{br}Image will be scaled to {width}x{height}px.",
|
"account_edit.upload_modal.step_upload.hint": "WEBP, PNG, GIF or JPG format, up to {limit}MB.{br}Image will be scaled to {width}x{height}px.",
|
||||||
"account_edit.upload_modal.title_add": "Add profile photo",
|
"account_edit.upload_modal.title_add.avatar": "Add profile photo",
|
||||||
"account_edit.upload_modal.title_replace": "Replace profile photo",
|
"account_edit.upload_modal.title_add.header": "Add cover photo",
|
||||||
|
"account_edit.upload_modal.title_replace.avatar": "Replace profile photo",
|
||||||
|
"account_edit.upload_modal.title_replace.header": "Replace cover photo",
|
||||||
"account_edit.verified_modal.details": "Add credibility to your Mastodon profile by verifying links to personal websites. Here’s how it works:",
|
"account_edit.verified_modal.details": "Add credibility to your Mastodon profile by verifying links to personal websites. Here’s how it works:",
|
||||||
"account_edit.verified_modal.invisible_link.details": "Add the link to your header. The important part is rel=\"me\" which prevents impersonation on websites with user-generated content. You can even use a link tag in the header of the page instead of {tag}, but the HTML must be accessible without executing JavaScript.",
|
"account_edit.verified_modal.invisible_link.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.invisible_link.summary": "How do I make the link invisible?",
|
||||||
@@ -229,11 +238,13 @@
|
|||||||
"account_edit.verified_modal.step2.header": "Add your website as a custom field",
|
"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.verified_modal.title": "How to add a verified link",
|
||||||
"account_edit_tags.add_tag": "Add #{tagName}",
|
"account_edit_tags.add_tag": "Add #{tagName}",
|
||||||
"account_edit_tags.column_title": "Edit featured hashtags",
|
"account_edit_tags.column_title": "Edit Tags",
|
||||||
"account_edit_tags.help_text": "Featured hashtags help users discover and interact with your profile. They appear as filters on your Profile page’s Activity view.",
|
"account_edit_tags.help_text": "Featured hashtags help users discover and interact with your profile. They appear as filters on your Profile page’s Activity view.",
|
||||||
|
"account_edit_tags.max_tags_reached": "You have reached the maximum number of featured hashtags.",
|
||||||
"account_edit_tags.search_placeholder": "Enter a hashtag…",
|
"account_edit_tags.search_placeholder": "Enter a hashtag…",
|
||||||
"account_edit_tags.suggestions": "Suggestions:",
|
"account_edit_tags.suggestions": "Suggestions:",
|
||||||
"account_edit_tags.tag_status_count": "{count, plural, one {# post} other {# posts}}",
|
"account_edit_tags.tag_status_count": "{count, plural, one {# post} other {# posts}}",
|
||||||
|
"account_list.total": "{total, plural, one {# account} other {# accounts}}",
|
||||||
"account_note.placeholder": "Click to add note",
|
"account_note.placeholder": "Click to add note",
|
||||||
"admin.dashboard.daily_retention": "User retention rate by day after sign-up",
|
"admin.dashboard.daily_retention": "User retention rate by day after sign-up",
|
||||||
"admin.dashboard.monthly_retention": "User retention rate by month after sign-up",
|
"admin.dashboard.monthly_retention": "User retention rate by month after sign-up",
|
||||||
@@ -674,7 +685,9 @@
|
|||||||
"follow_suggestions.who_to_follow": "Who to follow",
|
"follow_suggestions.who_to_follow": "Who to follow",
|
||||||
"followed_tags": "Followed hashtags",
|
"followed_tags": "Followed hashtags",
|
||||||
"followers.hide_other_followers": "This user has chosen to not make their other followers visible",
|
"followers.hide_other_followers": "This user has chosen to not make their other followers visible",
|
||||||
|
"followers.title": "Following {name}",
|
||||||
"following.hide_other_following": "This user has chosen to not make the rest of who they follow visible",
|
"following.hide_other_following": "This user has chosen to not make the rest of who they follow visible",
|
||||||
|
"following.title": "Followed by {name}",
|
||||||
"footer.about": "About",
|
"footer.about": "About",
|
||||||
"footer.about_mastodon": "About Mastodon",
|
"footer.about_mastodon": "About Mastodon",
|
||||||
"footer.about_server": "About {domain}",
|
"footer.about_server": "About {domain}",
|
||||||
|
|||||||
@@ -4608,7 +4608,6 @@ a.status-card {
|
|||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
border-radius: 4px 4px 0 0;
|
border-radius: 4px 4px 0 0;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class ActivityPub::Activity::Accept < ActivityPub::Activity
|
|||||||
|
|
||||||
def accept_quote!(quote)
|
def accept_quote!(quote)
|
||||||
approval_uri = value_or_id(first_of_value(@json['result']))
|
approval_uri = value_or_id(first_of_value(@json['result']))
|
||||||
return if unsupported_uri_scheme?(approval_uri) || quote.quoted_account != @account || !quote.status.local? || !quote.pending?
|
return if unsupported_uri_scheme?(approval_uri) || non_matching_uri_hosts?(approval_uri, @account.uri) || quote.quoted_account != @account || !quote.status.local? || !quote.pending?
|
||||||
|
|
||||||
# NOTE: we are not going through `ActivityPub::VerifyQuoteService` as the `Accept` is as authoritative
|
# NOTE: we are not going through `ActivityPub::VerifyQuoteService` as the `Accept` is as authoritative
|
||||||
# as the stamp, but this means we are not checking the stamp, which may lead to inconsistencies
|
# as the stamp, but this means we are not checking the stamp, which may lead to inconsistencies
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||||||
@params = {}
|
@params = {}
|
||||||
@quote = nil
|
@quote = nil
|
||||||
@quote_uri = nil
|
@quote_uri = nil
|
||||||
|
@quote_approval_uri = nil
|
||||||
|
|
||||||
process_status_params
|
process_status_params
|
||||||
process_tags
|
process_tags
|
||||||
@@ -229,9 +230,9 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||||||
@quote_uri = @status_parser.quote_uri
|
@quote_uri = @status_parser.quote_uri
|
||||||
return unless @status_parser.quote?
|
return unless @status_parser.quote?
|
||||||
|
|
||||||
approval_uri = @status_parser.quote_approval_uri
|
@quote_approval_uri = @status_parser.quote_approval_uri
|
||||||
approval_uri = nil if unsupported_uri_scheme?(approval_uri) || TagManager.instance.local_url?(approval_uri)
|
@quote_approval_uri = nil if unsupported_uri_scheme?(@quote_approval_uri) || TagManager.instance.local_url?(@quote_approval_uri)
|
||||||
@quote = Quote.new(account: @account, approval_uri: approval_uri, legacy: @status_parser.legacy_quote?, state: @status_parser.deleted_quote? ? :deleted : :pending)
|
@quote = Quote.new(account: @account, approval_uri: nil, legacy: @status_parser.legacy_quote?, state: @status_parser.deleted_quote? ? :deleted : :pending)
|
||||||
end
|
end
|
||||||
|
|
||||||
def process_hashtag(tag)
|
def process_hashtag(tag)
|
||||||
@@ -391,9 +392,9 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||||||
return if @quote.nil?
|
return if @quote.nil?
|
||||||
|
|
||||||
embedded_quote = safe_prefetched_embed(@account, @status_parser.quoted_object, @json['context'])
|
embedded_quote = safe_prefetched_embed(@account, @status_parser.quoted_object, @json['context'])
|
||||||
ActivityPub::VerifyQuoteService.new.call(@quote, fetchable_quoted_uri: @quote_uri, prefetched_quoted_object: embedded_quote, request_id: @options[:request_id], depth: @options[:depth])
|
ActivityPub::VerifyQuoteService.new.call(@quote, @quote_approval_uri, fetchable_quoted_uri: @quote_uri, prefetched_quoted_object: embedded_quote, request_id: @options[:request_id], depth: @options[:depth])
|
||||||
rescue Mastodon::RecursionLimitExceededError, Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS
|
rescue Mastodon::RecursionLimitExceededError, Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS
|
||||||
ActivityPub::RefetchAndVerifyQuoteWorker.perform_in(rand(30..600).seconds, @quote.id, @quote_uri, { 'request_id' => @options[:request_id] })
|
ActivityPub::RefetchAndVerifyQuoteWorker.perform_in(rand(30..600).seconds, @quote.id, @quote_uri, { 'request_id' => @options[:request_id], 'approval_uri' => @quote_approval_uri })
|
||||||
end
|
end
|
||||||
|
|
||||||
def conversation_from_uri(uri)
|
def conversation_from_uri(uri)
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class CollectionItem < ApplicationRecord
|
|||||||
private
|
private
|
||||||
|
|
||||||
def set_position
|
def set_position
|
||||||
return if position_changed?
|
return if position.present? && position_changed?
|
||||||
|
|
||||||
self.position = self.class.where(collection_id:).maximum(:position).to_i + 1
|
self.position = self.class.where(collection_id:).maximum(:position).to_i + 1
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -45,8 +45,12 @@ class Quote < ApplicationRecord
|
|||||||
after_destroy_commit :decrement_counter_caches!
|
after_destroy_commit :decrement_counter_caches!
|
||||||
after_update_commit :update_counter_caches!
|
after_update_commit :update_counter_caches!
|
||||||
|
|
||||||
def accept!
|
def accept!(approval_uri: nil)
|
||||||
update!(state: :accepted)
|
if approval_uri.present?
|
||||||
|
update!(state: :accepted, approval_uri:)
|
||||||
|
else
|
||||||
|
update!(state: :accepted)
|
||||||
|
end
|
||||||
|
|
||||||
reset_parent_cache! if attribute_previously_changed?(:state)
|
reset_parent_cache! if attribute_previously_changed?(:state)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
|
|||||||
create_edits!
|
create_edits!
|
||||||
end
|
end
|
||||||
|
|
||||||
fetch_and_verify_quote!(@quote, @status_parser.quote_uri) if @quote.present?
|
fetch_and_verify_quote!(@quote, @quote_approval_uri, @status_parser.quote_uri) if @quote.present?
|
||||||
download_media_files!
|
download_media_files!
|
||||||
queue_poll_notifications!
|
queue_poll_notifications!
|
||||||
|
|
||||||
@@ -317,9 +317,9 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
|
|||||||
approval_uri = @status_parser.quote_approval_uri
|
approval_uri = @status_parser.quote_approval_uri
|
||||||
approval_uri = nil if unsupported_uri_scheme?(approval_uri) || TagManager.instance.local_url?(approval_uri)
|
approval_uri = nil if unsupported_uri_scheme?(approval_uri) || TagManager.instance.local_url?(approval_uri)
|
||||||
|
|
||||||
quote.update(approval_uri: approval_uri, state: :pending, legacy: @status_parser.legacy_quote?) if quote.approval_uri != @status_parser.quote_approval_uri
|
quote.update(approval_uri: nil, state: :pending, legacy: @status_parser.legacy_quote?) if quote.approval_uri.present? && quote.approval_uri != @status_parser.quote_approval_uri
|
||||||
|
|
||||||
fetch_and_verify_quote!(quote, quote_uri)
|
fetch_and_verify_quote!(quote, approval_uri, quote_uri)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_quote!
|
def update_quote!
|
||||||
@@ -335,18 +335,20 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
|
|||||||
# Revoke the quote while we get a chance… maybe this should be a `before_destroy` hook?
|
# Revoke the quote while we get a chance… maybe this should be a `before_destroy` hook?
|
||||||
RevokeQuoteService.new.call(@status.quote) if @status.quote.quoted_account&.local? && @status.quote.accepted?
|
RevokeQuoteService.new.call(@status.quote) if @status.quote.quoted_account&.local? && @status.quote.accepted?
|
||||||
@status.quote.destroy
|
@status.quote.destroy
|
||||||
quote = Quote.create(status: @status, approval_uri: approval_uri, legacy: @status_parser.legacy_quote?, state: @status_parser.deleted_quote? ? :deleted : :pending)
|
quote = Quote.create(status: @status, approval_uri: nil, legacy: @status_parser.legacy_quote?, state: @status_parser.deleted_quote? ? :deleted : :pending)
|
||||||
@quote_changed = true
|
@quote_changed = true
|
||||||
else
|
else
|
||||||
quote = @status.quote
|
quote = @status.quote
|
||||||
quote.update(approval_uri: approval_uri, state: :pending, legacy: @status_parser.legacy_quote?) if quote.approval_uri != approval_uri
|
quote.update(approval_uri: nil, state: :pending, legacy: @status_parser.legacy_quote?) if quote.approval_uri.present? && quote.approval_uri != approval_uri
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
quote = Quote.create(status: @status, approval_uri: approval_uri, legacy: @status_parser.legacy_quote?)
|
quote = Quote.create(status: @status, approval_uri: nil, legacy: @status_parser.legacy_quote?)
|
||||||
@quote_changed = true
|
@quote_changed = true
|
||||||
end
|
end
|
||||||
|
|
||||||
@quote = quote
|
@quote = quote
|
||||||
|
@quote_approval_uri = approval_uri
|
||||||
|
|
||||||
quote.save
|
quote.save
|
||||||
elsif @status.quote.present?
|
elsif @status.quote.present?
|
||||||
@quote = nil
|
@quote = nil
|
||||||
@@ -355,11 +357,11 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_and_verify_quote!(quote, quote_uri)
|
def fetch_and_verify_quote!(quote, approval_uri, quote_uri)
|
||||||
embedded_quote = safe_prefetched_embed(@account, @status_parser.quoted_object, @activity_json['context'])
|
embedded_quote = safe_prefetched_embed(@account, @status_parser.quoted_object, @activity_json['context'])
|
||||||
ActivityPub::VerifyQuoteService.new.call(quote, fetchable_quoted_uri: quote_uri, prefetched_quoted_object: embedded_quote, request_id: @request_id)
|
ActivityPub::VerifyQuoteService.new.call(quote, approval_uri, fetchable_quoted_uri: quote_uri, prefetched_quoted_object: embedded_quote, request_id: @request_id)
|
||||||
rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS
|
rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS
|
||||||
ActivityPub::RefetchAndVerifyQuoteWorker.perform_in(rand(30..600).seconds, quote.id, quote_uri, { 'request_id' => @request_id })
|
ActivityPub::RefetchAndVerifyQuoteWorker.perform_in(rand(30..600).seconds, quote.id, quote_uri, { 'request_id' => @request_id, 'approval_uri' => approval_uri })
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_counts!
|
def update_counts!
|
||||||
|
|||||||
@@ -6,20 +6,21 @@ class ActivityPub::VerifyQuoteService < BaseService
|
|||||||
MAX_SYNCHRONOUS_DEPTH = 2
|
MAX_SYNCHRONOUS_DEPTH = 2
|
||||||
|
|
||||||
# Optionally fetch quoted post, and verify the quote is authorized
|
# Optionally fetch quoted post, and verify the quote is authorized
|
||||||
def call(quote, fetchable_quoted_uri: nil, prefetched_quoted_object: nil, prefetched_approval: nil, request_id: nil, depth: nil)
|
def call(quote, approval_uri, fetchable_quoted_uri: nil, prefetched_quoted_object: nil, prefetched_approval: nil, request_id: nil, depth: nil)
|
||||||
@request_id = request_id
|
@request_id = request_id
|
||||||
@depth = depth || 0
|
@depth = depth || 0
|
||||||
@quote = quote
|
@quote = quote
|
||||||
|
@approval_uri = approval_uri.presence || @quote.approval_uri
|
||||||
@fetching_error = nil
|
@fetching_error = nil
|
||||||
|
|
||||||
fetch_quoted_post_if_needed!(fetchable_quoted_uri, prefetched_body: prefetched_quoted_object)
|
fetch_quoted_post_if_needed!(fetchable_quoted_uri, prefetched_body: prefetched_quoted_object)
|
||||||
return if quote.quoted_account&.local?
|
return if quote.quoted_account&.local?
|
||||||
return if fast_track_approval! || quote.approval_uri.blank?
|
return if fast_track_approval! || @approval_uri.blank?
|
||||||
|
|
||||||
@json = fetch_approval_object(quote.approval_uri, prefetched_body: prefetched_approval)
|
@json = fetch_approval_object(@approval_uri, prefetched_body: prefetched_approval)
|
||||||
return quote.reject! if @json.nil?
|
return quote.reject! if @json.nil?
|
||||||
|
|
||||||
return if non_matching_uri_hosts?(quote.approval_uri, value_or_id(@json['attributedTo']))
|
return if non_matching_uri_hosts?(@approval_uri, value_or_id(@json['attributedTo']))
|
||||||
return unless matching_type? && matching_quote_uri?
|
return unless matching_type? && matching_quote_uri?
|
||||||
|
|
||||||
# Opportunistically import embedded posts if needed
|
# Opportunistically import embedded posts if needed
|
||||||
@@ -30,7 +31,7 @@ class ActivityPub::VerifyQuoteService < BaseService
|
|||||||
|
|
||||||
return unless matching_quoted_post? && matching_quoted_author?
|
return unless matching_quoted_post? && matching_quoted_author?
|
||||||
|
|
||||||
quote.accept!
|
quote.accept!(approval_uri: @approval_uri)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -87,7 +88,7 @@ class ActivityPub::VerifyQuoteService < BaseService
|
|||||||
object = @json['interactionTarget'].merge({ '@context' => @json['@context'] })
|
object = @json['interactionTarget'].merge({ '@context' => @json['@context'] })
|
||||||
|
|
||||||
# It's not safe to fetch if the inlined object is cross-origin or doesn't match expectations
|
# It's not safe to fetch if the inlined object is cross-origin or doesn't match expectations
|
||||||
return if object['id'] != uri || non_matching_uri_hosts?(@quote.approval_uri, object['id'])
|
return if object['id'] != uri || non_matching_uri_hosts?(@approval_uri, object['id'])
|
||||||
|
|
||||||
status = ActivityPub::FetchRemoteStatusService.new.call(object['id'], prefetched_body: object, on_behalf_of: @quote.account.followers.local.first, request_id: @request_id, depth: @depth)
|
status = ActivityPub::FetchRemoteStatusService.new.call(object['id'], prefetched_body: object, on_behalf_of: @quote.account.followers.local.first, request_id: @request_id, depth: @depth)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ class ActivityPub::QuoteRefreshWorker
|
|||||||
return if quote.nil? || quote.updated_at > Quote::BACKGROUND_REFRESH_INTERVAL.ago
|
return if quote.nil? || quote.updated_at > Quote::BACKGROUND_REFRESH_INTERVAL.ago
|
||||||
|
|
||||||
quote.touch
|
quote.touch
|
||||||
ActivityPub::VerifyQuoteService.new.call(quote)
|
ActivityPub::VerifyQuoteService.new.call(quote, quote.approval_uri)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class ActivityPub::RefetchAndVerifyQuoteWorker
|
|||||||
|
|
||||||
def perform(quote_id, quoted_uri, options = {})
|
def perform(quote_id, quoted_uri, options = {})
|
||||||
quote = Quote.find(quote_id)
|
quote = Quote.find(quote_id)
|
||||||
ActivityPub::VerifyQuoteService.new.call(quote, fetchable_quoted_uri: quoted_uri, request_id: options[:request_id])
|
ActivityPub::VerifyQuoteService.new.call(quote, options['approval_uri'], fetchable_quoted_uri: quoted_uri, request_id: options['request_id'])
|
||||||
::DistributionWorker.perform_async(quote.status_id, { 'update' => true }) if quote.state_previously_changed?
|
::DistributionWorker.perform_async(quote.status_id, { 'update' => true }) if quote.state_previously_changed?
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
# Do nothing
|
# Do nothing
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ Rails.application.routes.draw do
|
|||||||
|
|
||||||
draw(:web_app)
|
draw(:web_app)
|
||||||
|
|
||||||
get '/web/(*any)', to: redirect('/%{any}', status: 302), as: :web, defaults: { any: '' }, format: false
|
get '/web/(*any)', to: redirect(path: '/%{any}', status: 302), as: :web, defaults: { any: '' }, format: false
|
||||||
get '/about', to: 'about#show'
|
get '/about', to: 'about#show'
|
||||||
get '/about/more', to: redirect('/about')
|
get '/about/more', to: redirect('/about')
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ services:
|
|||||||
web:
|
web:
|
||||||
# You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes
|
# You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes
|
||||||
# build: .
|
# build: .
|
||||||
image: ghcr.io/glitch-soc/mastodon:v4.5.7
|
image: ghcr.io/glitch-soc/mastodon:v4.5.8
|
||||||
restart: always
|
restart: always
|
||||||
env_file: .env.production
|
env_file: .env.production
|
||||||
command: bundle exec puma -C config/puma.rb
|
command: bundle exec puma -C config/puma.rb
|
||||||
@@ -83,7 +83,7 @@ services:
|
|||||||
# build:
|
# build:
|
||||||
# dockerfile: ./streaming/Dockerfile
|
# dockerfile: ./streaming/Dockerfile
|
||||||
# context: .
|
# context: .
|
||||||
image: ghcr.io/glitch-soc/mastodon-streaming:v4.5.7
|
image: ghcr.io/glitch-soc/mastodon-streaming:v4.5.8
|
||||||
restart: always
|
restart: always
|
||||||
env_file: .env.production
|
env_file: .env.production
|
||||||
command: node ./streaming/index.js
|
command: node ./streaming/index.js
|
||||||
@@ -102,7 +102,7 @@ services:
|
|||||||
sidekiq:
|
sidekiq:
|
||||||
# You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes
|
# You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes
|
||||||
# build: .
|
# build: .
|
||||||
image: ghcr.io/glitch-soc/mastodon:v4.5.7
|
image: ghcr.io/glitch-soc/mastodon:v4.5.8
|
||||||
restart: always
|
restart: always
|
||||||
env_file: .env.production
|
env_file: .env.production
|
||||||
command: bundle exec sidekiq
|
command: bundle exec sidekiq
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ module Mastodon
|
|||||||
end
|
end
|
||||||
|
|
||||||
def default_prerelease
|
def default_prerelease
|
||||||
'alpha.5'
|
'alpha.6'
|
||||||
end
|
end
|
||||||
|
|
||||||
def prerelease
|
def prerelease
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ RSpec.describe CollectionItem do
|
|||||||
expect(custom_item.position).to eq 7
|
expect(custom_item.position).to eq 7
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'automatically sets the position if excplicitly set to `nil`' do
|
||||||
|
item = collection.collection_items.create!(account:, position: nil)
|
||||||
|
|
||||||
|
expect(item.position).to eq 1
|
||||||
|
end
|
||||||
|
|
||||||
it 'automatically sets `activity_uri` when account is remote' do
|
it 'automatically sets `activity_uri` when account is remote' do
|
||||||
item = collection.collection_items.create(account: Fabricate(:remote_account))
|
item = collection.collection_items.create(account: Fabricate(:remote_account))
|
||||||
|
|
||||||
|
|||||||
@@ -47,15 +47,26 @@ RSpec.describe ActivityPub::ProcessFeaturedItemService do
|
|||||||
it_behaves_like 'non-matching URIs'
|
it_behaves_like 'non-matching URIs'
|
||||||
|
|
||||||
context 'when item does not yet exist' do
|
context 'when item does not yet exist' do
|
||||||
it 'creates and verifies the item' do
|
context 'when a position is given' do
|
||||||
expect { subject.call(collection, object, position:) }.to change(collection.collection_items, :count).by(1)
|
it 'creates and verifies the item' do
|
||||||
|
expect { subject.call(collection, object, position:) }.to change(collection.collection_items, :count).by(1)
|
||||||
|
|
||||||
expect(stubbed_service).to have_received(:call)
|
expect(stubbed_service).to have_received(:call)
|
||||||
|
|
||||||
new_item = collection.collection_items.last
|
new_item = collection.collection_items.last
|
||||||
expect(new_item.object_uri).to eq 'https://example.com/actor/1'
|
expect(new_item.object_uri).to eq 'https://example.com/actor/1'
|
||||||
expect(new_item.approval_uri).to be_nil
|
expect(new_item.approval_uri).to be_nil
|
||||||
expect(new_item.position).to eq 3
|
expect(new_item.position).to eq 3
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when no position is given' do
|
||||||
|
it 'creates the item' do
|
||||||
|
expect { subject.call(collection, object) }.to change(collection.collection_items, :count).by(1)
|
||||||
|
new_item = collection.collection_items.last
|
||||||
|
|
||||||
|
expect(new_item.position).to eq 1
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -940,10 +940,10 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
|
|||||||
stub_request(:get, approval_uri).to_return(headers: { 'Content-Type': 'application/activity+json' }, body: quote_authorization_json.to_json)
|
stub_request(:get, approval_uri).to_return(headers: { 'Content-Type': 'application/activity+json' }, body: quote_authorization_json.to_json)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'updates the approval URI but does not verify the quote' do
|
it 'does not update the approval URI and does not verify the quote' do
|
||||||
expect { subject.call(status, json, json) }
|
expect { subject.call(status, json, json) }
|
||||||
.to change(status, :quote).from(nil)
|
.to change(status, :quote).from(nil)
|
||||||
expect(status.quote.approval_uri).to eq approval_uri
|
expect(status.quote.approval_uri).to be_nil
|
||||||
expect(status.quote.state).to_not eq 'accepted'
|
expect(status.quote.state).to_not eq 'accepted'
|
||||||
expect(status.quote.quoted_status).to be_nil
|
expect(status.quote.quoted_status).to be_nil
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -9,268 +9,284 @@ RSpec.describe ActivityPub::VerifyQuoteService do
|
|||||||
let(:quoted_account) { Fabricate(:account, domain: 'b.example.com') }
|
let(:quoted_account) { Fabricate(:account, domain: 'b.example.com') }
|
||||||
let(:quoted_status) { Fabricate(:status, account: quoted_account) }
|
let(:quoted_status) { Fabricate(:status, account: quoted_account) }
|
||||||
let(:status) { Fabricate(:status, account: account) }
|
let(:status) { Fabricate(:status, account: account) }
|
||||||
let(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: approval_uri) }
|
let(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: approval_uri_record) }
|
||||||
|
|
||||||
context 'with an unfetchable approval URI' do
|
shared_examples 'common behavior' do
|
||||||
let(:approval_uri) { 'https://b.example.com/approvals/1234' }
|
context 'with an unfetchable approval URI' do
|
||||||
|
let(:approval_uri) { 'https://b.example.com/approvals/1234' }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
stub_request(:get, approval_uri)
|
stub_request(:get, approval_uri)
|
||||||
.to_return(status: 404)
|
.to_return(status: 404)
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'with an already-fetched post' do
|
context 'with an already-fetched post' do
|
||||||
it 'does not update the status' do
|
it 'does not update the status' do
|
||||||
expect { subject.call(quote) }
|
expect { subject.call(quote, approval_uri_arg) }
|
||||||
.to change(quote, :state).to('rejected')
|
.to change(quote, :state).to('rejected')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with an already-verified quote' do
|
||||||
|
let(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: approval_uri_record, state: :accepted) }
|
||||||
|
|
||||||
|
it 'rejects the quote' do
|
||||||
|
expect { subject.call(quote, approval_uri_arg) }
|
||||||
|
.to change(quote, :state).to('revoked')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'with an already-verified quote' do
|
context 'with an approval URI' do
|
||||||
let(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: approval_uri, state: :accepted) }
|
let(:approval_uri) { 'https://b.example.com/approvals/1234' }
|
||||||
|
|
||||||
it 'rejects the quote' do
|
let(:approval_type) { 'QuoteAuthorization' }
|
||||||
expect { subject.call(quote) }
|
let(:approval_id) { approval_uri }
|
||||||
.to change(quote, :state).to('revoked')
|
let(:approval_attributed_to) { ActivityPub::TagManager.instance.uri_for(quoted_account) }
|
||||||
end
|
let(:approval_interacting_object) { ActivityPub::TagManager.instance.uri_for(status) }
|
||||||
end
|
let(:approval_interaction_target) { ActivityPub::TagManager.instance.uri_for(quoted_status) }
|
||||||
end
|
|
||||||
|
|
||||||
context 'with an approval URI' do
|
let(:json) do
|
||||||
let(:approval_uri) { 'https://b.example.com/approvals/1234' }
|
|
||||||
|
|
||||||
let(:approval_type) { 'QuoteAuthorization' }
|
|
||||||
let(:approval_id) { approval_uri }
|
|
||||||
let(:approval_attributed_to) { ActivityPub::TagManager.instance.uri_for(quoted_account) }
|
|
||||||
let(:approval_interacting_object) { ActivityPub::TagManager.instance.uri_for(status) }
|
|
||||||
let(:approval_interaction_target) { ActivityPub::TagManager.instance.uri_for(quoted_status) }
|
|
||||||
|
|
||||||
let(:json) do
|
|
||||||
{
|
|
||||||
'@context': [
|
|
||||||
'https://www.w3.org/ns/activitystreams',
|
|
||||||
{
|
|
||||||
QuoteAuthorization: 'https://w3id.org/fep/044f#QuoteAuthorization',
|
|
||||||
gts: 'https://gotosocial.org/ns#',
|
|
||||||
interactionPolicy: {
|
|
||||||
'@id': 'gts:interactionPolicy',
|
|
||||||
'@type': '@id',
|
|
||||||
},
|
|
||||||
interactingObject: {
|
|
||||||
'@id': 'gts:interactingObject',
|
|
||||||
'@type': '@id',
|
|
||||||
},
|
|
||||||
interactionTarget: {
|
|
||||||
'@id': 'gts:interactionTarget',
|
|
||||||
'@type': '@id',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
type: approval_type,
|
|
||||||
id: approval_id,
|
|
||||||
attributedTo: approval_attributed_to,
|
|
||||||
interactingObject: approval_interacting_object,
|
|
||||||
interactionTarget: approval_interaction_target,
|
|
||||||
}.with_indifferent_access
|
|
||||||
end
|
|
||||||
|
|
||||||
before do
|
|
||||||
stub_request(:get, approval_uri)
|
|
||||||
.to_return(status: 200, body: json.to_json, headers: { 'Content-Type': 'application/activity+json' })
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with a valid activity for already-fetched posts' do
|
|
||||||
it 'updates the status' do
|
|
||||||
expect { subject.call(quote) }
|
|
||||||
.to change(quote, :state).to('accepted')
|
|
||||||
|
|
||||||
expect(a_request(:get, approval_uri))
|
|
||||||
.to have_been_made.once
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with a valid activity for a post that cannot be fetched but is passed as fetched_quoted_object' do
|
|
||||||
let(:quoted_status) { nil }
|
|
||||||
|
|
||||||
let(:approval_interaction_target) { 'https://b.example.com/unknown-quoted' }
|
|
||||||
let(:prefetched_object) do
|
|
||||||
{
|
{
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
'@context': [
|
||||||
type: 'Note',
|
'https://www.w3.org/ns/activitystreams',
|
||||||
id: 'https://b.example.com/unknown-quoted',
|
{
|
||||||
to: 'https://www.w3.org/ns/activitystreams#Public',
|
QuoteAuthorization: 'https://w3id.org/fep/044f#QuoteAuthorization',
|
||||||
attributedTo: ActivityPub::TagManager.instance.uri_for(quoted_account),
|
gts: 'https://gotosocial.org/ns#',
|
||||||
content: 'previously unknown post',
|
interactionPolicy: {
|
||||||
|
'@id': 'gts:interactionPolicy',
|
||||||
|
'@type': '@id',
|
||||||
|
},
|
||||||
|
interactingObject: {
|
||||||
|
'@id': 'gts:interactingObject',
|
||||||
|
'@type': '@id',
|
||||||
|
},
|
||||||
|
interactionTarget: {
|
||||||
|
'@id': 'gts:interactionTarget',
|
||||||
|
'@type': '@id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
type: approval_type,
|
||||||
|
id: approval_id,
|
||||||
|
attributedTo: approval_attributed_to,
|
||||||
|
interactingObject: approval_interacting_object,
|
||||||
|
interactionTarget: approval_interaction_target,
|
||||||
}.with_indifferent_access
|
}.with_indifferent_access
|
||||||
end
|
end
|
||||||
|
|
||||||
before do
|
before do
|
||||||
stub_request(:get, 'https://b.example.com/unknown-quoted')
|
stub_request(:get, approval_uri)
|
||||||
.to_return(status: 404)
|
.to_return(status: 200, body: json.to_json, headers: { 'Content-Type': 'application/activity+json' })
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'updates the status' do
|
context 'with a valid activity for already-fetched posts' do
|
||||||
expect { subject.call(quote, fetchable_quoted_uri: 'https://b.example.com/unknown-quoted', prefetched_quoted_object: prefetched_object) }
|
it 'updates the status' do
|
||||||
.to change(quote, :state).to('accepted')
|
expect { subject.call(quote, approval_uri_arg) }
|
||||||
|
.to change(quote, :state).to('accepted')
|
||||||
|
|
||||||
expect(a_request(:get, approval_uri))
|
expect(a_request(:get, approval_uri))
|
||||||
.to have_been_made.once
|
.to have_been_made.once
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
expect(quote.reload.quoted_status.content).to eq 'previously unknown post'
|
context 'with a valid activity for a post that cannot be fetched but is passed as fetched_quoted_object' do
|
||||||
|
let(:quoted_status) { nil }
|
||||||
|
|
||||||
|
let(:approval_interaction_target) { 'https://b.example.com/unknown-quoted' }
|
||||||
|
let(:prefetched_object) do
|
||||||
|
{
|
||||||
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||||
|
type: 'Note',
|
||||||
|
id: 'https://b.example.com/unknown-quoted',
|
||||||
|
to: 'https://www.w3.org/ns/activitystreams#Public',
|
||||||
|
attributedTo: ActivityPub::TagManager.instance.uri_for(quoted_account),
|
||||||
|
content: 'previously unknown post',
|
||||||
|
}.with_indifferent_access
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
stub_request(:get, 'https://b.example.com/unknown-quoted')
|
||||||
|
.to_return(status: 404)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates the status' do
|
||||||
|
expect { subject.call(quote, approval_uri_arg, fetchable_quoted_uri: 'https://b.example.com/unknown-quoted', prefetched_quoted_object: prefetched_object) }
|
||||||
|
.to change(quote, :state).to('accepted')
|
||||||
|
|
||||||
|
expect(a_request(:get, approval_uri))
|
||||||
|
.to have_been_made.once
|
||||||
|
|
||||||
|
expect(quote.reload.quoted_status.content).to eq 'previously unknown post'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a valid activity for a post that cannot be fetched but is inlined' do
|
||||||
|
let(:quoted_status) { nil }
|
||||||
|
|
||||||
|
let(:approval_interaction_target) do
|
||||||
|
{
|
||||||
|
type: 'Note',
|
||||||
|
id: 'https://b.example.com/unknown-quoted',
|
||||||
|
to: 'https://www.w3.org/ns/activitystreams#Public',
|
||||||
|
attributedTo: ActivityPub::TagManager.instance.uri_for(quoted_account),
|
||||||
|
content: 'previously unknown post',
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
stub_request(:get, 'https://b.example.com/unknown-quoted')
|
||||||
|
.to_return(status: 404)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates the status' do
|
||||||
|
expect { subject.call(quote, approval_uri_arg, fetchable_quoted_uri: 'https://b.example.com/unknown-quoted') }
|
||||||
|
.to change(quote, :state).to('accepted')
|
||||||
|
|
||||||
|
expect(a_request(:get, approval_uri))
|
||||||
|
.to have_been_made.once
|
||||||
|
|
||||||
|
expect(quote.reload.quoted_status.content).to eq 'previously unknown post'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a valid activity for a post that cannot be fetched and is inlined from an untrusted source' do
|
||||||
|
let(:quoted_status) { nil }
|
||||||
|
|
||||||
|
let(:approval_interaction_target) do
|
||||||
|
{
|
||||||
|
type: 'Note',
|
||||||
|
id: 'https://example.com/unknown-quoted',
|
||||||
|
to: 'https://www.w3.org/ns/activitystreams#Public',
|
||||||
|
attributedTo: ActivityPub::TagManager.instance.uri_for(account),
|
||||||
|
content: 'previously unknown post',
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
stub_request(:get, 'https://example.com/unknown-quoted')
|
||||||
|
.to_return(status: 404)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not update the status' do
|
||||||
|
expect { subject.call(quote, approval_uri_arg, fetchable_quoted_uri: 'https://example.com/unknown-quoted') }
|
||||||
|
.to not_change(quote, :state)
|
||||||
|
.and not_change(quote, :quoted_status)
|
||||||
|
|
||||||
|
expect(a_request(:get, approval_uri))
|
||||||
|
.to have_been_made.once
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a valid activity for already-fetched posts, with a pre-fetched approval' do
|
||||||
|
it 'updates the status without fetching the activity' do
|
||||||
|
expect { subject.call(quote, approval_uri_arg, prefetched_approval: JSON.generate(json)) }
|
||||||
|
.to change(quote, :state).to('accepted')
|
||||||
|
|
||||||
|
expect(a_request(:get, approval_uri))
|
||||||
|
.to_not have_been_made
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with an unverifiable approval' do
|
||||||
|
let(:approval_uri) { 'https://evil.com/approvals/1234' }
|
||||||
|
|
||||||
|
it 'does not update the status' do
|
||||||
|
expect { subject.call(quote, approval_uri_arg) }
|
||||||
|
.to_not change(quote, :state)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with an invalid approval document because of a mismatched ID' do
|
||||||
|
let(:approval_id) { 'https://evil.com/approvals/1234' }
|
||||||
|
|
||||||
|
it 'does not accept the quote' do
|
||||||
|
# NOTE: maybe we want to skip that instead of rejecting it?
|
||||||
|
expect { subject.call(quote, approval_uri_arg) }
|
||||||
|
.to change(quote, :state).to('rejected')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with an approval from the wrong account' do
|
||||||
|
let(:approval_attributed_to) { ActivityPub::TagManager.instance.uri_for(Fabricate(:account, domain: 'b.example.com')) }
|
||||||
|
|
||||||
|
it 'does not update the status' do
|
||||||
|
expect { subject.call(quote, approval_uri_arg) }
|
||||||
|
.to_not change(quote, :state)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with an approval for the wrong quoted post' do
|
||||||
|
let(:approval_interaction_target) { ActivityPub::TagManager.instance.uri_for(Fabricate(:status, account: quoted_account)) }
|
||||||
|
|
||||||
|
it 'does not update the status' do
|
||||||
|
expect { subject.call(quote, approval_uri_arg) }
|
||||||
|
.to_not change(quote, :state)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with an approval for the wrong quote post' do
|
||||||
|
let(:approval_interacting_object) { ActivityPub::TagManager.instance.uri_for(Fabricate(:status, account: account)) }
|
||||||
|
|
||||||
|
it 'does not update the status' do
|
||||||
|
expect { subject.call(quote, approval_uri_arg) }
|
||||||
|
.to_not change(quote, :state)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with an approval of the wrong type' do
|
||||||
|
let(:approval_type) { 'ReplyAuthorization' }
|
||||||
|
|
||||||
|
it 'does not update the status' do
|
||||||
|
expect { subject.call(quote, approval_uri_arg) }
|
||||||
|
.to_not change(quote, :state)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'with a valid activity for a post that cannot be fetched but is inlined' do
|
context 'with fast-track authorizations' do
|
||||||
let(:quoted_status) { nil }
|
let(:approval_uri) { nil }
|
||||||
|
|
||||||
let(:approval_interaction_target) do
|
context 'without any fast-track condition' do
|
||||||
{
|
it 'does not update the status' do
|
||||||
type: 'Note',
|
expect { subject.call(quote, approval_uri_arg) }
|
||||||
id: 'https://b.example.com/unknown-quoted',
|
.to_not change(quote, :state)
|
||||||
to: 'https://www.w3.org/ns/activitystreams#Public',
|
end
|
||||||
attributedTo: ActivityPub::TagManager.instance.uri_for(quoted_account),
|
|
||||||
content: 'previously unknown post',
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
before do
|
context 'when the account and the quoted account are the same' do
|
||||||
stub_request(:get, 'https://b.example.com/unknown-quoted')
|
let(:quoted_account) { account }
|
||||||
.to_return(status: 404)
|
|
||||||
|
it 'updates the status' do
|
||||||
|
expect { subject.call(quote, approval_uri_arg) }
|
||||||
|
.to change(quote, :state).to('accepted')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'updates the status' do
|
context 'when the account is mentioned by the quoted post' do
|
||||||
expect { subject.call(quote, fetchable_quoted_uri: 'https://b.example.com/unknown-quoted') }
|
before do
|
||||||
.to change(quote, :state).to('accepted')
|
quoted_status.mentions << Mention.new(account: account)
|
||||||
|
end
|
||||||
|
|
||||||
expect(a_request(:get, approval_uri))
|
it 'does not update the status' do
|
||||||
.to have_been_made.once
|
expect { subject.call(quote, approval_uri_arg) }
|
||||||
|
.to_not change(quote, :state).from('pending')
|
||||||
expect(quote.reload.quoted_status.content).to eq 'previously unknown post'
|
end
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with a valid activity for a post that cannot be fetched and is inlined from an untrusted source' do
|
|
||||||
let(:quoted_status) { nil }
|
|
||||||
|
|
||||||
let(:approval_interaction_target) do
|
|
||||||
{
|
|
||||||
type: 'Note',
|
|
||||||
id: 'https://example.com/unknown-quoted',
|
|
||||||
to: 'https://www.w3.org/ns/activitystreams#Public',
|
|
||||||
attributedTo: ActivityPub::TagManager.instance.uri_for(account),
|
|
||||||
content: 'previously unknown post',
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
before do
|
|
||||||
stub_request(:get, 'https://example.com/unknown-quoted')
|
|
||||||
.to_return(status: 404)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not update the status' do
|
|
||||||
expect { subject.call(quote, fetchable_quoted_uri: 'https://example.com/unknown-quoted') }
|
|
||||||
.to not_change(quote, :state)
|
|
||||||
.and not_change(quote, :quoted_status)
|
|
||||||
|
|
||||||
expect(a_request(:get, approval_uri))
|
|
||||||
.to have_been_made.once
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with a valid activity for already-fetched posts, with a pre-fetched approval' do
|
|
||||||
it 'updates the status without fetching the activity' do
|
|
||||||
expect { subject.call(quote, prefetched_approval: json.to_json) }
|
|
||||||
.to change(quote, :state).to('accepted')
|
|
||||||
|
|
||||||
expect(a_request(:get, approval_uri))
|
|
||||||
.to_not have_been_made
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with an unverifiable approval' do
|
|
||||||
let(:approval_uri) { 'https://evil.com/approvals/1234' }
|
|
||||||
|
|
||||||
it 'does not update the status' do
|
|
||||||
expect { subject.call(quote) }
|
|
||||||
.to_not change(quote, :state)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with an invalid approval document because of a mismatched ID' do
|
|
||||||
let(:approval_id) { 'https://evil.com/approvals/1234' }
|
|
||||||
|
|
||||||
it 'does not accept the quote' do
|
|
||||||
# NOTE: maybe we want to skip that instead of rejecting it?
|
|
||||||
expect { subject.call(quote) }
|
|
||||||
.to change(quote, :state).to('rejected')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with an approval from the wrong account' do
|
|
||||||
let(:approval_attributed_to) { ActivityPub::TagManager.instance.uri_for(Fabricate(:account, domain: 'b.example.com')) }
|
|
||||||
|
|
||||||
it 'does not update the status' do
|
|
||||||
expect { subject.call(quote) }
|
|
||||||
.to_not change(quote, :state)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with an approval for the wrong quoted post' do
|
|
||||||
let(:approval_interaction_target) { ActivityPub::TagManager.instance.uri_for(Fabricate(:status, account: quoted_account)) }
|
|
||||||
|
|
||||||
it 'does not update the status' do
|
|
||||||
expect { subject.call(quote) }
|
|
||||||
.to_not change(quote, :state)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with an approval for the wrong quote post' do
|
|
||||||
let(:approval_interacting_object) { ActivityPub::TagManager.instance.uri_for(Fabricate(:status, account: account)) }
|
|
||||||
|
|
||||||
it 'does not update the status' do
|
|
||||||
expect { subject.call(quote) }
|
|
||||||
.to_not change(quote, :state)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with an approval of the wrong type' do
|
|
||||||
let(:approval_type) { 'ReplyAuthorization' }
|
|
||||||
|
|
||||||
it 'does not update the status' do
|
|
||||||
expect { subject.call(quote) }
|
|
||||||
.to_not change(quote, :state)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'with fast-track authorizations' do
|
context 'when approval URI is passed as argument' do
|
||||||
let(:approval_uri) { nil }
|
let(:approval_uri_arg) { approval_uri }
|
||||||
|
let(:approval_uri_record) { nil }
|
||||||
|
|
||||||
context 'without any fast-track condition' do
|
it_behaves_like 'common behavior'
|
||||||
it 'does not update the status' do
|
end
|
||||||
expect { subject.call(quote) }
|
|
||||||
.to_not change(quote, :state)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when the account and the quoted account are the same' do
|
context 'when approval URI is stored in the record (legacy)' do
|
||||||
let(:quoted_account) { account }
|
let(:approval_uri_arg) { nil }
|
||||||
|
let(:approval_uri_record) { approval_uri }
|
||||||
|
|
||||||
it 'updates the status' do
|
it_behaves_like 'common behavior'
|
||||||
expect { subject.call(quote) }
|
|
||||||
.to change(quote, :state).to('accepted')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when the account is mentioned by the quoted post' do
|
|
||||||
before do
|
|
||||||
quoted_status.mentions << Mention.new(account: account)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not the status' do
|
|
||||||
expect { subject.call(quote) }
|
|
||||||
.to_not change(quote, :state).from('pending')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ RSpec.describe ActivityPub::QuoteRefreshWorker do
|
|||||||
expect { worker.perform(quote.id) }
|
expect { worker.perform(quote.id) }
|
||||||
.to(change { quote.reload.updated_at })
|
.to(change { quote.reload.updated_at })
|
||||||
|
|
||||||
expect(service).to have_received(:call).with(quote)
|
expect(service).to have_received(:call).with(quote, quote.approval_uri)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ RSpec.describe ActivityPub::QuoteRefreshWorker do
|
|||||||
expect { worker.perform(quote.id) }
|
expect { worker.perform(quote.id) }
|
||||||
.to_not(change { quote.reload.updated_at })
|
.to_not(change { quote.reload.updated_at })
|
||||||
|
|
||||||
expect(service).to_not have_received(:call).with(quote)
|
expect(service).to_not have_received(:call).with(quote, quote.approval_uri)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,11 +13,20 @@ RSpec.describe ActivityPub::RefetchAndVerifyQuoteWorker do
|
|||||||
let(:status) { Fabricate(:status, account: account) }
|
let(:status) { Fabricate(:status, account: account) }
|
||||||
let(:quote) { Fabricate(:quote, status: status, quoted_status: nil) }
|
let(:quote) { Fabricate(:quote, status: status, quoted_status: nil) }
|
||||||
let(:url) { 'https://example.com/quoted-status' }
|
let(:url) { 'https://example.com/quoted-status' }
|
||||||
|
let(:approval_uri) { 'https://example.com/approval-uri' }
|
||||||
|
|
||||||
it 'sends the status to the service' do
|
it 'sends the status to the service' do
|
||||||
worker.perform(quote.id, url)
|
worker.perform(quote.id, url, { 'approval_uri' => approval_uri })
|
||||||
|
|
||||||
expect(service).to have_received(:call).with(quote, fetchable_quoted_uri: url, request_id: anything)
|
expect(service).to have_received(:call).with(quote, approval_uri, fetchable_quoted_uri: url, request_id: anything)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with the old format' do
|
||||||
|
it 'sends the status to the service' do
|
||||||
|
worker.perform(quote.id, url)
|
||||||
|
|
||||||
|
expect(service).to have_received(:call).with(quote, nil, fetchable_quoted_uri: url, request_id: anything)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns nil for non-existent record' do
|
it 'returns nil for non-existent record' do
|
||||||
|
|||||||
Reference in New Issue
Block a user