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

This commit is contained in:
Claire
2026-03-04 18:40:39 +01:00
26 changed files with 656 additions and 114 deletions

View File

@@ -56,7 +56,7 @@ services:
- internal_network - internal_network
es: es:
image: docker.elastic.co/elasticsearch/elasticsearch-oss:7.10.2 image: docker.elastic.co/elasticsearch/elasticsearch:7.17.29
restart: unless-stopped restart: unless-stopped
environment: environment:
ES_JAVA_OPTS: -Xms512m -Xmx512m ES_JAVA_OPTS: -Xms512m -Xmx512m

View File

@@ -352,10 +352,10 @@ jobs:
- '3.3' - '3.3'
- '.ruby-version' - '.ruby-version'
search-image: search-image:
- docker.elastic.co/elasticsearch/elasticsearch:7.17.13 - docker.elastic.co/elasticsearch/elasticsearch:7.17.29
include: include:
- ruby-version: '.ruby-version' - ruby-version: '.ruby-version'
search-image: docker.elastic.co/elasticsearch/elasticsearch:8.10.2 search-image: docker.elastic.co/elasticsearch/elasticsearch:8.19.2
- ruby-version: '.ruby-version' - ruby-version: '.ruby-version'
search-image: opensearchproject/opensearch:2 search-image: opensearchproject/opensearch:2

View File

@@ -0,0 +1,34 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { CharacterCounter } from './index';
const meta = {
component: CharacterCounter,
title: 'Components/CharacterCounter',
} satisfies Meta<typeof CharacterCounter>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Required: Story = {
args: {
currentString: 'Hello, world!',
maxLength: 100,
},
};
export const ExceedingLimit: Story = {
args: {
currentString: 'Hello, world!',
maxLength: 10,
},
};
export const Recommended: Story = {
args: {
currentString: 'Hello, world!',
maxLength: 10,
recommended: true,
},
};

View File

@@ -0,0 +1,63 @@
import { useMemo } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { polymorphicForwardRef } from '@/types/polymorphic';
import classes from './styles.module.scss';
interface CharacterCounterProps {
currentString: string;
maxLength: number;
recommended?: boolean;
}
const segmenter = new Intl.Segmenter();
export const CharacterCounter = polymorphicForwardRef<
'span',
CharacterCounterProps
>(
(
{
currentString,
maxLength,
as: Component = 'span',
recommended = false,
...props
},
ref,
) => {
const currentLength = useMemo(
() => [...segmenter.segment(currentString)].length,
[currentString],
);
return (
<Component
{...props}
ref={ref}
className={classNames(
classes.counter,
currentLength > maxLength && !recommended && classes.counterError,
)}
>
{recommended ? (
<FormattedMessage
id='character_counter.recommended'
defaultMessage='{currentLength}/{maxLength} recommended characters'
values={{ currentLength, maxLength }}
/>
) : (
<FormattedMessage
id='character_counter.required'
defaultMessage='{currentLength}/{maxLength} characters'
values={{ currentLength, maxLength }}
/>
)}
</Component>
);
},
);
CharacterCounter.displayName = 'CharCounter';

View File

@@ -0,0 +1,8 @@
.counter {
margin-top: 4px;
font-size: 13px;
}
.counterError {
color: var(--color-text-error);
}

View File

@@ -0,0 +1,29 @@
import { useCallback } from 'react';
import type { FC } from 'react';
import EmojiPickerDropdown from '@/mastodon/features/compose/containers/emoji_picker_dropdown_container';
export const EmojiPickerButton: FC<{
onPick: (emoji: string) => void;
disabled?: boolean;
}> = ({ onPick, disabled }) => {
const handlePick = useCallback(
(emoji: unknown) => {
if (disabled) {
return;
}
if (typeof emoji === 'object' && emoji !== null) {
if ('native' in emoji && typeof emoji.native === 'string') {
onPick(emoji.native);
} else if (
'shortcode' in emoji &&
typeof emoji.shortcode === 'string'
) {
onPick(`:${emoji.shortcode}:`);
}
}
},
[disabled, onPick],
);
return <EmojiPickerDropdown onPickEmoji={handlePick} disabled={disabled} />;
};

View File

@@ -0,0 +1,24 @@
.fieldWrapper div:has(:global(.emoji-picker-dropdown)) {
position: relative;
> input,
> textarea {
padding-inline-end: 36px;
}
> textarea {
min-height: 40px; // Button size with 8px margin
}
}
.fieldWrapper :global(.emoji-picker-dropdown) {
position: absolute;
top: 8px;
right: 8px;
height: 24px;
z-index: 1;
:global(.icon-button) {
color: var(--color-text-secondary);
}
}

