mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-29 03:00:33 +02:00
Merge commit '74b3b6c798d1f137947e80df8eefb7412e70febd' into glitch-soc/merge-upstream
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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<
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -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 page’s Activity view.'
|
defaultMessage='Featured hashtags help users discover and interact with your profile. They appear as filters on your Profile page’s 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'
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 page’s Activity view.",
|
"account_edit_tags.help_text": "Featured hashtags help users discover and interact with your profile. They appear as filters on your Profile page’s Activity view.",
|
||||||
"account_edit_tags.search_placeholder": "Enter a hashtag…",
|
"account_edit_tags.search_placeholder": "Enter a hashtag…",
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
13
app/policies/collection_item_policy.rb
Normal file
13
app/policies/collection_item_policy.rb
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
23
spec/policies/collection_item_policy_spec.rb
Normal file
23
spec/policies/collection_item_policy_spec.rb
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user