Merge commit '74b3b6c798d1f137947e80df8eefb7412e70febd' into glitch-soc/merge-upstream

This commit is contained in:
Claire
2026-03-02 18:11:45 +01:00
41 changed files with 593 additions and 156 deletions

View File

@@ -10,6 +10,6 @@ linters:
MiddleDot: MiddleDot:
enabled: true enabled: true
LineLength: LineLength:
max: 300 max: 240 # Override default value of 80 inherited from rubocop
ViewLength: ViewLength:
max: 200 # Override default value of 100 inherited from rubocop max: 200 # Override default value of 100 inherited from rubocop

View File

@@ -11,7 +11,7 @@ class Api::V1Alpha::CollectionItemsController < Api::BaseController
before_action :set_collection before_action :set_collection
before_action :set_account, only: [:create] before_action :set_account, only: [:create]
before_action :set_collection_item, only: [:destroy] before_action :set_collection_item, only: [:destroy, :revoke]
after_action :verify_authorized after_action :verify_authorized
@@ -32,6 +32,14 @@ class Api::V1Alpha::CollectionItemsController < Api::BaseController
head 200 head 200
end end
def revoke
authorize @collection_item, :revoke?
RevokeCollectionItemService.new.call(@collection_item)
head 200
end
private private
def set_collection def set_collection

View File

@@ -18,4 +18,12 @@ module RegistrationHelper
def ip_blocked?(remote_ip) def ip_blocked?(remote_ip)
IpBlock.severity_sign_up_block.containing(remote_ip.to_s).exists? IpBlock.severity_sign_up_block.containing(remote_ip.to_s).exists?
end end
def terms_agreement_label
if TermsOfService.live.exists?
t('auth.user_agreement_html', privacy_policy_path: privacy_policy_path, terms_of_service_path: terms_of_service_path)
else
t('auth.user_privacy_agreement_html', privacy_policy_path: privacy_policy_path)
end
end
end end

View File

@@ -23,6 +23,16 @@ module SettingsHelper
) )
end end
def author_attribution_name(account)
return if account.nil?
link_to(root_url, class: 'story__details__shared__author-link') do
safe_join(
[image_tag(account.avatar.url, class: 'account__avatar', size: 16, alt: ''), tag.bdi(display_name(account))]
)
end
end
def session_device_icon(session) def session_device_icon(session)
device = session.detection.device device = session.detection.device

View File

@@ -1,4 +1,5 @@
import type { ApiAccountFieldJSON } from './accounts'; import type { ApiAccountFieldJSON } from './accounts';
import type { ApiFeaturedTagJSON } from './tags';
export interface ApiProfileJSON { export interface ApiProfileJSON {
id: string; id: string;
@@ -20,6 +21,7 @@ export interface ApiProfileJSON {
show_media_replies: boolean; show_media_replies: boolean;
show_featured: boolean; show_featured: boolean;
attribution_domains: string[]; attribution_domains: string[];
featured_tags: ApiFeaturedTagJSON[];
} }
export type ApiProfileUpdateParams = Partial< export type ApiProfileUpdateParams = Partial<

View File

@@ -17,16 +17,6 @@ export interface ApiHashtagJSON extends ApiHashtagBase {
} }
export interface ApiFeaturedTagJSON extends ApiHashtagBase { export interface ApiFeaturedTagJSON extends ApiHashtagBase {
statuses_count: number; statuses_count: string;
last_status_at: string | null; last_status_at: string | null;
} }
export function hashtagToFeaturedTag(tag: ApiHashtagJSON): ApiFeaturedTagJSON {
return {
id: tag.id,
name: tag.name,
url: tag.url,
statuses_count: 0,
last_status_at: null,
};
}

View File