View File

@@ -0,0 +1,59 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import type { EmojiInputProps } from './emoji_text_field';
import { EmojiTextAreaField, EmojiTextInputField } from './emoji_text_field';
const meta = {
title: 'Components/Form Fields/EmojiTextInputField',
args: {
label: 'Label',
hint: 'Hint text',
value: 'Insert text with emoji',
},
render({ value: initialValue = '', ...args }) {
const [value, setValue] = useState(initialValue);
return <EmojiTextInputField {...args} value={value} onChange={setValue} />;
},
} satisfies Meta<EmojiInputProps & { disabled?: boolean }>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Simple: Story = {};
export const WithMaxLength: Story = {
args: {
maxLength: 20,
},
};
export const WithRecommended: Story = {
args: {
maxLength: 20,
recommended: true,
},
};
export const Disabled: Story = {
args: {
disabled: true,
},
};
export const TextArea: Story = {
render(args) {
const [value, setValue] = useState('Insert text with emoji');
return (
<EmojiTextAreaField
{...args}
value={value}
onChange={setValue}
label='Label'
maxLength={100}
/>
);
},
};

View File

@@ -0,0 +1,174 @@
import type {
ChangeEvent,
ChangeEventHandler,
ComponentPropsWithoutRef,
Dispatch,
FC,
ReactNode,
RefObject,
SetStateAction,
} from 'react';
import { useCallback, useId, useRef } from 'react';
import { insertEmojiAtPosition } from '@/mastodon/features/emoji/utils';
import type { OmitUnion } from '@/mastodon/utils/types';
import { CharacterCounter } from '../character_counter';
import { EmojiPickerButton } from '../emoji/picker_button';
import classes from './emoji_text_field.module.scss';
import type { CommonFieldWrapperProps, InputProps } from './form_field_wrapper';
import { FormFieldWrapper } from './form_field_wrapper';
import { TextArea } from './text_area_field';
import type { TextAreaProps } from './text_area_field';
import { TextInput } from './text_input_field';
export type EmojiInputProps = {
value?: string;
onChange?: Dispatch<SetStateAction<string>>;
maxLength?: number;
recommended?: boolean;
} & Omit<CommonFieldWrapperProps, 'wrapperClassName'>;
export const EmojiTextInputField: FC<
OmitUnion<ComponentPropsWithoutRef<'input'>, EmojiInputProps>
> = ({
onChange,
value,
label,
hint,
hasError,
maxLength,
recommended,
disabled,
...otherProps
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const wrapperProps = {
label,
hint,
hasError,
maxLength,
recommended,
disabled,
inputRef,
value,
onChange,
};
return (
<EmojiFieldWrapper {...wrapperProps}>
{(inputProps) => (
<TextInput
{...inputProps}
{...otherProps}
value={value}
ref={inputRef}
/>
)}
</EmojiFieldWrapper>
);
};
export const EmojiTextAreaField: FC<
OmitUnion<Omit<TextAreaProps, 'style'>, EmojiInputProps>
> = ({
onChange,
value,
label,
maxLength,
recommended = false,
disabled,
hint,
hasError,
...otherProps
}) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const wrapperProps = {
label,
hint,
hasError,
maxLength,
recommended,
disabled,
inputRef: textareaRef,
value,
onChange,
};
return (
<EmojiFieldWrapper {...wrapperProps}>
{(inputProps) => (
<TextArea
{...otherProps}
{...inputProps}
value={value}
ref={textareaRef}
/>
)}
</EmojiFieldWrapper>
);
};
const EmojiFieldWrapper: FC<
EmojiInputProps & {
disabled?: boolean;
children: (
inputProps: InputProps & { onChange: ChangeEventHandler },
) => ReactNode;
inputRef: RefObject<HTMLTextAreaElement | HTMLInputElement>;
}
> = ({
value,
onChange,
children,
disabled,
inputRef,
maxLength,
recommended = false,
...otherProps
}) => {
const counterId = useId();
const handlePickEmoji = useCallback(
(emoji: string) => {
onChange?.((prev) => {
const position = inputRef.current?.selectionStart ?? prev.length;
return insertEmojiAtPosition(prev, emoji, position);
});
},
[onChange, inputRef],
);
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
onChange?.(event.target.value);
},
[onChange],
);
return (
<FormFieldWrapper
className={classes.fieldWrapper}
describedById={counterId}
{...otherProps}
>
{(inputProps) => (
<>
{children({ ...inputProps, onChange: handleChange })}
<EmojiPickerButton onPick={handlePickEmoji} disabled={disabled} />
{maxLength && (
<CharacterCounter
currentString={value ?? ''}
maxLength={maxLength}
recommended={recommended}
id={counterId}
/>
)}
</>
)}
</FormFieldWrapper>
);
};

