Redesign: Update followed hashtag column header (#40384)

This commit is contained in:
diondiondion
2026-09-08 07:51:24 +00:00
committed by GitHub
parent 1efe0d87c3
commit b33ed232b8
9 changed files with 299 additions and 90 deletions

View File

@@ -12,6 +12,7 @@ import { Toggle } from '@/mastodon/components/form_fields/toggle_field';
import { injectIntl } from '@/mastodon/components/intl';
import SettingToggle from '../../notifications/components/setting_toggle';
import { isRedesignEnabled } from '@/mastodon/utils/environment';
const messages = defineMessages({
placeholder: { id: 'hashtag.column_settings.select.placeholder', defaultMessage: 'Enter hashtags…' },
@@ -108,6 +109,16 @@ class ColumnSettings extends PureComponent {
render () {
const { settings, onChange } = this.props;
if (isRedesignEnabled()) {
return (
<div className='column-settings column-settings__hashtags'>
{this.modeSelect('any')}
{this.modeSelect('all')}
{this.modeSelect('none')}
</div>
)
}
return (
<div className='column-settings'>
<section>
@@ -115,7 +126,7 @@ class ColumnSettings extends PureComponent {
<SettingToggle settings={settings} settingPath={['local']} onChange={onChange} label={<FormattedMessage id='community.column_settings.local_only' defaultMessage='Local only' />} />
<div className='setting-toggle'>
<Toggle id='hashtag.column_settings.tag_toggle' onChange={this.onToggle} checked={this.state.open} />
<Toggle id='hashtag.column_settings.tag_toggle' onChange={this.onToggle} checked={this.state.open} size={16} />
<span className='setting-toggle__label'>
<FormattedMessage id='hashtag.column_settings.tag_toggle' defaultMessage='Include additional tags in this column' />

View File

@@ -0,0 +1,48 @@
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { closeModal } from '@/mastodon/actions/modal';
import { Button } from '@/mastodon/components/button/redesign';
import {
ModalActions,
ModalShell,
ModalTitle,
} from '@/mastodon/components/modal_shell/redesign';
import { useAppDispatch } from '@/mastodon/store';
import ColumnSettingsContainer from '../containers/column_settings_container';
const HashtagSettingsModal: React.FC<{ columnId: string; tagId: string }> = ({
columnId,
tagId,
}) => {
const dispatch = useAppDispatch();
const handleCloseModal = useCallback(() => {
void dispatch(
closeModal({ modalType: 'HASHTAG_SETTINGS', ignoreFocus: false }),
);
}, [dispatch]);
return (
<ModalShell>
<ModalTitle onClose={handleCloseModal}>
<FormattedMessage
id='hashtags.add_more_tags_to_column'
defaultMessage='Add more tags to #{tag}'
values={{ tag: tagId }}
/>
</ModalTitle>
<ColumnSettingsContainer columnId={columnId} />
<ModalActions>
<Button variant='solid' onClick={handleCloseModal}>
<FormattedMessage id='alt_text_modal.done' defaultMessage='Done' />
</Button>
</ModalActions>
</ModalShell>
);
};
// eslint-disable-next-line import/no-default-export
export default HashtagSettingsModal;

View File

@@ -0,0 +1,114 @@
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { changeColumnParams } from '@/mastodon/actions/columns';
import { openModal } from '@/mastodon/actions/modal';
import { ColumnSettingsMenu } from '@/mastodon/components/column_header';
import { MultiColumnMenuItems } from '@/mastodon/components/column_header/multicolumn_settings';
import {
MenuItem,
MenuItemCheckbox,
MenuItemDivider,
} from '@/mastodon/components/menu';
import type { MenuItemCheckboxChangeHandler } from '@/mastodon/components/menu/items';
import { useIdentity } from '@/mastodon/identity_context';
import { useAppDispatch } from 'mastodon/store';
import { useColumnSettings } from '../../public_timeline/components/feed_column_settings';
import { useHashtag, messages } from './hashtag_header';
export const HashtagColumnMenu: React.FC<{
tagId: string;
multiColumn?: boolean;
columnId?: string;
onPin: () => void;
onMove: (dir: number) => void;
}> = ({ tagId, multiColumn, columnId, onPin, onMove }) => {
const dispatch = useAppDispatch();
const { signedIn } = useIdentity();
const { tag, toggleFollow, toggleFeature } = useHashtag(tagId);
const columnSettings = useColumnSettings(columnId);
const isLocalOnly = columnSettings.get('local') as boolean;
const toggleIsLocalOnly = useCallback<MenuItemCheckboxChangeHandler>(
({ checked }) => {
dispatch(changeColumnParams(columnId, ['local'], checked));
},
[columnId, dispatch],
);
const openAdvancedSettings = useCallback(() => {
dispatch(
openModal({
modalType: 'HASHTAG_SETTINGS',
modalProps: { columnId, tagId },
}),
);
}, [columnId, dispatch, tagId]);
if (!tag || !signedIn) {
return null;
}
const pinned = !!columnId;
return (
<ColumnSettingsMenu
label={
<FormattedMessage
id='hashtag.options'
defaultMessage='Hashtag options'
/>
}
>
<MenuItem onClick={toggleFollow}>
{tag.following ? (
<FormattedMessage {...messages.unfollowHashtag} />
) : (
<FormattedMessage {...messages.followHashtag} />
)}
</MenuItem>
<MenuItem onClick={toggleFeature}>
{tag.featuring ? (
<FormattedMessage {...messages.unfeature} />
) : (
<FormattedMessage {...messages.feature} />
)}
</MenuItem>
{multiColumn && pinned && (
<>
<MenuItemDivider />
<MenuItemCheckbox
value='local'
checked={isLocalOnly}
onChange={toggleIsLocalOnly}
keepMenuOpenOnClick
>
<FormattedMessage
id='community.column_settings.local_only'
defaultMessage='Local only'
/>
</MenuItemCheckbox>
<MenuItem onClick={openAdvancedSettings}>
<FormattedMessage
id='hashtags.add_more_tags'
defaultMessage='Add more tags to this column…'
values={{ tag: tagId }}
/>
</MenuItem>
</>
)}
{multiColumn && (
<MultiColumnMenuItems
withDivider
pinned={pinned}
onPin={onPin}
onMove={onMove}
/>
)}
</ColumnSettingsMenu>
);
};

View File

@@ -20,7 +20,7 @@ import { useIdentity } from 'mastodon/identity_context';
import { PERMISSION_MANAGE_TAXONOMIES } from 'mastodon/permissions';
import { useAppDispatch } from 'mastodon/store';
const messages = defineMessages({
export const messages = defineMessages({
followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' },
unfollowHashtag: {
id: 'hashtag.unfollow',
@@ -76,11 +76,7 @@ const usesTodayRenderer = (
/>
);
export const HashtagHeader: React.FC<{
tagId: string;
}> = ({ tagId }) => {
const intl = useIntl();
const { signedIn, permissions } = useIdentity();
export function useHashtag(tagId: string) {
const dispatch = useAppDispatch();
const [tag, setTag] = useState<ApiHashtagJSON>();
@@ -94,54 +90,32 @@ export const HashtagHeader: React.FC<{
});
}, [dispatch, tagId, setTag]);
const menu = useMemo(() => {
const arr = [];
if (tag && signedIn) {
const handleFeature = () => {
if (tag.featuring) {
void dispatch(unfeatureHashtag({ tagId })).then((result) => {
if (isFulfilled(result)) {
setTag(result.payload);
}
return '';
});
} else {
void dispatch(featureHashtag({ tagId })).then((result) => {
if (isFulfilled(result)) {
setTag(result.payload);
}
return '';
});
}
};
arr.push({
text: intl.formatMessage(
tag.featuring ? messages.unfeature : messages.feature,
),
action: handleFeature,
});
arr.push(null);
if (
(permissions & PERMISSION_MANAGE_TAXONOMIES) ===
PERMISSION_MANAGE_TAXONOMIES
) {
arr.push({
text: intl.formatMessage(messages.adminModeration, { name: tagId }),
href: `/admin/tags/${tag.id}`,
});
}
const toggleFeature = useCallback(() => {
if (!tag) {
return;
}
if (tag.featuring) {
void dispatch(unfeatureHashtag({ tagId })).then((result) => {
if (isFulfilled(result)) {
setTag(result.payload);
}
return arr;
}, [setTag, dispatch, tagId, signedIn, permissions, intl, tag]);
return '';
});
} else {
void dispatch(featureHashtag({ tagId })).then((result) => {
if (isFulfilled(result)) {
setTag(result.payload);
}
const handleFollow = useCallback(() => {
return '';
});
}
}, [dispatch, tag, tagId]);
const { signedIn } = useIdentity();
const toggleFollow = useCallback(() => {
if (!signedIn || !tag) {
return;
}
@@ -167,7 +141,44 @@ export const HashtagHeader: React.FC<{
return '';
});
}
}, [dispatch, setTag, signedIn, tag, tagId]);
}, [dispatch, signedIn, tag, tagId]);
return { tag, toggleFollow, toggleFeature };
}
export const HashtagHeader: React.FC<{
tagId: string;
}> = ({ tagId }) => {
const intl = useIntl();
const { signedIn, permissions } = useIdentity();
const { tag, toggleFeature, toggleFollow } = useHashtag(tagId);
const menu = useMemo(() => {
const arr = [];
if (tag && signedIn) {
arr.push({
text: intl.formatMessage(
tag.featuring ? messages.unfeature : messages.feature,
),
action: toggleFeature,
});
arr.push(null);
if (
(permissions & PERMISSION_MANAGE_TAXONOMIES) ===
PERMISSION_MANAGE_TAXONOMIES
) {
arr.push({
text: intl.formatMessage(messages.adminModeration, { name: tagId }),
href: `/admin/tags/${tag.id}`,
});
}
}
return arr;
}, [tag, signedIn, intl, toggleFeature, permissions, tagId]);
if (!tag) {
return null;
@@ -199,7 +210,7 @@ export const HashtagHeader: React.FC<{
{signedIn && (
<Button
onClick={handleFollow}
onClick={toggleFollow}
text={intl.formatMessage(
tag.following
? messages.unfollowHashtag

View File

@@ -14,7 +14,7 @@ import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import { connectHashtagStream } from 'mastodon/actions/streaming';
import { expandHashtagTimeline, clearTimeline } from 'mastodon/actions/timelines';
import { Column } from '@/mastodon/components/column';
import { ColumnHeader } from '@/mastodon/components/column/header';
import { ColumnHeader as LegacyColumnHeader } from '@/mastodon/components/column/header';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { remoteTopicFeedAccess, me, localTopicFeedAccess } from 'mastodon/initial_state';
@@ -22,6 +22,9 @@ import StatusListContainer from '../ui/containers/status_list_container';
import { HashtagHeader } from './components/hashtag_header';
import ColumnSettingsContainer from './containers/column_settings_container';
import { ColumnHeader } from '@/mastodon/components/column_header';
import { isRedesignEnabled } from '@/mastodon/utils/environment';
import { HashtagColumnMenu } from './components/hashtag_column_menu';
const mapStateToProps = (state, props) => {
const local = props.params.local || (!me && remoteTopicFeedAccess !== 'public');
@@ -163,26 +166,44 @@ class HashtagTimeline extends PureComponent {
const { hasUnread, columnId, multiColumn, local, hasFeedAccess } = this.props;
const { id } = this.props.params;
const pinned = !!columnId;
const withHeadingSection = !isRedesignEnabled() && !pinned;
const title = <>#{this.title()}</>;
const titleAsString = `#${id}`;
return (
<Column bindToDocument={!multiColumn} label={`#${id}`}>
<ColumnHeader
icon='hashtag'
iconComponent={TagIcon}
active={hasUnread}
title={this.title()}
onPin={this.handlePin}
onMove={this.handleMove}
pinned={pinned}
multiColumn={multiColumn}
showBackButton
scrollTopOnClick
>
{columnId && <ColumnSettingsContainer columnId={columnId} />}
</ColumnHeader>
<Column bindToDocument={!multiColumn} label={titleAsString}>
{isRedesignEnabled() ? (
<ColumnHeader
title={title}
withUnreadMarker={hasUnread}
withBackButton={multiColumn && !pinned && 'auto'}
extraButtons={
<HashtagColumnMenu
tagId={id}
multiColumn={multiColumn}
columnId={columnId}
onPin={this.handlePin}
onMove={this.handleMove}
/>
}
/>
) : (
<LegacyColumnHeader
icon='hashtag'
iconComponent={TagIcon}
active={hasUnread}
title={this.title()}
multiColumn={multiColumn}
showBackButton
scrollTopOnClick
>
{columnId && <ColumnSettingsContainer columnId={columnId} />}
</LegacyColumnHeader>
)}
<StatusListContainer
prepend={pinned ? null : <HashtagHeader tagId={id} />}
prepend={withHeadingSection && <HashtagHeader tagId={id} />}
alwaysPrepend
trackScroll={!pinned}
scrollKey={`hashtag_timeline-${columnId}`}
@@ -206,7 +227,7 @@ class HashtagTimeline extends PureComponent {
/>
<Helmet>
<title>#{id}</title>
<title>{titleAsString}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>

View File

@@ -2,6 +2,8 @@ import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import type { Map as ImmutableMap } from 'immutable';
import { SlidersHorizontalIcon } from '@phosphor-icons/react';
import { ColumnSettingsMenu } from '@/mastodon/components/column_header';
@@ -16,7 +18,9 @@ export const HomeColumnSettings: React.FC<{
}> = ({ children }) => {
const dispatch = useAppDispatch();
const settings = useAppSelector((state) => state.settings.get('home'));
const settings = useAppSelector((state) =>
state.settings.get('home'),
) as ImmutableMap<string, unknown>;
const onChange = useCallback<MenuItemCheckboxChangeHandler>(
({ value, checked }) => {
dispatch(changeSetting(['home', 'shows', value], checked));
@@ -24,14 +28,9 @@ export const HomeColumnSettings: React.FC<{
[dispatch],
);
/* eslint-disable @typescript-eslint/no-unsafe-call */
// @ts-expect-error settings isn't typed yet
const showBoosts = settings.getIn(['shows', 'reblog']) as boolean;
// @ts-expect-error settings isn't typed yet
const showQuotes = settings.getIn(['shows', 'quote']) as boolean;
// @ts-expect-error settings isn't typed yet
const showReplies = settings.getIn(['shows', 'reply']) as boolean;
/* eslint-enable @typescript-eslint/no-unsafe-call */
return (
<ColumnSettingsMenu

View File

@@ -4,6 +4,7 @@ import { FormattedMessage } from 'react-intl';
import type {
List as ImmutableList,
Map as ImmutableMap,
Record as ImmutableRecord,
} from 'immutable';
@@ -21,20 +22,24 @@ type Columns = ImmutableList<
}>
>;
export const FeedColumnSettings: React.FC<{
columnId: string | undefined;
localOnly?: boolean;
}> = ({ columnId, localOnly }) => {
const dispatch = useAppDispatch();
const settings = useAppSelector((state) => {
export function useColumnSettings(columnId: string | undefined) {
return useAppSelector((state) => {
const columns = state.settings.get('columns') as Columns;
const index = columns.findIndex((c) => c.get('uuid') === columnId);
return columnId && index >= 0
? columns.get(index)?.get('params')
: state.settings.get('public');
});
}) as ImmutableMap<string, unknown>;
}
export const FeedColumnSettings: React.FC<{
columnId: string | undefined;
localOnly?: boolean;
}> = ({ columnId, localOnly }) => {
const dispatch = useAppDispatch();
const settings = useColumnSettings(columnId);
const onChange = useCallback<MenuItemCheckboxChangeHandler>(
({ value, checked }) => {
@@ -47,12 +52,8 @@ export const FeedColumnSettings: React.FC<{
[columnId, dispatch],
);
/* eslint-disable @typescript-eslint/no-unsafe-call */
// @ts-expect-error settings isn't typed yet
const onlyMedia = settings.getIn(['other', 'onlyMedia']) as boolean;
// @ts-expect-error settings isn't typed yet
const onlyRemote = settings.getIn(['other', 'onlyRemote']) as boolean;
/* eslint-enable @typescript-eslint/no-unsafe-call */
return (
<>

View File

@@ -110,6 +110,7 @@ export const MODAL_COMPONENTS = {
'COMPOSER_REARRANGE': () => import('@/mastodon/features/compose/redesign/modal_rearrange'),
'COMPOSER_SWITCH_TO_POST': () => import('@/mastodon/features/compose/redesign/modal_switch'),
'NOTIFICATION_SETTINGS': () => import('@/mastodon/features/notifications_v2/components/notification_settings_modal'),
'HASHTAG_SETTINGS': () => import('@/mastodon/features/hashtag_timeline/components/column_settings_modal'),
};
/** @arg {keyof import('@/mastodon/features/account_edit/modals')} type */

View File

@@ -868,8 +868,11 @@
"hashtag.feature": "Feature on profile",
"hashtag.follow": "Follow hashtag",
"hashtag.mute": "Mute #{hashtag}",
"hashtag.options": "Hashtag options",
"hashtag.unfeature": "Don't feature on profile",
"hashtag.unfollow": "Unfollow hashtag",
"hashtags.add_more_tags": "Add more tags to this column…",
"hashtags.add_more_tags_to_column": "Add more tags to #{tag}",
"hashtags.and_other": "…and {count, plural, other {# more}}",
"hints.profiles.followers_may_be_missing": "Followers for this profile may be missing.",
"hints.profiles.follows_may_be_missing": "Follows for this profile may be missing.",