Merge commit 'c72ca33fac1ae1518371f5954ae9487692b17709' into glitch-soc/merge-upstream

This commit is contained in:
Claire
2026-03-24 17:05:53 +01:00
46 changed files with 881 additions and 551 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 youd like to share.', 'Add your pronouns, external links, or anything else youd 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)}
/> />
} }
> >

View File

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

View File

@@ -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> DONT: <ul> <li>Start with “Photo of” its 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> DONT: <ul> <li>Start with “Photo of” its 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>
)}
</> </>
); );
}; };

View File

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

View File

@@ -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 peoples posts.' id='account_edit.profile_tab.show_media_replies.description'
/> defaultMessage='When enabled, Media tab shows both your posts and replies to other peoples posts.'
} />
/> }
/>
)}
<ToggleField <ToggleField
checked={profile.showFeatured} checked={profile.showFeatured}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 pages Activity view.", "account_edit_tags.help_text": "Featured hashtags help users discover and interact with your profile. They appear as filters on your Profile pages Activity view.",
"account_edit_tags.search_placeholder": "Enter a hashtag…", "account_edit_tags.search_placeholder": "Enter a hashtag…",
"account_edit_tags.suggestions": "Suggestions:", "account_edit_tags.suggestions": "Suggestions:",

View File

@@ -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 youd like to share.", "account_edit.custom_fields.placeholder": "Add your pronouns, external links, or anything else youd like to share.",
"account_edit.custom_fields.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 cant be undone.", "account_edit.field_delete_modal.confirm": "Are you sure you want to delete this custom field? This action cant be undone.",
"account_edit.field_delete_modal.delete_button": "Delete", "account_edit.field_delete_modal.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. Heres how it works:", "account_edit.verified_modal.details": "Add credibility to your Mastodon profile by verifying links to personal websites. Heres how it works:",
"account_edit.verified_modal.invisible_link.details": "Add the link to your header. The important part is rel=\"me\" which prevents impersonation on websites with user-generated content. You can even use a link tag in the header of the page instead of {tag}, but the HTML must be accessible without executing JavaScript.", "account_edit.verified_modal.invisible_link.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 pages Activity view.", "account_edit_tags.help_text": "Featured hashtags help users discover and interact with your profile. They appear as filters on your Profile pages Activity view.",
"account_edit_tags.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}",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ module Mastodon
end end
def default_prerelease def default_prerelease
'alpha.5' 'alpha.6'
end end
def prerelease def prerelease

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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