@@ -4,8 +4,11 @@ import { FormattedMessage } from 'react-intl';
import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react'; import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { getColumnSkipLinkId } from 'mastodon/features/ui/components/skip_links';
import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context'; import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
import { useColumnIndexContext } from '../features/ui/components/columns_area';
import { useAppHistory } from './router'; import { useAppHistory } from './router';
type OnClickCallback = () => void; type OnClickCallback = () => void;
@@ -28,9 +31,15 @@ export const ColumnBackButton: React.FC<{ onClick?: OnClickCallback }> = ({
onClick, onClick,
}) => { }) => {
const handleClick = useHandleClick(onClick); const handleClick = useHandleClick(onClick);
const columnIndex = useColumnIndexContext();
const component = ( const component = (
<button onClick={handleClick} className='column-back-button' type='button'> <button
onClick={handleClick}
id={getColumnSkipLinkId(columnIndex)}
className='column-back-button'
type='button'
>
<Icon <Icon
id='chevron-left' id='chevron-left'
icon={ArrowBackIcon} icon={ArrowBackIcon}

View File

@@ -16,6 +16,9 @@ import { Icon } from 'mastodon/components/icon';
import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context'; import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
import { useIdentity } from 'mastodon/identity_context'; import { useIdentity } from 'mastodon/identity_context';
import { useColumnIndexContext } from '../features/ui/components/columns_area';
import { getColumnSkipLinkId } from '../features/ui/components/skip_links';
import { useAppHistory } from './router'; import { useAppHistory } from './router';
export const messages = defineMessages({ export const messages = defineMessages({
@@ -33,10 +36,11 @@ export const messages = defineMessages({
}); });
const BackButton: React.FC<{ const BackButton: React.FC<{
onlyIcon: boolean; hasTitle: boolean;
}> = ({ onlyIcon }) => { }> = ({ hasTitle }) => {
const history = useAppHistory(); const history = useAppHistory();
const intl = useIntl(); const intl = useIntl();
const columnIndex = useColumnIndexContext();
const handleBackClick = useCallback(() => { const handleBackClick = useCallback(() => {
if (history.location.state?.fromMastodon) { if (history.location.state?.fromMastodon) {
@@ -50,8 +54,9 @@ const BackButton: React.FC<{
<button <button
onClick={handleBackClick} onClick={handleBackClick}
className={classNames('column-header__back-button', { className={classNames('column-header__back-button', {
compact: onlyIcon, compact: hasTitle,
})} })}
id={!hasTitle ? getColumnSkipLinkId(columnIndex) : undefined}
aria-label={intl.formatMessage(messages.back)} aria-label={intl.formatMessage(messages.back)}
type='button' type='button'
> >
@@ -60,7 +65,7 @@ const BackButton: React.FC<{
icon={ArrowBackIcon} icon={ArrowBackIcon}
className='column-back-button__icon' className='column-back-button__icon'
/> />
{!onlyIcon && ( {!hasTitle && (
<FormattedMessage id='column_back_button.label' defaultMessage='Back' /> <FormattedMessage id='column_back_button.label' defaultMessage='Back' />
)} )}
</button> </button>
@@ -221,7 +226,7 @@ export const ColumnHeader: React.FC<Props> = ({
!pinned && !pinned &&
((multiColumn && history.location.state?.fromMastodon) || showBackButton) ((multiColumn && history.location.state?.fromMastodon) || showBackButton)
) { ) {
backButton = <BackButton onlyIcon={!!title} />; backButton = <BackButton hasTitle={!!title} />;
} }
const collapsedContent = [extraContent]; const collapsedContent = [extraContent];
@@ -260,6 +265,7 @@ export const ColumnHeader: React.FC<Props> = ({
const hasIcon = icon && iconComponent; const hasIcon = icon && iconComponent;
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const hasTitle = (hasIcon || backButton) && title; const hasTitle = (hasIcon || backButton) && title;
const columnIndex = useColumnIndexContext();
const component = ( const component = (
<div className={wrapperClassName}> <div className={wrapperClassName}>
@@ -272,6 +278,7 @@ export const ColumnHeader: React.FC<Props> = ({
onClick={handleTitleClick} onClick={handleTitleClick}
className='column-header__title' className='column-header__title'
type='button' type='button'
id={getColumnSkipLinkId(columnIndex)}
> >
{!backButton && hasIcon && ( {!backButton && hasIcon && (
<Icon <Icon

View File

@@ -63,7 +63,10 @@ interface ComboboxProps<T extends ComboboxItem> extends TextInputProps {
* Customise the rendering of each option. * Customise the rendering of each option.
* The rendered content must not contain other interactive content! * The rendered content must not contain other interactive content!
*/ */
renderItem: (item: T, state: ComboboxItemState) => React.ReactElement; renderItem: (
item: T,
state: ComboboxItemState,
) => React.ReactElement | string;
/** /**
* The main selection handler, called when an option is selected or deselected. * The main selection handler, called when an option is selected or deselected.
*/ */

View File

@@ -50,6 +50,9 @@ const hotkeyTest: Story['play'] = async ({ canvas, userEvent }) => {
await userEvent.keyboard('gh'); await userEvent.keyboard('gh');
await confirmHotkey('goToHome'); await confirmHotkey('goToHome');
await userEvent.keyboard('ge');
await confirmHotkey('goToExplore');
await userEvent.keyboard('gn'); await userEvent.keyboard('gn');
await confirmHotkey('goToNotifications'); await confirmHotkey('goToNotifications');
@@ -106,6 +109,9 @@ export const Default = {
goToHome: () => { goToHome: () => {
setMatchedHotkey('goToHome'); setMatchedHotkey('goToHome');
}, },
goToExplore: () => {
setMatchedHotkey('goToExplore');
},
goToNotifications: () => { goToNotifications: () => {
setMatchedHotkey('goToNotifications'); setMatchedHotkey('goToNotifications');
}, },

View File

@@ -118,6 +118,7 @@ const hotkeyMatcherMap = {
openMedia: just('e'), openMedia: just('e'),
onTranslate: just('t'), onTranslate: just('t'),
goToHome: sequence('g', 'h'), goToHome: sequence('g', 'h'),
goToExplore: sequence('g', 'e'),
goToNotifications: sequence('g', 'n'), goToNotifications: sequence('g', 'n'),
goToLocal: sequence('g', 'l'), goToLocal: sequence('g', 'l'),
goToFederated: sequence('g', 't'), goToFederated: sequence('g', 't'),

View File

@@ -1,16 +1,14 @@
import classNames from 'classnames'; import classNames from 'classnames';
interface SimpleComponentProps { interface ModalShellProps {
className?: string; className?: string;
children?: React.ReactNode; children?: React.ReactNode;
} }
interface ModalShellComponent extends React.FC<SimpleComponentProps> { export const ModalShell: React.FC<ModalShellProps> = ({
Body: React.FC<SimpleComponentProps>; children,
Actions: React.FC<SimpleComponentProps>; className,
} }) => {
export const ModalShell: ModalShellComponent = ({ children, className }) => {
return ( return (
<div <div
className={classNames( className={classNames(
@@ -24,7 +22,7 @@ export const ModalShell: ModalShellComponent = ({ children, className }) => {
); );
}; };
const ModalShellBody: ModalShellComponent['Body'] = ({ export const ModalShellBody: React.FC<ModalShellProps> = ({
children, children,
className, className,
}) => { }) => {
@@ -39,7 +37,7 @@ const ModalShellBody: ModalShellComponent['Body'] = ({
); );
}; };
const ModalShellActions: ModalShellComponent['Actions'] = ({ export const ModalShellActions: React.FC<ModalShellProps> = ({
children, children,
className, className,
}) => { }) => {
@@ -51,6 +49,3 @@ const ModalShellActions: ModalShellComponent['Actions'] = ({
</div> </div>
); );
}; };
ModalShell.Body = ModalShellBody;
ModalShell.Actions = ModalShellActions;

View File

@@ -1,9 +1,9 @@
import type { ChangeEventHandler, FC } from 'react'; import type { ChangeEventHandler, FC } from 'react';
import { useCallback } from 'react'; import { useCallback, useMemo } from 'react';
import { useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import type { ApiFeaturedTagJSON } from '@/mastodon/api_types/tags'; import type { ApiHashtagJSON } from '@/mastodon/api_types/tags';
import { Combobox } from '@/mastodon/components/form_fields'; import { Combobox } from '@/mastodon/components/form_fields';
import { import {
addFeaturedTag, addFeaturedTag,
@@ -15,10 +15,50 @@ import SearchIcon from '@/material-icons/400-24px/search.svg?react';
import classes from '../styles.module.scss'; import classes from '../styles.module.scss';
type SearchResult = Omit<ApiHashtagJSON, 'url' | 'history'> & {
label?: string;
};
const messages = defineMessages({
placeholder: {
id: 'account_edit_tags.search_placeholder',
defaultMessage: 'Enter a hashtag…',
},
addTag: {
id: 'account_edit_tags.add_tag',
defaultMessage: 'Add #{tagName}',
},
});
export const AccountEditTagSearch: FC = () => { export const AccountEditTagSearch: FC = () => {
const { query, isLoading, results } = useAppSelector( const intl = useIntl();
(state) => state.profileEdit.search,
); const {
query,
isLoading,
results: rawResults,
} = useAppSelector((state) => state.profileEdit.search);
const results = useMemo(() => {
if (!rawResults) {
return [];
}
const results: SearchResult[] = [...rawResults]; // Make array mutable
const trimmedQuery = query.trim();
if (
trimmedQuery.length > 0 &&
results.every(
(result) => result.name.toLowerCase() !== trimmedQuery.toLowerCase(),
)
) {
results.push({
id: 'new',
name: trimmedQuery,
label: intl.formatMessage(messages.addTag, { tagName: trimmedQuery }),
});
}
return results;
}, [intl, query, rawResults]);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const handleSearchChange: ChangeEventHandler<HTMLInputElement> = useCallback( const handleSearchChange: ChangeEventHandler<HTMLInputElement> = useCallback(
@@ -28,10 +68,8 @@ export const AccountEditTagSearch: FC = () => {
[dispatch], [dispatch],
); );
const intl = useIntl();
const handleSelect = useCallback( const handleSelect = useCallback(
(item: ApiFeaturedTagJSON) => { (item: SearchResult) => {
void dispatch(clearSearch()); void dispatch(clearSearch());
void dispatch(addFeaturedTag({ name: item.name })); void dispatch(addFeaturedTag({ name: item.name }));
}, },
@@ -42,11 +80,8 @@ export const AccountEditTagSearch: FC = () => {
<Combobox <Combobox
value={query} value={query}
onChange={handleSearchChange} onChange={handleSearchChange}
placeholder={intl.formatMessage({ placeholder={intl.formatMessage(messages.placeholder)}
id: 'account_edit_tags.search_placeholder', items={results}
defaultMessage: 'Enter a hashtag…',
})}
items={results ?? []}
isLoading={isLoading} isLoading={isLoading}
renderItem={renderItem} renderItem={renderItem}
onSelectItem={handleSelect} onSelectItem={handleSelect}
@@ -57,4 +92,4 @@ export const AccountEditTagSearch: FC = () => {
); );
}; };
const renderItem = (item: ApiFeaturedTagJSON) => <p>#{item.name}</p>; const renderItem = (item: SearchResult) => item.label ?? `#${item.name}`;

View File

@@ -3,15 +3,15 @@ import type { FC } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import type { ApiFeaturedTagJSON } from '@/mastodon/api_types/tags';
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';
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId'; import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
import type { TagData } from '@/mastodon/reducers/slices/profile_edit';
import { import {
addFeaturedTag, addFeaturedTag,
deleteFeaturedTag, deleteFeaturedTag,
fetchFeaturedTags, fetchProfile,
fetchSuggestedTags, fetchSuggestedTags,
} from '@/mastodon/reducers/slices/profile_edit'; } from '@/mastodon/reducers/slices/profile_edit';
import { import {
@@ -35,9 +35,9 @@ const messages = defineMessages({
const selectTags = createAppSelector( const selectTags = createAppSelector(
[(state) => state.profileEdit], [(state) => state.profileEdit],
(profileEdit) => ({ (profileEdit) => ({
tags: profileEdit.tags ?? [], tags: profileEdit.profile?.featuredTags ?? [],
tagSuggestions: profileEdit.tagSuggestions ?? [], tagSuggestions: profileEdit.tagSuggestions ?? [],
isLoading: !profileEdit.tags || !profileEdit.tagSuggestions, isLoading: !profileEdit.profile || !profileEdit.tagSuggestions,
isPending: profileEdit.isPending, isPending: profileEdit.isPending,
}), }),
); );
@@ -52,7 +52,7 @@ export const AccountEditFeaturedTags: FC = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
useEffect(() => { useEffect(() => {
void dispatch(fetchFeaturedTags()); void dispatch(fetchProfile());
void dispatch(fetchSuggestedTags()); void dispatch(fetchSuggestedTags());
}, [dispatch]); }, [dispatch]);
@@ -78,7 +78,9 @@ export const AccountEditFeaturedTags: FC = () => {
defaultMessage='Featured hashtags help users discover and interact with your profile. They appear as filters on your Profile pages Activity view.' defaultMessage='Featured hashtags help users discover and interact with your profile. They appear as filters on your Profile pages Activity view.'
tagName='p' tagName='p'
/> />
<AccountEditTagSearch /> <AccountEditTagSearch />
{tagSuggestions.length > 0 && ( {tagSuggestions.length > 0 && (
<div className={classes.tagSuggestions}> <div className={classes.tagSuggestions}>
<FormattedMessage <FormattedMessage
@@ -90,7 +92,9 @@ export const AccountEditFeaturedTags: FC = () => {
))} ))}
</div> </div>
)} )}
{isLoading && <LoadingIndicator />} {isLoading && <LoadingIndicator />}
<AccountEditItemList <AccountEditItemList
items={tags} items={tags}
disabled={isPending} disabled={isPending}
@@ -102,15 +106,15 @@ export const AccountEditFeaturedTags: FC = () => {
); );
}; };
function renderTag(tag: ApiFeaturedTagJSON) { function renderTag(tag: TagData) {
return ( return (
<div className={classes.tagItem}> <div className={classes.tagItem}>
<h4>#{tag.name}</h4> <h4>#{tag.name}</h4>
{tag.statuses_count > 0 && ( {tag.statusesCount > 0 && (
<FormattedMessage <FormattedMessage
id='account_edit_tags.tag_status_count' id='account_edit_tags.tag_status_count'
defaultMessage='{count, plural, one {# post} other {# posts}}' defaultMessage='{count, plural, one {# post} other {# posts}}'
values={{ count: tag.statuses_count }} values={{ count: tag.statusesCount }}
tagName='p' tagName='p'
/> />
)} )}

View File

@@ -15,10 +15,7 @@ import { useElementHandledLink } from '@/mastodon/components/status/handled_link
import { useAccount } from '@/mastodon/hooks/useAccount'; import { useAccount } from '@/mastodon/hooks/useAccount';
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId'; import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
import { autoPlayGif } from '@/mastodon/initial_state'; import { autoPlayGif } from '@/mastodon/initial_state';
import { import { fetchProfile } from '@/mastodon/reducers/slices/profile_edit';
fetchFeaturedTags,
fetchProfile,
} from '@/mastodon/reducers/slices/profile_edit';
import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { AccountEditColumn, AccountEditEmptyColumn } from './components/column'; import { AccountEditColumn, AccountEditEmptyColumn } from './components/column';
@@ -87,9 +84,8 @@ export const AccountEdit: FC = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { profile, tags = [] } = useAppSelector((state) => state.profileEdit); const { profile } = useAppSelector((state) => state.profileEdit);
useEffect(() => { useEffect(() => {
void dispatch(fetchFeaturedTags());
void dispatch(fetchProfile()); void dispatch(fetchProfile());
}, [dispatch]); }, [dispatch]);
@@ -127,7 +123,7 @@ export const AccountEdit: FC = () => {
const headerSrc = autoPlayGif ? profile.header : profile.headerStatic; const headerSrc = autoPlayGif ? profile.header : profile.headerStatic;
const hasName = !!profile.displayName; const hasName = !!profile.displayName;
const hasBio = !!profile.bio; const hasBio = !!profile.bio;
const hasTags = tags.length > 0; const hasTags = profile.featuredTags.length > 0;
return ( return (
<AccountEditColumn <AccountEditColumn
@@ -190,7 +186,7 @@ export const AccountEdit: FC = () => {
/> />
} }
> >
{tags.map((tag) => `#${tag.name}`).join(', ')} {profile.featuredTags.map((tag) => `#${tag.name}`).join(', ')}
</AccountEditSection> </AccountEditSection>
<AccountEditSection <AccountEditSection

View File

@@ -4,7 +4,11 @@ import { FormattedMessage } from 'react-intl';
import { Button } from '@/mastodon/components/button'; import { Button } from '@/mastodon/components/button';
import { EmojiHTML } from '@/mastodon/components/emoji/html'; import { EmojiHTML } from '@/mastodon/components/emoji/html';
import { ModalShell } from '@/mastodon/components/modal_shell'; import {
ModalShell,
ModalShellActions,
ModalShellBody,
} from '@/mastodon/components/modal_shell';
import type { AccountField } from '../common'; import type { AccountField } from '../common';
import { useFieldHtml } from '../hooks/useFieldHtml'; import { useFieldHtml } from '../hooks/useFieldHtml';
@@ -19,7 +23,7 @@ export const AccountFieldModal: FC<{
const handleValueElement = useFieldHtml(field.valueHasEmojis); const handleValueElement = useFieldHtml(field.valueHasEmojis);
return ( return (
<ModalShell> <ModalShell>
<ModalShell.Body> <ModalShellBody>
<EmojiHTML <EmojiHTML
as='h2' as='h2'
htmlString={field.name_emojified} htmlString={field.name_emojified}
@@ -31,12 +35,12 @@ export const AccountFieldModal: FC<{
onElement={handleValueElement} onElement={handleValueElement}
className={classes.fieldValue} className={classes.fieldValue}
/> />
</ModalShell.Body> </ModalShellBody>
<ModalShell.Actions> <ModalShellActions>
<Button onClick={onClose} plain> <Button onClick={onClose} plain>
<FormattedMessage id='lightbox.close' defaultMessage='Close' /> <FormattedMessage id='lightbox.close' defaultMessage='Close' />
</Button> </Button>
</ModalShell.Actions> </ModalShellActions>
</ModalShell> </ModalShell>
); );
}; };

View File

@@ -13,7 +13,11 @@ import { AvatarGroup } from 'mastodon/components/avatar_group';
import { Button } from 'mastodon/components/button'; import { Button } from 'mastodon/components/button';
import { CopyLinkField } from 'mastodon/components/form_fields'; import { CopyLinkField } from 'mastodon/components/form_fields';
import { IconButton } from 'mastodon/components/icon_button'; import { IconButton } from 'mastodon/components/icon_button';
import { ModalShell } from 'mastodon/components/modal_shell'; import {
ModalShell,
ModalShellActions,
ModalShellBody,
} from 'mastodon/components/modal_shell';
import { useAppDispatch } from 'mastodon/store'; import { useAppDispatch } from 'mastodon/store';
import { AuthorNote } from '.'; import { AuthorNote } from '.';
@@ -64,7 +68,7 @@ export const CollectionShareModal: React.FC<{
return ( return (
<ModalShell> <ModalShell>
<ModalShell.Body> <ModalShellBody>
<h1 className={classes.heading}> <h1 className={classes.heading}>
{isNew ? ( {isNew ? (
<FormattedMessage <FormattedMessage
@@ -112,9 +116,9 @@ export const CollectionShareModal: React.FC<{
})} })}
value={collectionLink} value={collectionLink}
/> />
</ModalShell.Body> </ModalShellBody>
<ModalShell.Actions className={classes.actions}> <ModalShellActions className={classes.actions}>
<div className={classes.shareButtonWrapper}> <div className={classes.shareButtonWrapper}>
<Button secondary onClick={handleShareViaPost}> <Button secondary onClick={handleShareViaPost}>
<FormattedMessage <FormattedMessage
@@ -135,7 +139,7 @@ export const CollectionShareModal: React.FC<{
<Button plain onClick={onClose} className={classes.closeButtonMobile}> <Button plain onClick={onClose} className={classes.closeButtonMobile}>
<FormattedMessage id='lightbox.close' defaultMessage='Close' /> <FormattedMessage id='lightbox.close' defaultMessage='Close' />
</Button> </Button>
</ModalShell.Actions> </ModalShellActions>
</ModalShell> </ModalShell>
); );
}; };

View File

@@ -134,6 +134,10 @@ class KeyboardShortcuts extends ImmutablePureComponent {
<td><kbd>g</kbd>+<kbd>h</kbd></td> <td><kbd>g</kbd>+<kbd>h</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.home' defaultMessage='to open home timeline' /></td> <td><FormattedMessage id='keyboard_shortcuts.home' defaultMessage='to open home timeline' /></td>
</tr> </tr>
<tr>
<td><kbd>g</kbd>+<kbd>e</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.explore' defaultMessage='to open trending timeline' /></td>
</tr>
<tr> <tr>
<td><kbd>g</kbd>+<kbd>n</kbd></td> <td><kbd>g</kbd>+<kbd>n</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.notifications' defaultMessage='to open notifications column' /></td> <td><FormattedMessage id='keyboard_shortcuts.notifications' defaultMessage='to open notifications column' /></td>

View File

@@ -33,6 +33,7 @@ import { IconWithBadge } from 'mastodon/components/icon_with_badge';
import { WordmarkLogo } from 'mastodon/components/logo'; import { WordmarkLogo } from 'mastodon/components/logo';
import { Search } from 'mastodon/features/compose/components/search'; import { Search } from 'mastodon/features/compose/components/search';
import { ColumnLink } from 'mastodon/features/ui/components/column_link'; import { ColumnLink } from 'mastodon/features/ui/components/column_link';
import { getNavigationSkipLinkId } from 'mastodon/features/ui/components/skip_links';
import { useBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint'; import { useBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint';
import { useIdentity } from 'mastodon/identity_context'; import { useIdentity } from 'mastodon/identity_context';
import { import {
@@ -224,7 +225,11 @@ export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({
return ( return (
<div className='navigation-panel'> <div className='navigation-panel'>
<div className='navigation-panel__logo'> <div className='navigation-panel__logo'>
<Link to='/' className='column-link column-link--logo'> <Link
to='/'
className='column-link column-link--logo'
id={getNavigationSkipLinkId()}
>
<WordmarkLogo /> <WordmarkLogo />
</Link> </Link>
</div> </div>

View File

@@ -1,5 +1,5 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Children, cloneElement, useCallback } from 'react'; import { Children, cloneElement, createContext, useContext, useCallback } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
@@ -53,6 +53,13 @@ const TabsBarPortal = () => {
return <div id='tabs-bar__portal' ref={setRef} />; return <div id='tabs-bar__portal' ref={setRef} />;
}; };
// Simple context to allow column children to know which column they're in
export const ColumnIndexContext = createContext(1);
/**
* @returns {number}
*/
export const useColumnIndexContext = () => useContext(ColumnIndexContext);
export default class ColumnsArea extends ImmutablePureComponent { export default class ColumnsArea extends ImmutablePureComponent {
static propTypes = { static propTypes = {
columns: ImmutablePropTypes.list.isRequired, columns: ImmutablePropTypes.list.isRequired,
@@ -140,18 +147,22 @@ export default class ColumnsArea extends ImmutablePureComponent {
return ( return (
<div className={`columns-area ${ isModalOpen ? 'unscrollable' : '' }`} ref={this.setRef}> <div className={`columns-area ${ isModalOpen ? 'unscrollable' : '' }`} ref={this.setRef}>
{columns.map(column => { {columns.map((column, index) => {
const params = column.get('params', null) === null ? null : column.get('params').toJS(); const params = column.get('params', null) === null ? null : column.get('params').toJS();
const other = params && params.other ? params.other : {}; const other = params && params.other ? params.other : {};
return ( return (
<Bundle key={column.get('uuid')} fetchComponent={componentMap[column.get('id')]} loading={this.renderLoading(column.get('id'))} error={this.renderError}> <ColumnIndexContext.Provider value={index} key={column.get('uuid')}>
{SpecificComponent => <SpecificComponent columnId={column.get('uuid')} params={params} multiColumn {...other} />} <Bundle fetchComponent={componentMap[column.get('id')]} loading={this.renderLoading(column.get('id'))} error={this.renderError}>
</Bundle> {SpecificComponent => <SpecificComponent columnId={column.get('uuid')} params={params} multiColumn {...other} />}
</Bundle>
</ColumnIndexContext.Provider>
); );
})} })}
{Children.map(children, child => cloneElement(child, { multiColumn: true }))} <ColumnIndexContext.Provider value={columns.size}>
{Children.map(children, child => cloneElement(child, { multiColumn: true }))}
</ColumnIndexContext.Provider>
</div> </div>
); );
} }

View File

@@ -3,7 +3,11 @@ import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { Button } from 'mastodon/components/button'; import { Button } from 'mastodon/components/button';
import { ModalShell } from 'mastodon/components/modal_shell'; import {
ModalShell,
ModalShellActions,
ModalShellBody,
} from 'mastodon/components/modal_shell';
export interface BaseConfirmationModalProps { export interface BaseConfirmationModalProps {
onClose: () => void; onClose: () => void;
@@ -58,14 +62,14 @@ export const ConfirmationModal: React.FC<
return ( return (
<ModalShell> <ModalShell>
<ModalShell.Body> <ModalShellBody>
<h1 id={titleId}>{title}</h1> <h1 id={titleId}>{title}</h1>
{message && <p>{message}</p>} {message && <p>{message}</p>}
{extraContent ?? children} {extraContent ?? children}
</ModalShell.Body> </ModalShellBody>
<ModalShell.Actions> <ModalShellActions>
<button onClick={onClose} className='link-button' type='button'> <button onClick={onClose} className='link-button' type='button'>
{cancel ?? ( {cancel ?? (
<FormattedMessage <FormattedMessage
@@ -99,7 +103,7 @@ export const ConfirmationModal: React.FC<
{confirm} {confirm}
</Button> </Button>
{/* eslint-enable */} {/* eslint-enable */}
</ModalShell.Actions> </ModalShellActions>
</ModalShell> </ModalShell>
); );
}; };

View File

@@ -11,7 +11,6 @@ import {
DomainBlockModal, DomainBlockModal,
ReportModal, ReportModal,
ReportCollectionModal, ReportCollectionModal,
ShareCollectionModal,
EmbedModal, EmbedModal,
ListAdder, ListAdder,
CompareHistoryModal, CompareHistoryModal,
@@ -80,7 +79,7 @@ export const MODAL_COMPONENTS = {
'DOMAIN_BLOCK': DomainBlockModal, 'DOMAIN_BLOCK': DomainBlockModal,
'REPORT': ReportModal, 'REPORT': ReportModal,
'REPORT_COLLECTION': ReportCollectionModal, 'REPORT_COLLECTION': ReportCollectionModal,
'SHARE_COLLECTION': ShareCollectionModal, 'SHARE_COLLECTION': () => import('@/mastodon/features/collections/detail/share_modal').then(module => ({ default: module.CollectionShareModal })),
'ACTIONS': () => Promise.resolve({ default: ActionsModal }), 'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
'EMBED': EmbedModal, 'EMBED': EmbedModal,
'FOCAL_POINT': () => Promise.resolve({ default: AltTextModal }), 'FOCAL_POINT': () => Promise.resolve({ default: AltTextModal }),

View File

@@ -0,0 +1,84 @@
import { useCallback, useId } from 'react';
import { useIntl } from 'react-intl';
import { useAppSelector } from 'mastodon/store';
import classes from './skip_links.module.scss';
export const getNavigationSkipLinkId = () => 'skip-link-target-nav';
export const getColumnSkipLinkId = (index: number) =>
`skip-link-target-content-${index}`;
export const SkipLinks: React.FC<{
multiColumn: boolean;
onFocusGettingStartedColumn: () => void;
}> = ({ multiColumn, onFocusGettingStartedColumn }) => {
const intl = useIntl();
const columnCount = useAppSelector((state) => {
const settings = state.settings as Immutable.Collection<string, unknown>;
return (settings.get('columns') as Immutable.Map<number, unknown>).size;
});
const focusMultiColumnNavbar = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
onFocusGettingStartedColumn();
},
[onFocusGettingStartedColumn],
);
return (
<ul className={classes.list}>
<li className={classes.listItem}>
<SkipLink target={getColumnSkipLinkId(1)} hotkey='1'>
{intl.formatMessage({
id: 'skip_links.skip_to_content',
defaultMessage: 'Skip to main content',
})}
</SkipLink>
</li>
<li className={classes.listItem}>
<SkipLink
target={multiColumn ? `/getting-started` : getNavigationSkipLinkId()}
onRouterLinkClick={multiColumn ? focusMultiColumnNavbar : undefined}
hotkey={multiColumn ? `${columnCount}` : '2'}
>
{intl.formatMessage({
id: 'skip_links.skip_to_navigation',
defaultMessage: 'Skip to main navigation',
})}
</SkipLink>
</li>
</ul>
);
};
const SkipLink: React.FC<{
children: string;
target: string;
onRouterLinkClick?: React.MouseEventHandler;
hotkey: string;
}> = ({ children, hotkey, target, onRouterLinkClick }) => {
const intl = useIntl();
const id = useId();
return (
<>
<a href={`#${target}`} aria-describedby={id} onClick={onRouterLinkClick}>
{children}
</a>
<span id={id} className={classes.hotkeyHint}>
{intl.formatMessage(
{
id: 'skip_links.hotkey',
defaultMessage: '<span>Hotkey</span> {hotkey}',
},
{
hotkey,
span: (text) => <span className='sr-only'>{text}</span>,
},
)}
</span>
</>
);
};

View File

@@ -0,0 +1,64 @@
.list {
position: fixed;
z-index: 100;
margin: 10px;
padding: 10px 16px;
border-radius: 10px;
font-size: 15px;
color: var(--color-text-primary);
background: var(--color-bg-primary);
box-shadow: var(--dropdown-shadow);
/* Hide visually when not focused */
&:not(:focus-within) {
width: 1px;
height: 1px;
margin: 0;
padding: 0;
clip-path: inset(50%);
overflow: hidden;
}
}
.listItem {
display: flex;
align-items: center;
gap: 10px;
padding-inline-end: 4px;
border-radius: 4px;
&:not(:first-child) {
margin-top: 2px;
}
&:focus-within {
outline: var(--outline-focus-default);
background: var(--color-bg-brand-softer);
}
:any-link {
display: block;
padding: 8px;
color: inherit;
text-decoration-color: var(--color-text-secondary);
text-underline-offset: 0.2em;
&:focus,
&:focus-visible {
outline: none;
}
}
}
.hotkeyHint {
display: inline-block;
box-sizing: border-box;
min-width: 2.5ch;
margin-inline-start: auto;
padding: 3px 5px;
font-family: 'Courier New', Courier, monospace;
text-align: center;
background: var(--color-bg-primary);
border: 1px solid var(--color-border-primary);
border-radius: 4px;
}

View File

@@ -92,6 +92,7 @@ import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
// Without this it ends up in ~8 very commonly used bundles. // Without this it ends up in ~8 very commonly used bundles.
import '../../components/status'; import '../../components/status';
import { areCollectionsEnabled } from '../collections/utils'; import { areCollectionsEnabled } from '../collections/utils';
import { getNavigationSkipLinkId, SkipLinks } from './components/skip_links';
const messages = defineMessages({ const messages = defineMessages({
beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' }, beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' },
@@ -253,9 +254,9 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/lists' component={Lists} content={children} /> <WrappedRoute path='/lists' component={Lists} content={children} />
{areCollectionsEnabled() && {areCollectionsEnabled() &&
[ [
<WrappedRoute path={['/collections/new', '/collections/:id/edit']} component={CollectionsEditor} content={children} />, <WrappedRoute path={['/collections/new', '/collections/:id/edit']} component={CollectionsEditor} content={children} key='collections-editor' />,
<WrappedRoute path='/collections/:id' component={CollectionDetail} content={children} />, <WrappedRoute path='/collections/:id' component={CollectionDetail} content={children} key='collections-detail' />,
<WrappedRoute path='/collections' component={Collections} content={children} /> <WrappedRoute path='/collections' component={Collections} content={children} key='collections-list' />
] ]
} }
<Route component={BundleColumnError} /> <Route component={BundleColumnError} />
@@ -534,6 +535,10 @@ class UI extends PureComponent {
this.props.history.push('/home'); this.props.history.push('/home');
}; };
handleHotkeyGoToExplore = () => {
this.props.history.push('/explore');
};
handleHotkeyGoToNotifications = () => { handleHotkeyGoToNotifications = () => {
this.props.history.push('/notifications'); this.props.history.push('/notifications');
}; };
@@ -552,6 +557,14 @@ class UI extends PureComponent {
handleHotkeyGoToStart = () => { handleHotkeyGoToStart = () => {
this.props.history.push('/getting-started'); this.props.history.push('/getting-started');
// Set focus to the navigation after a timeout
// to allow for it to be displayed first
setTimeout(() => {
const navbarSkipTarget = document.querySelector(
`#${getNavigationSkipLinkId()}`,
);
navbarSkipTarget?.focus();
}, 0);
}; };
handleHotkeyGoToFavourites = () => { handleHotkeyGoToFavourites = () => {
@@ -595,6 +608,7 @@ class UI extends PureComponent {
moveToTop: this.handleMoveToTop, moveToTop: this.handleMoveToTop,
back: this.handleHotkeyBack, back: this.handleHotkeyBack,
goToHome: this.handleHotkeyGoToHome, goToHome: this.handleHotkeyGoToHome,
goToExplore: this.handleHotkeyGoToExplore,
goToNotifications: this.handleHotkeyGoToNotifications, goToNotifications: this.handleHotkeyGoToNotifications,
goToLocal: this.handleHotkeyGoToLocal, goToLocal: this.handleHotkeyGoToLocal,
goToFederated: this.handleHotkeyGoToFederated, goToFederated: this.handleHotkeyGoToFederated,
@@ -612,6 +626,10 @@ class UI extends PureComponent {
return ( return (
<Hotkeys global handlers={handlers}> <Hotkeys global handlers={handlers}>
<div className={classNames('ui', { 'is-composing': isComposing })} ref={this.setRef}> <div className={classNames('ui', { 'is-composing': isComposing })} ref={this.setRef}>
<SkipLinks
multiColumn={layout === 'multi-column'}
onFocusGettingStartedColumn={this.handleHotkeyGoToStart}
/>
<SwitchingColumnsArea <SwitchingColumnsArea
identity={this.props.identity} identity={this.props.identity}
location={location} location={location}

View File

@@ -62,12 +62,6 @@ export function CollectionsEditor() {
); );
} }
export function ShareCollectionModal() {
return import('../../collections/detail/share_modal').then(
module => ({default: module.CollectionShareModal})
);
}
export function Status () { export function Status () {
return import('../../status'); return import('../../status');
} }

View File

@@ -1,3 +1,8 @@
import {
getColumnSkipLinkId,
getNavigationSkipLinkId,
} from '../components/skip_links';
/** /**
* Out of a list of elements, return the first one whose top edge * Out of a list of elements, return the first one whose top edge
* is inside of the viewport, and return the element and its BoundingClientRect. * is inside of the viewport, and return the element and its BoundingClientRect.
@@ -20,9 +25,29 @@ function findFirstVisibleWithRect(
return null; return null;
} }
function focusColumnTitle(index: number, multiColumn: boolean) {
if (multiColumn) {
const column = document.querySelector(`.column:nth-child(${index})`);
if (column) {
column
.querySelector<HTMLAnchorElement>(
`#${getColumnSkipLinkId(index - 1)}, #${getNavigationSkipLinkId()}`,
)
?.focus();
}
} else {
const idSelector =
index === 2
? `#${getNavigationSkipLinkId()}`
: `#${getColumnSkipLinkId(1)}`;
document.querySelector<HTMLAnchorElement>(idSelector)?.focus();
}
}
/** /**
* Move focus to the column of the passed index (1-based). * Move focus to the column of the passed index (1-based).
* Focus is placed on the topmost visible item * Focus is placed on the topmost visible item, or the column title
*/ */
export function focusColumn(index = 1) { export function focusColumn(index = 1) {
// Skip the leftmost drawer in multi-column mode // Skip the leftmost drawer in multi-column mode
@@ -35,11 +60,21 @@ export function focusColumn(index = 1) {
`.column:nth-child(${index + indexOffset})`, `.column:nth-child(${index + indexOffset})`,
); );
if (!column) return; function fallback() {
focusColumnTitle(index + indexOffset, isMultiColumnLayout);
}
if (!column) {
fallback();
return;
}
const container = column.querySelector('.scrollable'); const container = column.querySelector('.scrollable');
if (!container) return; if (!container) {
fallback();
return;
}
const focusableItems = Array.from( const focusableItems = Array.from(
container.querySelectorAll<HTMLElement>( container.querySelectorAll<HTMLElement>(
@@ -50,20 +85,23 @@ export function focusColumn(index = 1) {
// Find first item visible in the viewport // Find first item visible in the viewport
const itemToFocus = findFirstVisibleWithRect(focusableItems); const itemToFocus = findFirstVisibleWithRect(focusableItems);
if (itemToFocus) { if (!itemToFocus) {
const viewportWidth = fallback();
window.innerWidth || document.documentElement.clientWidth; return;
const { item, rect } = itemToFocus;
if (
container.scrollTop > item.offsetTop ||
rect.right > viewportWidth ||
rect.left < 0
) {
itemToFocus.item.scrollIntoView(true);
}
itemToFocus.item.focus();
} }
const viewportWidth =
window.innerWidth || document.documentElement.clientWidth;
const { item, rect } = itemToFocus;
if (
container.scrollTop > item.offsetTop ||
rect.right > viewportWidth ||
rect.left < 0
) {
itemToFocus.item.scrollIntoView(true);
}
itemToFocus.item.focus();
} }
/** /**

View File

@@ -173,6 +173,7 @@
"account_edit.profile_tab.subtitle": "Customize the tabs on your profile and what they display.", "account_edit.profile_tab.subtitle": "Customize the tabs on your profile and what they display.",
"account_edit.profile_tab.title": "Profile tab settings", "account_edit.profile_tab.title": "Profile tab settings",
"account_edit.save": "Save", "account_edit.save": "Save",
"account_edit_tags.add_tag": "Add #{tagName}",
"account_edit_tags.column_title": "Edit featured hashtags", "account_edit_tags.column_title": "Edit featured hashtags",
"account_edit_tags.help_text": "Featured hashtags help users discover and interact with your profile. They appear as filters on your Profile pages Activity view.", "account_edit_tags.help_text": "Featured hashtags help users discover and interact with your profile. They appear as filters on your Profile pages Activity view.",
"account_edit_tags.search_placeholder": "Enter a hashtag…", "account_edit_tags.search_placeholder": "Enter a hashtag…",
@@ -682,6 +683,7 @@
"keyboard_shortcuts.direct": "Open private mentions column", "keyboard_shortcuts.direct": "Open private mentions column",
"keyboard_shortcuts.down": "Move down in the list", "keyboard_shortcuts.down": "Move down in the list",
"keyboard_shortcuts.enter": "Open post", "keyboard_shortcuts.enter": "Open post",
"keyboard_shortcuts.explore": "Open trending timeline",
"keyboard_shortcuts.favourite": "Favorite post", "keyboard_shortcuts.favourite": "Favorite post",
"keyboard_shortcuts.favourites": "Open favorites list", "keyboard_shortcuts.favourites": "Open favorites list",
"keyboard_shortcuts.federated": "Open federated timeline", "keyboard_shortcuts.federated": "Open federated timeline",
@@ -1071,6 +1073,9 @@
"sign_in_banner.mastodon_is": "Mastodon is the best way to keep up with what's happening.", "sign_in_banner.mastodon_is": "Mastodon is the best way to keep up with what's happening.",
"sign_in_banner.sign_in": "Login", "sign_in_banner.sign_in": "Login",
"sign_in_banner.sso_redirect": "Login or Register", "sign_in_banner.sso_redirect": "Login or Register",
"skip_links.hotkey": "<span>Hotkey</span> {hotkey}",
"skip_links.skip_to_content": "Skip to main content",
"skip_links.skip_to_navigation": "Skip to main navigation",
"status.admin_account": "Open moderation interface for @{name}", "status.admin_account": "Open moderation interface for @{name}",
"status.admin_domain": "Open moderation interface for {domain}", "status.admin_domain": "Open moderation interface for {domain}",
"status.admin_status": "Open this post in the moderation interface", "status.admin_status": "Open this post in the moderation interface",

View File

@@ -16,8 +16,10 @@ import type {
ApiProfileJSON, ApiProfileJSON,
ApiProfileUpdateParams, ApiProfileUpdateParams,
} from '@/mastodon/api_types/profile'; } from '@/mastodon/api_types/profile';
import { hashtagToFeaturedTag } from '@/mastodon/api_types/tags'; import type {
import type { ApiFeaturedTagJSON } from '@/mastodon/api_types/tags'; ApiFeaturedTagJSON,
ApiHashtagJSON,
} from '@/mastodon/api_types/tags';
import type { AppDispatch } from '@/mastodon/store'; import type { AppDispatch } from '@/mastodon/store';
import { import {
createAppAsyncThunk, createAppAsyncThunk,
@@ -28,21 +30,30 @@ import type { SnakeToCamelCase } from '@/mastodon/utils/types';
type ProfileData = { type ProfileData = {
[Key in keyof Omit< [Key in keyof Omit<
ApiProfileJSON, ApiProfileJSON,
'note' 'note' | 'featured_tags'
> as SnakeToCamelCase<Key>]: ApiProfileJSON[Key]; > as SnakeToCamelCase<Key>]: ApiProfileJSON[Key];
} & { } & {
bio: ApiProfileJSON['note']; bio: ApiProfileJSON['note'];
featuredTags: TagData[];
};
export type TagData = {
[Key in keyof Omit<
ApiFeaturedTagJSON,
'statuses_count'
> as SnakeToCamelCase<Key>]: ApiFeaturedTagJSON[Key];
} & {
statusesCount: number;
}; };
export interface ProfileEditState { export interface ProfileEditState {
profile?: ProfileData; profile?: ProfileData;
tags?: ApiFeaturedTagJSON[]; tagSuggestions?: ApiHashtagJSON[];
tagSuggestions?: ApiFeaturedTagJSON[];
isPending: boolean; isPending: boolean;
search: { search: {
query: string; query: string;
isLoading: boolean; isLoading: boolean;
results?: ApiFeaturedTagJSON[]; results?: ApiHashtagJSON[];
}; };
} }
@@ -64,7 +75,7 @@ const profileEditSlice = createSlice({
} }
state.search.query = action.payload; state.search.query = action.payload;
state.search.isLoading = false; state.search.isLoading = true;
state.search.results = undefined; state.search.results = undefined;
}, },
clearSearch(state) { clearSearch(state) {
@@ -78,10 +89,7 @@ const profileEditSlice = createSlice({
state.profile = action.payload; state.profile = action.payload;
}); });
builder.addCase(fetchSuggestedTags.fulfilled, (state, action) => { builder.addCase(fetchSuggestedTags.fulfilled, (state, action) => {
state.tagSuggestions = action.payload.map(hashtagToFeaturedTag); state.tagSuggestions = action.payload;
});
builder.addCase(fetchFeaturedTags.fulfilled, (state, action) => {
state.tags = action.payload;
}); });
builder.addCase(patchProfile.pending, (state) => { builder.addCase(patchProfile.pending, (state) => {
@@ -102,13 +110,14 @@ const profileEditSlice = createSlice({
state.isPending = false; state.isPending = false;
}); });
builder.addCase(addFeaturedTag.fulfilled, (state, action) => { builder.addCase(addFeaturedTag.fulfilled, (state, action) => {
if (!state.tags) { if (!state.profile) {
return; return;
} }
state.tags = [...state.tags, action.payload].toSorted( state.profile.featuredTags = [
(a, b) => b.statuses_count - a.statuses_count, ...state.profile.featuredTags,
); transformTag(action.payload),
].toSorted((a, b) => a.name.localeCompare(b.name));
if (state.tagSuggestions) { if (state.tagSuggestions) {
state.tagSuggestions = state.tagSuggestions.filter( state.tagSuggestions = state.tagSuggestions.filter(
(tag) => tag.name !== action.meta.arg.name, (tag) => tag.name !== action.meta.arg.name,
@@ -124,11 +133,13 @@ const profileEditSlice = createSlice({
state.isPending = false; state.isPending = false;
}); });
builder.addCase(deleteFeaturedTag.fulfilled, (state, action) => { builder.addCase(deleteFeaturedTag.fulfilled, (state, action) => {
if (!state.tags) { if (!state.profile) {
return; return;
} }
state.tags = state.tags.filter((tag) => tag.id !== action.meta.arg.tagId); state.profile.featuredTags = state.profile.featuredTags.filter(
(tag) => tag.id !== action.meta.arg.tagId,
);
state.isPending = false; state.isPending = false;
}); });
@@ -141,14 +152,16 @@ const profileEditSlice = createSlice({
}); });
builder.addCase(fetchSearchResults.fulfilled, (state, action) => { builder.addCase(fetchSearchResults.fulfilled, (state, action) => {
state.search.isLoading = false; state.search.isLoading = false;
const searchResults: ApiFeaturedTagJSON[] = []; const searchResults: ApiHashtagJSON[] = [];
const currentTags = new Set((state.tags ?? []).map((tag) => tag.name)); const currentTags = new Set(
(state.profile?.featuredTags ?? []).map((tag) => tag.name),
);
for (const tag of action.payload) { for (const tag of action.payload) {
if (currentTags.has(tag.name)) { if (currentTags.has(tag.name)) {
continue; continue;
} }
searchResults.push(hashtagToFeaturedTag(tag)); searchResults.push(tag);
if (searchResults.length >= 10) { if (searchResults.length >= 10) {
break; break;
} }
@@ -161,6 +174,14 @@ const profileEditSlice = createSlice({
export const profileEdit = profileEditSlice.reducer; export const profileEdit = profileEditSlice.reducer;
export const { clearSearch } = profileEditSlice.actions; export const { clearSearch } = profileEditSlice.actions;
const transformTag = (result: ApiFeaturedTagJSON): TagData => ({
id: result.id,
name: result.name,
url: result.url,
statusesCount: Number.parseInt(result.statuses_count),
lastStatusAt: result.last_status_at,
});
const transformProfile = (result: ApiProfileJSON): ProfileData => ({ const transformProfile = (result: ApiProfileJSON): ProfileData => ({
id: result.id, id: result.id,
displayName: result.display_name, displayName: result.display_name,
@@ -181,6 +202,7 @@ const transformProfile = (result: ApiProfileJSON): ProfileData => ({
showMediaReplies: result.show_media_replies, showMediaReplies: result.show_media_replies,
showFeatured: result.show_featured, showFeatured: result.show_featured,
attributionDomains: result.attribution_domains, attributionDomains: result.attribution_domains,
featuredTags: result.featured_tags.map(transformTag),
}); });
export const fetchProfile = createDataLoadingThunk( export const fetchProfile = createDataLoadingThunk(
@@ -215,8 +237,10 @@ export const addFeaturedTag = createDataLoadingThunk(
condition(arg, { getState }) { condition(arg, { getState }) {
const state = getState(); const state = getState();
return ( return (
!!state.profileEdit.tags && !!state.profileEdit.profile &&
!state.profileEdit.tags.some((tag) => tag.name === arg.name) !state.profileEdit.profile.featuredTags.some(
(tag) => tag.name === arg.name,
)
); );
}, },
}, },

View File

@@ -385,6 +385,7 @@ $content-width: 840px;
color: var(--color-text-secondary); color: var(--color-text-secondary);
padding-top: 24px; padding-top: 24px;
margin-bottom: 8px; margin-bottom: 8px;
cursor: pointer;
} }
@media screen and (max-width: $no-columns-breakpoint) { @media screen and (max-width: $no-columns-breakpoint) {

View File

@@ -3903,6 +3903,11 @@ a.account__display-name {
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
} }
&:focus-visible {
outline: var(--outline-focus-default);
outline-offset: -2px;
}
} }
.column-header__back-button { .column-header__back-button {
@@ -4036,15 +4041,17 @@ a.account__display-name {
} }
.column-link { .column-link {
box-sizing: border-box;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
width: 100%; width: 100%;
padding: 12px; padding: 10px;
padding-inline-start: 14px;
overflow: hidden;
font-size: 16px; font-size: 16px;
font-weight: 400; font-weight: 400;
text-decoration: none; text-decoration: none;
overflow: hidden;
white-space: nowrap; white-space: nowrap;
color: color-mix( color: color-mix(
in oklab, in oklab,
@@ -4052,9 +4059,8 @@ a.account__display-name {
var(--color-text-secondary) var(--color-text-secondary)
); );
background: transparent; background: transparent;
border: 0; border: 2px solid transparent;
border-left: 4px solid transparent; border-radius: 4px;
box-sizing: border-box;
&:hover, &:hover,
&:active, &:active,
@@ -4066,17 +4072,15 @@ a.account__display-name {
color: var(--color-text-brand); color: var(--color-text-brand);
} }
&:focus {
outline: 0;
}
&:focus-visible { &:focus-visible {
outline: none;
border-color: var(--color-text-brand); border-color: var(--color-text-brand);
border-radius: 0; background: var(--color-bg-brand-softer);
} }
&--logo { &--logo {
padding: 10px; padding: 8px;
padding-inline-start: 12px;
} }
} }
@@ -6408,6 +6412,10 @@ a.status-card {
line-height: 20px; line-height: 20px;
color: var(--color-text-secondary); color: var(--color-text-secondary);
/*
* Using the :where selector to lower specificity and allow for
* easy customisation of modal heading styles
*/
:where(h1) { :where(h1) {
font-size: 16px; font-size: 16px;
line-height: 24px; line-height: 24px;

View File

@@ -0,0 +1,13 @@
# frozen_string_literal: true
class CollectionItemPolicy < ApplicationPolicy
def revoke?
featured_account.present? && current_account == featured_account
end
private
def featured_account
record.account
end
end

View File

@@ -18,5 +18,12 @@
%hr.spacer/ %hr.spacer/
.content__heading__actions .content__heading__actions
= link_to t('admin.terms_of_service.preview.send_preview', email: current_user.email), admin_announcement_test_path(@announcement), method: :post, class: 'button button-secondary' = link_to t('admin.terms_of_service.preview.send_preview', email: current_user.email),
= link_to t('admin.terms_of_service.preview.send_to_all', count: @user_count, display_count: number_with_delimiter(@user_count)), admin_announcement_distribution_path(@announcement), method: :post, class: 'button', data: { confirm: t('admin.reports.are_you_sure') } admin_announcement_test_path(@announcement),
class: 'button button-secondary',
method: :post
= link_to t('admin.terms_of_service.preview.send_to_all', count: @user_count, display_count: number_with_delimiter(@user_count)),
admin_announcement_distribution_path(@announcement),
class: 'button',
data: { confirm: t('admin.reports.are_you_sure') },
method: :post

View File

@@ -16,5 +16,12 @@
%hr.spacer/ %hr.spacer/
.content__heading__actions .content__heading__actions
= link_to t('admin.terms_of_service.preview.send_preview', email: current_user.email), admin_terms_of_service_test_path(@terms_of_service), method: :post, class: 'button button-secondary' = link_to t('admin.terms_of_service.preview.send_preview', email: current_user.email),
= link_to t('admin.terms_of_service.preview.send_to_all', count: @user_count, display_count: number_with_delimiter(@user_count)), admin_terms_of_service_distribution_path(@terms_of_service), method: :post, class: 'button', data: { confirm: t('admin.reports.are_you_sure') } admin_terms_of_service_test_path(@terms_of_service),
class: 'button button-secondary',
method: :post
= link_to t('admin.terms_of_service.preview.send_to_all', count: @user_count, display_count: number_with_delimiter(@user_count)),
admin_terms_of_service_distribution_path(@terms_of_service),
class: 'button',
data: { confirm: t('admin.reports.are_you_sure') },
method: :post

View File

@@ -79,7 +79,7 @@
.fields-group .fields-group
= f.input :agreement, = f.input :agreement,
as: :boolean, as: :boolean,
label: TermsOfService.live.exists? ? t('auth.user_agreement_html', privacy_policy_path: privacy_policy_path, terms_of_service_path: terms_of_service_path) : t('auth.user_privacy_agreement_html', privacy_policy_path: privacy_policy_path), label: terms_agreement_label,
required: false, required: false,
wrapper: :with_label wrapper: :with_label

View File

@@ -85,9 +85,15 @@
%h4= t 'appearance.boosting_preferences' %h4= t 'appearance.boosting_preferences'
.fields-group .fields-group
= ff.input :'web.reblog_modal', wrapper: :with_label, hint: I18n.t('simple_form.hints.defaults.setting_boost_modal'), label: I18n.t('simple_form.labels.defaults.setting_boost_modal') = ff.input :'web.reblog_modal',
hint: I18n.t('simple_form.hints.defaults.setting_boost_modal'),
label: I18n.t('simple_form.labels.defaults.setting_boost_modal'),
wrapper: :with_label
.fields-group .fields-group
= ff.input :'web.quick_boosting', wrapper: :with_label, hint: t('simple_form.hints.defaults.setting_quick_boosting_html', boost_icon: material_symbol('repeat'), options_icon: material_symbol('more_horiz')), label: I18n.t('simple_form.labels.defaults.setting_quick_boosting') = ff.input :'web.quick_boosting',
hint: t('simple_form.hints.defaults.setting_quick_boosting_html', boost_icon: material_symbol('repeat'), options_icon: material_symbol('more_horiz')),
label: I18n.t('simple_form.labels.defaults.setting_quick_boosting'),
wrapper: :with_label
.flash-message.hidden-on-touch-devices= t('appearance.boosting_preferences_info_html', icon: material_symbol('repeat')) .flash-message.hidden-on-touch-devices= t('appearance.boosting_preferences_info_html', icon: material_symbol('repeat'))
%h4= t 'appearance.sensitive_content' %h4= t 'appearance.sensitive_content'

View File

@@ -51,7 +51,7 @@
%strong.status-card__title= t('author_attribution.example_title') %strong.status-card__title= t('author_attribution.example_title')
.more-from-author .more-from-author
= logo_as_symbol(:icon) = logo_as_symbol(:icon)
= t('author_attribution.more_from_html', name: link_to(root_url, class: 'story__details__shared__author-link') { image_tag(@account.avatar.url, class: 'account__avatar', width: 16, height: 16, alt: '') + tag.bdi(display_name(@account)) }) = t('author_attribution.more_from_html', name: author_attribution_name(@account))
%h4= t('verification.here_is_how') %h4= t('verification.here_is_how')
@@ -65,7 +65,10 @@
%p.lead= t('author_attribution.then_instructions') %p.lead= t('author_attribution.then_instructions')
.fields-group .fields-group
= f.input :attribution_domains, as: :text, wrapper: :with_block_label, input_html: { value: @account.attribution_domains.join("\n"), placeholder: "example1.com\nexample2.com\nexample3.com", rows: 4, autocapitalize: 'none', autocorrect: 'off' } = f.input :attribution_domains,
as: :text,
input_html: { value: @account.attribution_domains.join("\n"), placeholder: "example1.com\nexample2.com\nexample3.com", rows: 4, autocapitalize: 'none', autocorrect: 'off' },
wrapper: :with_block_label
.actions .actions
= f.button :button, t('generic.save_changes'), type: :submit = f.button :button, t('generic.save_changes'), type: :submit

View File

@@ -10,7 +10,9 @@
%td.email-inner-card-td.email-prose %td.email-inner-card-td.email-prose
%p= t @resource.approved? ? 'devise.mailer.confirmation_instructions.explanation' : 'devise.mailer.confirmation_instructions.explanation_when_pending', host: site_hostname %p= t @resource.approved? ? 'devise.mailer.confirmation_instructions.explanation' : 'devise.mailer.confirmation_instructions.explanation_when_pending', host: site_hostname
- if @resource.created_by_application - if @resource.created_by_application
= render 'application/mailer/button', text: t('devise.mailer.confirmation_instructions.action_with_app', app: @resource.created_by_application.name), url: confirmation_url(@resource, confirmation_token: @token, redirect_to_app: 'true') = render 'application/mailer/button',
text: t('devise.mailer.confirmation_instructions.action_with_app', app: @resource.created_by_application.name),
url: confirmation_url(@resource, confirmation_token: @token, redirect_to_app: 'true')
- else - else
= render 'application/mailer/button', text: t('devise.mailer.confirmation_instructions.action'), url: confirmation_url(@resource, confirmation_token: @token) = render 'application/mailer/button', text: t('devise.mailer.confirmation_instructions.action'), url: confirmation_url(@resource, confirmation_token: @token)
%p= t 'devise.mailer.confirmation_instructions.extra_html', terms_path: about_more_url, policy_path: privacy_policy_url %p= t 'devise.mailer.confirmation_instructions.extra_html', terms_path: about_more_url, policy_path: privacy_policy_url

View File

@@ -13,7 +13,11 @@ namespace :api, format: false do
resources :async_refreshes, only: :show resources :async_refreshes, only: :show
resources :collections, only: [:show, :create, :update, :destroy] do resources :collections, only: [:show, :create, :update, :destroy] do
resources :items, only: [:create, :destroy], controller: 'collection_items' resources :items, only: [:create, :destroy], controller: 'collection_items' do
member do
post :revoke
end
end
end end
end end

View File

@@ -0,0 +1,23 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe CollectionItemPolicy do
subject { described_class }
let(:account) { Fabricate(:account) }
permissions :revoke? do
context 'when collection item features the revoking account' do
let(:collection_item) { Fabricate.build(:collection_item, account:) }
it { is_expected.to permit(account, collection_item) }
end
context 'when collection item does not feature the revoking account' do
let(:collection_item) { Fabricate.build(:collection_item) }
it { is_expected.to_not permit(account, collection_item) }
end
end
end

View File

@@ -102,4 +102,35 @@ RSpec.describe 'Api::V1Alpha::CollectionItems', feature: :collections do
end end
end end
end end
describe 'POST /api/v1_alpha/collections/:collection_id/items/:id/revoke' do
subject do
post "/api/v1_alpha/collections/#{collection.id}/items/#{item.id}/revoke", headers: headers
end
let(:collection) { Fabricate(:collection) }
let(:item) { Fabricate(:collection_item, collection:, account: user.account) }
it_behaves_like 'forbidden for wrong scope', 'read'
context 'when user is in item' do
it 'revokes the collection item and returns http success' do
subject
expect(item.reload).to be_revoked
expect(response).to have_http_status(200)
end
end
context 'when user is not in the item' do
let(:item) { Fabricate(:collection_item, collection:) }
it 'returns http forbidden' do
subject
expect(response).to have_http_status(403)
end
end
end
end end