View File

@@ -5,10 +5,12 @@ import { useContext, useId } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { FieldsetNameContext } from './fieldset'; import { FieldsetNameContext } from './fieldset';
import classes from './form_field_wrapper.module.scss'; import classes from './form_field_wrapper.module.scss';
interface InputProps { export interface InputProps {
id: string; id: string;
required?: boolean; required?: boolean;
'aria-describedby'?: string; 'aria-describedby'?: string;
@@ -20,8 +22,10 @@ interface FieldWrapperProps {
required?: boolean; required?: boolean;
hasError?: boolean; hasError?: boolean;
inputId?: string; inputId?: string;
describedById?: string;
inputPlacement?: 'inline-start' | 'inline-end'; inputPlacement?: 'inline-start' | 'inline-end';
children: (inputProps: InputProps) => ReactNode; children: (inputProps: InputProps) => ReactNode;
className?: string;
} }
/** /**
@@ -30,7 +34,7 @@ interface FieldWrapperProps {
export type CommonFieldWrapperProps = Pick< export type CommonFieldWrapperProps = Pick<
FieldWrapperProps, FieldWrapperProps,
'label' | 'hint' | 'hasError' 'label' | 'hint' | 'hasError'
>; > & { wrapperClassName?: string };
/** /**
* A simple form field wrapper for adding a label and hint to enclosed components. * A simple form field wrapper for adding a label and hint to enclosed components.
@@ -42,10 +46,12 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
inputId: inputIdProp, inputId: inputIdProp,
label, label,
hint, hint,
describedById,
required, required,
hasError, hasError,
inputPlacement, inputPlacement,
children, children,
className,
}) => { }) => {
const uniqueId = useId(); const uniqueId = useId();
const inputId = inputIdProp || `${uniqueId}-input`; const inputId = inputIdProp || `${uniqueId}-input`;
@@ -59,7 +65,9 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
id: inputId, id: inputId,
}; };
if (hasHint) { if (hasHint) {
inputProps['aria-describedby'] = hintId; inputProps['aria-describedby'] = describedById
? `${describedById} ${hintId}`
: hintId;
} }
const input = ( const input = (
@@ -68,7 +76,7 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
return ( return (
<div <div
className={classes.wrapper} className={classNames(classes.wrapper, className)}
data-has-error={hasError} data-has-error={hasError}
data-input-placement={inputPlacement} data-input-placement={inputPlacement}
> >

View File

@@ -10,7 +10,7 @@ import { FormFieldWrapper } from './form_field_wrapper';
import type { CommonFieldWrapperProps } from './form_field_wrapper'; import type { CommonFieldWrapperProps } from './form_field_wrapper';
import classes from './text_input.module.scss'; import classes from './text_input.module.scss';
type TextAreaProps = export type TextAreaProps =
| ({ autoSize?: false } & ComponentPropsWithoutRef<'textarea'>) | ({ autoSize?: false } & ComponentPropsWithoutRef<'textarea'>)
| ({ autoSize: true } & TextareaAutosizeProps); | ({ autoSize: true } & TextareaAutosizeProps);
@@ -24,17 +24,23 @@ type TextAreaProps =
export const TextAreaField = forwardRef< export const TextAreaField = forwardRef<
HTMLTextAreaElement, HTMLTextAreaElement,
TextAreaProps & CommonFieldWrapperProps TextAreaProps & CommonFieldWrapperProps
>(({ id, label, hint, required, hasError, ...otherProps }, ref) => ( >(
(
{ id, label, hint, required, hasError, wrapperClassName, ...otherProps },
ref,
) => (
<FormFieldWrapper <FormFieldWrapper
label={label} label={label}
hint={hint} hint={hint}
required={required} required={required}
hasError={hasError} hasError={hasError}
inputId={id} inputId={id}
className={wrapperClassName}
> >
{(inputProps) => <TextArea {...otherProps} {...inputProps} ref={ref} />} {(inputProps) => <TextArea {...otherProps} {...inputProps} ref={ref} />}
</FormFieldWrapper> </FormFieldWrapper>
)); ),
);
TextAreaField.displayName = 'TextAreaField'; TextAreaField.displayName = 'TextAreaField';

View File

@@ -24,13 +24,17 @@ interface Props extends TextInputProps, CommonFieldWrapperProps {}
*/ */
export const TextInputField = forwardRef<HTMLInputElement, Props>( export const TextInputField = forwardRef<HTMLInputElement, Props>(
({ id, label, hint, hasError, required, ...otherProps }, ref) => ( (
{ id, label, hint, hasError, required, wrapperClassName, ...otherProps },
ref,
) => (
<FormFieldWrapper <FormFieldWrapper
label={label} label={label}
hint={hint} hint={hint}
required={required} required={required}
hasError={hasError} hasError={hasError}
inputId={id} inputId={id}
className={wrapperClassName}
> >
{(inputProps) => <TextInput {...otherProps} {...inputProps} ref={ref} />} {(inputProps) => <TextInput {...otherProps} {...inputProps} ref={ref} />}
</FormFieldWrapper> </FormFieldWrapper>

View File

@@ -0,0 +1,85 @@
import type { ComponentPropsWithoutRef } from 'react';
import { Children, forwardRef } from 'react';
import classNames from 'classnames';
import { LoadingIndicator } from '../loading_indicator';
export const Scrollable = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<'div'> & {
flex?: boolean;
fullscreen?: boolean;
}
>(({ flex = true, fullscreen, className, children, ...otherProps }, ref) => {
return (
<div
className={classNames(
'scrollable',
{ 'scrollable--flex': flex, fullscreen },
className,
)}
ref={ref}
{...otherProps}
>
{children}
</div>
);
});
Scrollable.displayName = 'Scrollable';
export const ItemList = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<'div'> & {
isLoading?: boolean;
emptyMessage?: React.ReactNode;
}
>(({ isLoading, emptyMessage, className, children, ...otherProps }, ref) => {
if (Children.count(children) === 0 && emptyMessage) {
return <div className='empty-column-indicator'>{emptyMessage}</div>;
}
return (
<>
<div
role='feed'
className={classNames('item-list', className)}
ref={ref}
{...otherProps}
>
{!isLoading && children}
</div>
{isLoading && (
<div className='scrollable__append'>
<LoadingIndicator />
</div>
)}
</>
);
});
ItemList.displayName = 'ItemList';
export const Article = forwardRef<
HTMLElement,
ComponentPropsWithoutRef<'article'> & {
focusable?: boolean;
'data-id'?: string;
'aria-posinset': number;
'aria-setsize': number;
}
>(({ focusable, className, children, ...otherProps }, ref) => {
return (
<article
ref={ref}
className={classNames(className, { focusable })}
tabIndex={-1}
{...otherProps}
>
{children}
</article>
);
});
Article.displayName = 'Article';

View File

@@ -1,7 +1,6 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Children, cloneElement, PureComponent } from 'react'; import { Children, cloneElement, PureComponent } from 'react';
import classNames from 'classnames';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
@@ -12,13 +11,14 @@ import { throttle } from 'lodash';
import { ScrollContainer } from 'mastodon/containers/scroll_container'; import { ScrollContainer } from 'mastodon/containers/scroll_container';
import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container'; import IntersectionObserverArticleContainer from '../../containers/intersection_observer_article_container';
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen'; import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../../features/ui/util/fullscreen';
import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; import IntersectionObserverWrapper from '../../features/ui/util/intersection_observer_wrapper';
import { LoadMore } from './load_more'; import { LoadMore } from '../load_more';
import { LoadPending } from './load_pending'; import { LoadPending } from '../load_pending';
import { LoadingIndicator } from './loading_indicator'; import { LoadingIndicator } from '../loading_indicator';
import { Scrollable, ItemList } from './components';
const MOUSE_IDLE_DELAY = 300; const MOUSE_IDLE_DELAY = 300;
@@ -336,24 +336,20 @@ class ScrollableList extends PureComponent {
if (showLoading) { if (showLoading) {
scrollableArea = ( scrollableArea = (
<div className='scrollable scrollable--flex' ref={this.setRef}> <Scrollable ref={this.setRef}>
{prepend} {prepend}
<div role='feed' className='item-list' /> <ItemList isLoading />
<div className='scrollable__append'>
<LoadingIndicator />
</div>
{footer} {footer}
</div> </Scrollable>
); );
} else if (isLoading || childrenCount > 0 || numPending > 0 || hasMore || !emptyMessage) { } else if (isLoading || childrenCount > 0 || numPending > 0 || hasMore || !emptyMessage) {
scrollableArea = ( scrollableArea = (
<div className={classNames('scrollable scrollable--flex', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}> <Scrollable fullscreen={fullscreen} ref={this.setRef} onMouseMove={this.handleMouseMove}>
{prepend} {prepend}
<div role='feed' className={classNames('item-list', className)}> <ItemList className={className}>
{loadPending} {loadPending}
{Children.map(this.props.children, (child, index) => ( {Children.map(this.props.children, (child, index) => (
@@ -378,14 +374,14 @@ class ScrollableList extends PureComponent {
{loadMore} {loadMore}
{!hasMore && append} {!hasMore && append}
</div> </ItemList>
{footer} {footer}
</div> </Scrollable>
); );
} else { } else {
scrollableArea = ( scrollableArea = (
<div className={classNames('scrollable scrollable--flex', { fullscreen })} ref={this.setRef}> <Scrollable fullscreen={fullscreen} ref={this.setRef}>
{alwaysPrepend && prepend} {alwaysPrepend && prepend}
<div className='empty-column-indicator'> <div className='empty-column-indicator'>
@@ -393,7 +389,7 @@ class ScrollableList extends PureComponent {
</div> </div>
{footer} {footer}
</div> </Scrollable>
); );
} }

View File

@@ -1,8 +1,9 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { cloneElement, Component } from 'react'; import { cloneElement, Component } from 'react';
import getRectFromEntry from '../features/ui/util/get_rect_from_entry'; import getRectFromEntry from '../../features/ui/util/get_rect_from_entry';
import scheduleIdleTask from '../features/ui/util/schedule_idle_task'; import scheduleIdleTask from '../../features/ui/util/schedule_idle_task';
import { Article } from './components';
// Diff these props in the "unrendered" state // Diff these props in the "unrendered" state
const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight']; const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
@@ -108,23 +109,22 @@ export default class IntersectionObserverArticle extends Component {
if (!isIntersecting && (isHidden || cachedHeight)) { if (!isIntersecting && (isHidden || cachedHeight)) {
return ( return (
<article <Article
ref={this.handleRef} ref={this.handleRef}
aria-posinset={index + 1} aria-posinset={index + 1}
aria-setsize={listLength} aria-setsize={listLength}
style={{ height: `${this.height || cachedHeight}px`, opacity: 0, overflow: 'hidden' }} style={{ height: `${this.height || cachedHeight}px`, opacity: 0, overflow: 'hidden' }}
data-id={id} data-id={id}
tabIndex={-1}
> >
{children && cloneElement(children, { hidden: true })} {children && cloneElement(children, { hidden: true })}
</article> </Article>
); );
} }
return ( return (
<article ref={this.handleRef} aria-posinset={index + 1} aria-setsize={listLength} data-id={id} tabIndex={-1}> <Article ref={this.handleRef} aria-posinset={index + 1} aria-setsize={listLength} data-id={id}>
{children && cloneElement(children, { hidden: false })} {children && cloneElement(children, { hidden: false })}
</article> </Article>
); );
} }

View File

@@ -1,7 +1,7 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { setHeight } from '../actions/height_cache'; import { setHeight } from '../actions/height_cache';
import IntersectionObserverArticle from '../components/intersection_observer_article'; import IntersectionObserverArticle from '../components/scrollable_list/intersection_observer_article';
const makeMapStateToProps = (state, props) => ({ const makeMapStateToProps = (state, props) => ({
cachedHeight: state.getIn(['height_cache', props.saveHeightKey, props.id]), cachedHeight: state.getIn(['height_cache', props.saveHeightKey, props.id]),

View File

@@ -12,6 +12,11 @@ import { Account } from 'mastodon/components/account';
import { ColumnBackButton } from 'mastodon/components/column_back_button'; import { ColumnBackButton } from 'mastodon/components/column_back_button';
import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { RemoteHint } from 'mastodon/components/remote_hint'; import { RemoteHint } from 'mastodon/components/remote_hint';
import {
Article,
ItemList,
Scrollable,
} from 'mastodon/components/scrollable_list/components';
import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header'; import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import Column from 'mastodon/features/ui/components/column'; import Column from 'mastodon/features/ui/components/column';
@@ -115,7 +120,7 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
<Column> <Column>
<ColumnBackButton /> <ColumnBackButton />
<div className='scrollable scrollable--flex'> <Scrollable>
{accountId && ( {accountId && (
<AccountHeader accountId={accountId} hideTabs={forceEmptyState} /> <AccountHeader accountId={accountId} hideTabs={forceEmptyState} />
)} )}
@@ -127,15 +132,17 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
defaultMessage='Collections' defaultMessage='Collections'
/> />
</h4> </h4>
<section> <ItemList>
{publicCollections.map((item, index) => ( {publicCollections.map((item, index) => (
<CollectionListItem <CollectionListItem
key={item.id} key={item.id}
collection={item} collection={item}
withoutBorder={index === publicCollections.length - 1} withoutBorder={index === publicCollections.length - 1}
positionInList={index + 1}
listSize={publicCollections.length}
/> />
))} ))}
</section> </ItemList>
</> </>
)} )}
{!featuredTags.isEmpty() && ( {!featuredTags.isEmpty() && (
@@ -146,9 +153,18 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
defaultMessage='Hashtags' defaultMessage='Hashtags'
/> />
</h4> </h4>
{featuredTags.map((tag) => ( <ItemList>
<FeaturedTag key={tag.get('id')} tag={tag} account={acct} /> {featuredTags.map((tag, index) => (
<Article
focusable
key={tag.get('id')}
aria-posinset={index + 1}
aria-setsize={featuredTags.size}
>
<FeaturedTag tag={tag} account={acct} />
</Article>
))} ))}
</ItemList>
</> </>
)} )}
{!featuredAccountIds.isEmpty() && ( {!featuredAccountIds.isEmpty() && (
@@ -159,13 +175,22 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
defaultMessage='Profiles' defaultMessage='Profiles'
/> />
</h4> </h4>
{featuredAccountIds.map((featuredAccountId) => ( <ItemList>
<Account key={featuredAccountId} id={featuredAccountId} /> {featuredAccountIds.map((featuredAccountId, index) => (
<Article
focusable
key={featuredAccountId}
aria-posinset={index + 1}
aria-setsize={featuredAccountIds.size}
>
<Account id={featuredAccountId} />
</Article>
))} ))}
</ItemList>
</> </>
)} )}
<RemoteHint accountId={accountId} /> <RemoteHint accountId={accountId} />
</div> </Scrollable>
</Column> </Column>
); );
}; };

View File

@@ -7,6 +7,7 @@ import { Link } from 'react-router-dom';
import type { ApiCollectionJSON } from 'mastodon/api_types/collections'; import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import { Article } from 'mastodon/components/scrollable_list/components';
import classes from './collection_list_item.module.scss'; import classes from './collection_list_item.module.scss';
import { CollectionMenu } from './collection_menu'; import { CollectionMenu } from './collection_menu';
@@ -68,19 +69,22 @@ export const CollectionMetaData: React.FC<{
export const CollectionListItem: React.FC<{ export const CollectionListItem: React.FC<{
collection: ApiCollectionJSON; collection: ApiCollectionJSON;
withoutBorder?: boolean; withoutBorder?: boolean;
}> = ({ collection, withoutBorder }) => { positionInList: number;
listSize: number;
}> = ({ collection, withoutBorder, positionInList, listSize }) => {
const { id, name } = collection; const { id, name } = collection;
const linkId = useId(); const linkId = useId();
return ( return (
<article <Article
focusable
className={classNames( className={classNames(
classes.wrapper, classes.wrapper,
'focusable',
withoutBorder && classes.wrapperWithoutBorder, withoutBorder && classes.wrapperWithoutBorder,
)} )}
tabIndex={-1}
aria-labelledby={linkId} aria-labelledby={linkId}
aria-posinset={positionInList}
aria-setsize={listSize}
> >
<div className={classes.content}> <div className={classes.content}>
<h2 id={linkId}> <h2 id={linkId}>
@@ -92,6 +96,6 @@ export const CollectionListItem: React.FC<{
</div> </div>
<CollectionMenu context='list' collection={collection} /> <CollectionMenu context='list' collection={collection} />
</article> </Article>
); );
}; };

View File

@@ -19,7 +19,11 @@ import {
LinkedDisplayName, LinkedDisplayName,
} from 'mastodon/components/display_name'; } from 'mastodon/components/display_name';
import { IconButton } from 'mastodon/components/icon_button'; import { IconButton } from 'mastodon/components/icon_button';
import ScrollableList from 'mastodon/components/scrollable_list'; import {
Article,
ItemList,
Scrollable,
} from 'mastodon/components/scrollable_list/components';
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 { me } from 'mastodon/initial_state'; import { me } from 'mastodon/initial_state';
@@ -202,24 +206,27 @@ export const CollectionDetailPage: React.FC<{
multiColumn={multiColumn} multiColumn={multiColumn}
/> />
<ScrollableList <Scrollable>
scrollKey='collection-detail' {collection && <CollectionHeader collection={collection} />}
<ItemList
isLoading={isLoading}
emptyMessage={intl.formatMessage(messages.empty)} emptyMessage={intl.formatMessage(messages.empty)}
showLoading={isLoading}
bindToDocument={!multiColumn}
alwaysPrepend
prepend={
collection ? <CollectionHeader collection={collection} /> : null
}
> >
{collection?.items.map(({ account_id }) => ( {collection?.items.map(({ account_id }, index, items) => (
<CollectionAccountItem <Article
key={account_id} key={account_id}
data-id={account_id}
aria-posinset={index + 1}
aria-setsize={items.length}
>
<CollectionAccountItem
accountId={account_id} accountId={account_id}
collectionOwnerId={collection.account_id} collectionOwnerId={collection.account_id}
/> />
</Article>
))} ))}
</ScrollableList> </ItemList>
</Scrollable>
<Helmet> <Helmet>
<title>{pageTitle}</title> <title>{pageTitle}</title>

View File

@@ -21,7 +21,11 @@ import { EmptyState } from 'mastodon/components/empty_state';
import { FormStack, Combobox } from 'mastodon/components/form_fields'; import { FormStack, Combobox } from 'mastodon/components/form_fields';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button'; import { IconButton } from 'mastodon/components/icon_button';
import ScrollableList from 'mastodon/components/scrollable_list'; import {
Article,
ItemList,
Scrollable,
} from 'mastodon/components/scrollable_list/components';
import { useSearchAccounts } from 'mastodon/features/lists/use_search_accounts'; import { useSearchAccounts } from 'mastodon/features/lists/use_search_accounts';
import { useAccount } from 'mastodon/hooks/useAccount'; import { useAccount } from 'mastodon/hooks/useAccount';
import { me } from 'mastodon/initial_state'; import { me } from 'mastodon/initial_state';
@@ -390,9 +394,8 @@ export const CollectionAccounts: React.FC<{
</Callout> </Callout>
)} )}
<div className={classes.scrollableWrapper}> <Scrollable className={classes.scrollableWrapper}>
<ScrollableList <ItemList
scrollKey='collection-items'
className={classes.scrollableInner} className={classes.scrollableInner}
emptyMessage={ emptyMessage={
<EmptyState <EmptyState
@@ -413,18 +416,22 @@ export const CollectionAccounts: React.FC<{
} }
/> />
} }
// TODO: Re-add `bindToDocument={!multiColumn}`
> >
{accountIds.map((accountId) => ( {accountIds.map((accountId, index) => (
<AddedAccountItem <Article
key={accountId} key={accountId}
aria-posinset={index}
aria-setsize={accountIds.length}
>
<AddedAccountItem
accountId={accountId} accountId={accountId}
isRemovable={!isEditMode || !hasMinAccounts} isRemovable={!isEditMode || !hasMinAccounts}
onRemove={handleRemoveAccountItem} onRemove={handleRemoveAccountItem}
/> />
</Article>
))} ))}
</ScrollableList> </ItemList>
</div> </Scrollable>
</FormStack> </FormStack>
{!isEditMode && ( {!isEditMode && (
<div className={classes.stickyFooter}> <div className={classes.stickyFooter}>

View File

@@ -49,12 +49,7 @@
flex-grow: 1; flex-grow: 1;
} }
.scrollableWrapper { .scrollableWrapper,
display: flex;
flex: 1;
margin-inline: -8px;
}
.scrollableInner { .scrollableInner {
margin-inline: -8px; margin-inline: -8px;
} }

View File

@@ -11,7 +11,10 @@ import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { Column } from 'mastodon/components/column'; import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header'; import { ColumnHeader } from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list'; import {
ItemList,
Scrollable,
} from 'mastodon/components/scrollable_list/components';
import { import {
fetchAccountCollections, fetchAccountCollections,
selectAccountCollections, selectAccountCollections,
@@ -85,16 +88,18 @@ export const Collections: React.FC<{
} }
/> />
<ScrollableList <Scrollable>
scrollKey='collections' <ItemList emptyMessage={emptyMessage} isLoading={status === 'loading'}>
emptyMessage={emptyMessage} {collections.map((item, index) => (
isLoading={status === 'loading'} <CollectionListItem
bindToDocument={!multiColumn} key={item.id}
> collection={item}
{collections.map((item) => ( positionInList={index + 1}
<CollectionListItem key={item.id} collection={item} /> listSize={collections.length}
/>
))} ))}
</ScrollableList> </ItemList>
</Scrollable>
<Helmet> <Helmet>
<title>{intl.formatMessage(messages.heading)}</title> <title>{intl.formatMessage(messages.heading)}</title>

View File

@@ -322,6 +322,7 @@ class EmojiPickerDropdown extends PureComponent {
onPickEmoji: PropTypes.func.isRequired, onPickEmoji: PropTypes.func.isRequired,
onSkinTone: PropTypes.func.isRequired, onSkinTone: PropTypes.func.isRequired,
skinTone: PropTypes.number.isRequired, skinTone: PropTypes.number.isRequired,
disabled: PropTypes.bool,
}; };
state = { state = {
@@ -384,7 +385,7 @@ class EmojiPickerDropdown extends PureComponent {
}; };
render() { render() {
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props; const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, disabled } = this.props;
const title = intl.formatMessage(messages.emoji); const title = intl.formatMessage(messages.emoji);
const { active, loading, placement } = this.state; const { active, loading, placement } = this.state;
@@ -396,6 +397,8 @@ class EmojiPickerDropdown extends PureComponent {
active={active} active={active}
iconComponent={MoodIcon} iconComponent={MoodIcon}
onClick={this.onToggle} onClick={this.onToggle}
disabled={disabled}
id="emoji"
inverted inverted
/> />

View File

@@ -169,7 +169,9 @@ export function focusItemSibling(index: number, direction: 1 | -1) {
} }
// Check if the sibling is a post or a 'follow suggestions' widget // Check if the sibling is a post or a 'follow suggestions' widget
let targetElement = siblingItem.querySelector<HTMLElement>('.focusable'); let targetElement = siblingItem.matches('.focusable')
? siblingItem
: siblingItem.querySelector<HTMLElement>('.focusable');
// Otherwise, check if the item is a 'load more' button. // Otherwise, check if the item is a 'load more' button.
if (!targetElement && siblingItem.matches('.load-more')) { if (!targetElement && siblingItem.matches('.load-more')) {

View File

@@ -276,6 +276,8 @@
"callout.dismiss": "Dismiss", "callout.dismiss": "Dismiss",
"carousel.current": "<sr>Slide</sr> {current, number} / {max, number}", "carousel.current": "<sr>Slide</sr> {current, number} / {max, number}",
"carousel.slide": "Slide {current, number} of {max, number}", "carousel.slide": "Slide {current, number} of {max, number}",
"character_counter.recommended": "{currentLength}/{maxLength} recommended characters",
"character_counter.required": "{currentLength}/{maxLength} characters",
"closed_registrations.other_server_instructions": "Since Mastodon is decentralized, you can create an account on another server and still interact with this one.", "closed_registrations.other_server_instructions": "Since Mastodon is decentralized, you can create an account on another server and still interact with this one.",
"closed_registrations_modal.description": "Creating an account on {domain} is currently not possible, but please keep in mind that you do not need an account specifically on {domain} to use Mastodon.", "closed_registrations_modal.description": "Creating an account on {domain} is currently not possible, but please keep in mind that you do not need an account specifically on {domain} to use Mastodon.",
"closed_registrations_modal.find_another_server": "Find another server", "closed_registrations_modal.find_another_server": "Find another server",

View File

@@ -10,6 +10,7 @@ import jsxA11Y from 'eslint-plugin-jsx-a11y';
import promisePlugin from 'eslint-plugin-promise'; import promisePlugin from 'eslint-plugin-promise';
import react from 'eslint-plugin-react'; import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks'; import reactHooks from 'eslint-plugin-react-hooks';
// @ts-expect-error -- No types available for this package
import storybook from 'eslint-plugin-storybook'; import storybook from 'eslint-plugin-storybook';
import { globalIgnores } from 'eslint/config'; import { globalIgnores } from 'eslint/config';
import globals from 'globals'; import globals from 'globals';
@@ -387,6 +388,7 @@ export default tseslint.config([
files: ['**/*.stories.ts', '**/*.stories.tsx', '.storybook/*'], files: ['**/*.stories.ts', '**/*.stories.tsx', '.storybook/*'],
rules: { rules: {
'import/no-default-export': 'off', 'import/no-default-export': 'off',
'react-hooks/rules-of-hooks': 'off',
}, },
}, },
{ {