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

This commit is contained in:
Claire committed 2026-01-29 21:08:46 +01:00
commit d62a7775b5
69 files changed
+1090 -284

No files matched your search

@@ -162,11 +162,13 @@ export const AccountHeader: React.FC<{
{!suspendedOrHidden && (
<div className='account__header__extra'>
<div className='account__header__bio'>
{me && account.id !== me && isRedesignEnabled() ? (
<AccountNoteRedesign accountId={accountId} />
) : (
<AccountNote accountId={accountId} />
)}
{me &&
account.id !== me &&
(isRedesignEnabled() ? (
<AccountNoteRedesign accountId={accountId} />
) : (
<AccountNote accountId={accountId} />
))}
<AccountBio
accountId={accountId}
@@ -2,6 +2,7 @@ import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import type { NavLinkProps } from 'react-router-dom';
import { NavLink } from 'react-router-dom';
import { isRedesignEnabled } from '../common';
@@ -12,7 +13,7 @@ export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
if (isRedesignEnabled()) {
return (
<div className={classes.tabs}>
<NavLink exact to={`/@${acct}`}>
<NavLink isActive={isActive} to={`/@${acct}`}>
<FormattedMessage id='account.activity' defaultMessage='Activity' />
</NavLink>
<NavLink exact to={`/@${acct}/media`}>
@@ -44,3 +45,7 @@ export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
</div>
);
};
const isActive: Required<NavLinkProps>['isActive'] = (match, location) =>
match?.url === location.pathname ||
(!!match?.url && location.pathname.startsWith(`${match.url}/tagged/`));
@@ -0,0 +1,124 @@
import { useCallback, useEffect, useState } from 'react';
import type { FC, MouseEventHandler } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { useParams } from 'react-router';
import { fetchFeaturedTags } from '@/mastodon/actions/featured_tags';
import { useAppHistory } from '@/mastodon/components/router';
import { Tag } from '@/mastodon/components/tags/tag';
import { useOverflow } from '@/mastodon/hooks/useOverflow';
import { selectAccountFeaturedTags } from '@/mastodon/selectors/accounts';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { useFilters } from '../hooks/useFilters';
import classes from './styles.module.scss';
export const FeaturedTags: FC<{ accountId: string }> = ({ accountId }) => {
// Fetch tags.
const featuredTags = useAppSelector((state) =>
selectAccountFeaturedTags(state, accountId),
);
const dispatch = useAppDispatch();
useEffect(() => {
void dispatch(fetchFeaturedTags({ accountId }));
}, [accountId, dispatch]);
// Get list of tags with overflow handling.
const [showOverflow, setShowOverflow] = useState(false);
const { hiddenCount, wrapperRef, listRef, hiddenIndex, maxWidth } =
useOverflow();
// Handle whether to show all tags.
const handleOverflowClick: MouseEventHandler = useCallback(() => {
setShowOverflow(true);
}, []);
const { onClick, currentTag } = useTagNavigate();
if (featuredTags.length === 0) {
return null;
}
return (
<div className={classes.tagsWrapper} ref={wrapperRef}>
<div
className={classNames(
classes.tagsList,
showOverflow && classes.tagsListShowAll,
)}
style={{ maxWidth }}
ref={listRef}
>
{featuredTags.map(({ id, name }, index) => (
<Tag
name={name}
key={id}
inert={hiddenIndex > 0 && index >= hiddenIndex ? '' : undefined}
onClick={onClick}
active={currentTag === name}
data-name={name}
/>
))}
</div>
{!showOverflow && hiddenCount > 0 && (
<Tag
onClick={handleOverflowClick}
name={
<FormattedMessage
id='featured_tags.more_items'
defaultMessage='+{count}'
values={{ count: hiddenCount }}
/>
}
/>
)}
</div>
);
};
function useTagNavigate() {
// Get current account, tag, and filters.
const { acct, tagged } = useParams<{ acct: string; tagged?: string }>();
const { boosts, replies } = useFilters();
const history = useAppHistory();
const handleTagClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(event) => {
const name = event.currentTarget.getAttribute('data-name');
if (!name || !acct) {
return;
}
// Determine whether to navigate to or from the tag.
let url = `/@${acct}/tagged/${encodeURIComponent(name)}`;
if (name === tagged) {
url = `/@${acct}`;
}
// Append filters.
const params = new URLSearchParams();
if (boosts) {
params.append('boosts', '1');
}
if (replies) {
params.append('replies', '1');
}
history.push({
pathname: url,
search: params.toString(),
});
},
[acct, tagged, boosts, replies, history],
);
return {
onClick: handleTagClick,
currentTag: tagged,
};
}
@@ -27,6 +27,7 @@ import { AccountHeader } from '../components/account_header';
import { LimitedAccountHint } from '../components/limited_account_hint';
import { useFilters } from '../hooks/useFilters';
import { FeaturedTags } from './featured_tags';
import { AccountFilters } from './filters';
const emptyList = ImmutableList<string>();
@@ -135,6 +136,7 @@ const Prepend: FC<{
<>
<AccountHeader accountId={accountId} hideTabs />
<AccountFilters />
<FeaturedTags accountId={accountId} />
<FeaturedCarousel accountId={accountId} tagged={tagged} />
</>
);
@@ -35,3 +35,25 @@
align-items: center;
}
}
.tagsWrapper {
margin: 0 24px 8px;
display: flex;
flex-wrap: nowrap;
justify-content: flex-start;
gap: 8px;
}
.tagsList {
display: flex;
gap: 4px;
flex-wrap: nowrap;
overflow: hidden;
position: relative;
}
.tagsListShowAll {
flex-wrap: wrap;
overflow: visible;
max-width: none !important;
}
@@ -0,0 +1,272 @@
import { useCallback, useState, useEffect } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { useParams, useHistory } from 'react-router-dom';
import { isFulfilled } from '@reduxjs/toolkit';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import type {
ApiCollectionJSON,
ApiCreateCollectionPayload,
ApiUpdateCollectionPayload,
} from 'mastodon/api_types/collections';
import { Button } from 'mastodon/components/button';
import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { TextAreaField, ToggleField } from 'mastodon/components/form_fields';
import { TextInputField } from 'mastodon/components/form_fields/text_input_field';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import {
createCollection,
fetchCollection,
updateCollection,
} from 'mastodon/reducers/slices/collections';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
edit: { id: 'column.edit_collection', defaultMessage: 'Edit collection' },
create: {
id: 'column.create_collection',
defaultMessage: 'Create collection',
},
});
const CollectionSettings: React.FC<{
collection?: ApiCollectionJSON | null;
}> = ({ collection }) => {
const dispatch = useAppDispatch();
const history = useHistory();
const {
id,
name: initialName = '',
description: initialDescription = '',
tag,
discoverable: initialDiscoverable = true,
sensitive: initialSensitive = false,
} = collection ?? {};
const [name, setName] = useState(initialName);
const [description, setDescription] = useState(initialDescription);
const [topic, setTopic] = useState(tag?.name ?? '');
const [discoverable] = useState(initialDiscoverable);
const [sensitive, setSensitive] = useState(initialSensitive);
const handleNameChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setName(event.target.value);
},
[],
);
const handleDescriptionChange = useCallback(
(event: React.ChangeEvent<HTMLTextAreaElement>) => {
setDescription(event.target.value);
},
[],
);
const handleTopicChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setTopic(event.target.value);
},
[],
);
const handleSensitiveChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setSensitive(event.target.checked);
},
[],
);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (id) {
const payload: ApiUpdateCollectionPayload = {
id,
name,
description,
tag_name: topic,
discoverable,
sensitive,
};
void dispatch(updateCollection({ payload })).then(() => {
history.push(`/collections`);
});
} else {
const payload: ApiCreateCollectionPayload = {
name,
description,
discoverable,
sensitive,
};
if (topic) {
payload.tag_name = topic;
}
void dispatch(
createCollection({
payload,
}),
).then((result) => {
if (isFulfilled(result)) {
history.replace(
`/collections/${result.payload.collection.id}/edit`,
);
history.push(`/collections`);
}
});
}
},
[id, dispatch, name, description, topic, discoverable, sensitive, history],
);
return (
<form className='simple_form app-form' onSubmit={handleSubmit}>
<div className='fields-group'>
<TextInputField
required
label={
<FormattedMessage
id='collections.collection_name'
defaultMessage='Name'
/>
}
hint={
<FormattedMessage
id='collections.name_length_hint'
defaultMessage='40 characters limit'
/>
}
value={name}
onChange={handleNameChange}
maxLength={40}
/>
</div>
<div className='fields-group'>
<TextAreaField
required
label={
<FormattedMessage
id='collections.collection_description'
defaultMessage='Description'
/>
}
hint={
<FormattedMessage
id='collections.description_length_hint'
defaultMessage='100 characters limit'
/>
}
value={description}
onChange={handleDescriptionChange}
maxLength={100}
/>
</div>
<div className='fields-group'>
<TextInputField
required={false}
label={
<FormattedMessage
id='collections.collection_topic'
defaultMessage='Topic'
/>
}
hint={
<FormattedMessage
id='collections.topic_hint'
defaultMessage='Add a hashtag that helps others understand the main topic of this collection.'
/>
}
value={topic}
onChange={handleTopicChange}
maxLength={40}
/>
</div>
<div className='fields-group'>
<ToggleField
label={
<FormattedMessage
id='collections.mark_as_sensitive'
defaultMessage='Mark as sensitive'
/>
}
hint={
<FormattedMessage
id='collections.mark_as_sensitive_hint'
defaultMessage="Hides the collection's description and accounts behind a content warning. The collection name will still be visible."
/>
}
checked={sensitive}
onChange={handleSensitiveChange}
/>
</div>
<div className='actions'>
<Button type='submit'>
{id ? (
<FormattedMessage id='lists.save' defaultMessage='Save' />
) : (
<FormattedMessage id='lists.create' defaultMessage='Create' />
)}
</Button>
</div>
</form>
);
};
export const CollectionEditorPage: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { id } = useParams<{ id?: string }>();
const collection = useAppSelector((state) =>
id ? state.collections.collections[id] : undefined,
);
const isEditMode = !!id;
const isLoading = isEditMode && !collection;
useEffect(() => {
if (id) {
void dispatch(fetchCollection({ collectionId: id }));
}
}, [dispatch, id]);
const pageTitle = intl.formatMessage(id ? messages.edit : messages.create);
return (
<Column bindToDocument={!multiColumn} label={pageTitle}>
<ColumnHeader
title={pageTitle}
icon='list-ul'
iconComponent={ListAltIcon}
multiColumn={multiColumn}
showBackButton
/>
<div className='scrollable'>
{isLoading ? (
<LoadingIndicator />
) : (
<CollectionSettings collection={collection} />
)}
</div>
<Helmet>
<title>{pageTitle}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
@@ -16,7 +16,6 @@ import { Dropdown } from 'mastodon/components/dropdown_menu';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import {
createCollection,
fetchAccountCollections,
selectMyCollections,
} from 'mastodon/reducers/slices/collections';
@@ -49,13 +48,14 @@ const ListItem: React.FC<{
const handleDeleteClick = useCallback(() => {
dispatch(
openModal({
modalType: 'CONFIRM_DELETE_LIST',
modalType: 'CONFIRM_DELETE_COLLECTION',
modalProps: {
listId: id,
name,
id,
},
}),
);
}, [dispatch, id]);
}, [dispatch, id, name]);
const menu = useMemo(
() => [
@@ -67,7 +67,7 @@ const ListItem: React.FC<{
return (
<div className='lists__item'>
<Link to={`/collections/${id}`} className='lists__item__title'>
<Link to={`/collections/${id}/edit`} className='lists__item__title'>
<span>{name}</span>
</Link>
@@ -94,24 +94,6 @@ export const Collections: React.FC<{
void dispatch(fetchAccountCollections({ accountId: me }));
}, [dispatch, me]);
const addDummyCollection = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
void dispatch(
createCollection({
payload: {
name: 'Test Collection',
description: 'A useful test collection',
discoverable: true,
sensitive: false,
},
}),
);
},
[dispatch],
);
const emptyMessage =
status === 'error' ? (
<FormattedMessage
@@ -152,7 +134,6 @@ export const Collections: React.FC<{
className='column-header__button'
title={intl.formatMessage(messages.create)}
aria-label={intl.formatMessage(messages.create)}
onClick={addDummyCollection}
>
<Icon id='plus' icon={AddIcon} />
</Link>
@@ -0,0 +1,54 @@
import { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router';
import { deleteCollection } from 'mastodon/reducers/slices/collections';
import { useAppDispatch } from 'mastodon/store';
import type { BaseConfirmationModalProps } from './confirmation_modal';
import { ConfirmationModal } from './confirmation_modal';
const messages = defineMessages({
deleteListTitle: {
id: 'confirmations.delete_collection.title',
defaultMessage: 'Delete "{name}"?',
},
deleteListMessage: {
id: 'confirmations.delete_collection.message',
defaultMessage: 'This action cannot be undone.',
},
deleteListConfirm: {
id: 'confirmations.delete_collection.confirm',
defaultMessage: 'Delete',
},
});
export const ConfirmDeleteCollectionModal: React.FC<
{
id: string;
name: string;
} & BaseConfirmationModalProps
> = ({ id, name, onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const history = useHistory();
const onConfirm = useCallback(() => {
void dispatch(deleteCollection({ collectionId: id }));
history.push('/collections');
}, [dispatch, history, id]);
return (
<ConfirmationModal
title={intl.formatMessage(messages.deleteListTitle, {
name,
})}
message={intl.formatMessage(messages.deleteListMessage)}
confirm={intl.formatMessage(messages.deleteListConfirm)}
onConfirm={onConfirm}
onClose={onClose}
/>
);
};
@@ -1,6 +1,7 @@
export { ConfirmationModal } from './confirmation_modal';
export { ConfirmDeleteStatusModal } from './delete_status';
export { ConfirmDeleteListModal } from './delete_list';
export { ConfirmDeleteCollectionModal } from './delete_collection';
export {
ConfirmReplyModal,
ConfirmEditStatusModal,
@@ -29,6 +29,7 @@ import {
ConfirmationModal,
ConfirmDeleteStatusModal,
ConfirmDeleteListModal,
ConfirmDeleteCollectionModal,
ConfirmReplyModal,
ConfirmEditStatusModal,
ConfirmUnblockModal,
@@ -57,6 +58,7 @@ export const MODAL_COMPONENTS = {
'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }),
'CONFIRM_DELETE_STATUS': () => Promise.resolve({ default: ConfirmDeleteStatusModal }),
'CONFIRM_DELETE_LIST': () => Promise.resolve({ default: ConfirmDeleteListModal }),
'CONFIRM_DELETE_COLLECTION': () => Promise.resolve({ default: ConfirmDeleteCollectionModal }),
'CONFIRM_REPLY': () => Promise.resolve({ default: ConfirmReplyModal }),
'CONFIRM_EDIT_STATUS': () => Promise.resolve({ default: ConfirmEditStatusModal }),
'CONFIRM_UNBLOCK': () => Promise.resolve({ default: ConfirmUnblockModal }),
@@ -64,6 +64,7 @@ import {
ListEdit,
ListMembers,
Collections,
CollectionsEditor,
Blocks,
DomainBlocks,
Mutes,
@@ -229,6 +230,12 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/followed_tags' component={FollowedTags} content={children} />
<WrappedRoute path='/mutes' component={Mutes} content={children} />
<WrappedRoute path='/lists' component={Lists} content={children} />
{areCollectionsEnabled() &&
<WrappedRoute path='/collections/new' component={CollectionsEditor} content={children} />
}
{areCollectionsEnabled() &&
<WrappedRoute path='/collections/:id/edit' component={CollectionsEditor} content={children} />
}
{areCollectionsEnabled() &&
<WrappedRoute path='/collections' component={Collections} content={children} />
}
@@ -50,6 +50,12 @@ export function Collections () {
);
}
export function CollectionsEditor () {
return import('../../collections/editor').then(
module => ({default: module.CollectionEditorPage})
);
}
export function Status () {
return import('../../status');
}