mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-28 17:50:01 +01:00
Merge commit 'a70079968c55891e387b1c1e524bb314b9dfb033' into glitch-soc/merge-upstream
This commit is contained in:
@@ -56,7 +56,7 @@ services:
|
||||
- internal_network
|
||||
|
||||
es:
|
||||
image: docker.elastic.co/elasticsearch/elasticsearch-oss:7.10.2
|
||||
image: docker.elastic.co/elasticsearch/elasticsearch:7.17.29
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ES_JAVA_OPTS: -Xms512m -Xmx512m
|
||||
|
||||
4
.github/workflows/test-ruby.yml
vendored
4
.github/workflows/test-ruby.yml
vendored
@@ -352,10 +352,10 @@ jobs:
|
||||
- '3.3'
|
||||
- '.ruby-version'
|
||||
search-image:
|
||||
- docker.elastic.co/elasticsearch/elasticsearch:7.17.13
|
||||
- docker.elastic.co/elasticsearch/elasticsearch:7.17.29
|
||||
include:
|
||||
- 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'
|
||||
search-image: opensearchproject/opensearch:2
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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';
|
||||
@@ -0,0 +1,8 @@
|
||||
.counter {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.counterError {
|
||||
color: var(--color-text-error);
|
||||
}
|
||||
29
app/javascript/mastodon/components/emoji/picker_button.tsx
Normal file
29
app/javascript/mastodon/components/emoji/picker_button.tsx
Normal 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} />;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -5,10 +5,12 @@ import { useContext, useId } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { FieldsetNameContext } from './fieldset';
|
||||
import classes from './form_field_wrapper.module.scss';
|
||||
|
||||
interface InputProps {
|
||||
export interface InputProps {
|
||||
id: string;
|
||||
required?: boolean;
|
||||
'aria-describedby'?: string;
|
||||
@@ -20,8 +22,10 @@ interface FieldWrapperProps {
|
||||
required?: boolean;
|
||||
hasError?: boolean;
|
||||
inputId?: string;
|
||||
describedById?: string;
|
||||
inputPlacement?: 'inline-start' | 'inline-end';
|
||||
children: (inputProps: InputProps) => ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,7 +34,7 @@ interface FieldWrapperProps {
|
||||
export type CommonFieldWrapperProps = Pick<
|
||||
FieldWrapperProps,
|
||||
'label' | 'hint' | 'hasError'
|
||||
>;
|
||||
> & { wrapperClassName?: string };
|
||||
|
||||
/**
|
||||
* 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,
|
||||
label,
|
||||
hint,
|
||||
describedById,
|
||||
required,
|
||||
hasError,
|
||||
inputPlacement,
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
const uniqueId = useId();
|
||||
const inputId = inputIdProp || `${uniqueId}-input`;
|
||||
@@ -59,7 +65,9 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
|
||||
id: inputId,
|
||||
};
|
||||
if (hasHint) {
|
||||
inputProps['aria-describedby'] = hintId;
|
||||
inputProps['aria-describedby'] = describedById
|
||||
? `${describedById} ${hintId}`
|
||||
: hintId;
|
||||
}
|
||||
|
||||
const input = (
|
||||
@@ -68,7 +76,7 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes.wrapper}
|
||||
className={classNames(classes.wrapper, className)}
|
||||
data-has-error={hasError}
|
||||
data-input-placement={inputPlacement}
|
||||
>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { FormFieldWrapper } from './form_field_wrapper';
|
||||
import type { CommonFieldWrapperProps } from './form_field_wrapper';
|
||||
import classes from './text_input.module.scss';
|
||||
|
||||
type TextAreaProps =
|
||||
export type TextAreaProps =
|
||||
| ({ autoSize?: false } & ComponentPropsWithoutRef<'textarea'>)
|
||||
| ({ autoSize: true } & TextareaAutosizeProps);
|
||||
|
||||
@@ -24,17 +24,23 @@ type TextAreaProps =
|
||||
export const TextAreaField = forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
TextAreaProps & CommonFieldWrapperProps
|
||||
>(({ id, label, hint, required, hasError, ...otherProps }, ref) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
inputId={id}
|
||||
>
|
||||
{(inputProps) => <TextArea {...otherProps} {...inputProps} ref={ref} />}
|
||||
</FormFieldWrapper>
|
||||
));
|
||||
>(
|
||||
(
|
||||
{ id, label, hint, required, hasError, wrapperClassName, ...otherProps },
|
||||
ref,
|
||||
) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
inputId={id}
|
||||
className={wrapperClassName}
|
||||
>
|
||||
{(inputProps) => <TextArea {...otherProps} {...inputProps} ref={ref} />}
|
||||
</FormFieldWrapper>
|
||||
),
|
||||
);
|
||||
|
||||
TextAreaField.displayName = 'TextAreaField';
|
||||
|
||||
|
||||
@@ -24,13 +24,17 @@ interface Props extends TextInputProps, CommonFieldWrapperProps {}
|
||||
*/
|
||||
|
||||
export const TextInputField = forwardRef<HTMLInputElement, Props>(
|
||||
({ id, label, hint, hasError, required, ...otherProps }, ref) => (
|
||||
(
|
||||
{ id, label, hint, hasError, required, wrapperClassName, ...otherProps },
|
||||
ref,
|
||||
) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
inputId={id}
|
||||
className={wrapperClassName}
|
||||
>
|
||||
{(inputProps) => <TextInput {...otherProps} {...inputProps} ref={ref} />}
|
||||
</FormFieldWrapper>
|
||||
|
||||
@@ -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';
|
||||
@@ -1,7 +1,6 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { Children, cloneElement, PureComponent } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
@@ -12,13 +11,14 @@ import { throttle } from 'lodash';
|
||||
|
||||
import { ScrollContainer } from 'mastodon/containers/scroll_container';
|
||||
|
||||
import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
|
||||
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
|
||||
import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
|
||||
import IntersectionObserverArticleContainer from '../../containers/intersection_observer_article_container';
|
||||
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../../features/ui/util/fullscreen';
|
||||
import IntersectionObserverWrapper from '../../features/ui/util/intersection_observer_wrapper';
|
||||
|
||||
import { LoadMore } from './load_more';
|
||||
import { LoadPending } from './load_pending';
|
||||
import { LoadingIndicator } from './loading_indicator';
|
||||
import { LoadMore } from '../load_more';
|
||||
import { LoadPending } from '../load_pending';
|
||||
import { LoadingIndicator } from '../loading_indicator';
|
||||
import { Scrollable, ItemList } from './components';
|
||||
|
||||
const MOUSE_IDLE_DELAY = 300;
|
||||
|
||||
@@ -336,24 +336,20 @@ class ScrollableList extends PureComponent {
|
||||
|
||||
if (showLoading) {
|
||||
scrollableArea = (
|
||||
<div className='scrollable scrollable--flex' ref={this.setRef}>
|
||||
<Scrollable ref={this.setRef}>
|
||||
{prepend}
|
||||
|
||||
<div role='feed' className='item-list' />
|
||||
|
||||
<div className='scrollable__append'>
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
<ItemList isLoading />
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
</Scrollable>
|
||||
);
|
||||
} else if (isLoading || childrenCount > 0 || numPending > 0 || hasMore || !emptyMessage) {
|
||||
scrollableArea = (
|
||||
<div className={classNames('scrollable scrollable--flex', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}>
|
||||
<Scrollable fullscreen={fullscreen} ref={this.setRef} onMouseMove={this.handleMouseMove}>
|
||||
{prepend}
|
||||
|
||||
<div role='feed' className={classNames('item-list', className)}>
|
||||
<ItemList className={className}>
|
||||
{loadPending}
|
||||
|
||||
{Children.map(this.props.children, (child, index) => (
|
||||
@@ -378,14 +374,14 @@ class ScrollableList extends PureComponent {
|
||||
{loadMore}
|
||||
|
||||
{!hasMore && append}
|
||||
</div>
|
||||
</ItemList>
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
</Scrollable>
|
||||
);
|
||||
} else {
|
||||
scrollableArea = (
|
||||
<div className={classNames('scrollable scrollable--flex', { fullscreen })} ref={this.setRef}>
|
||||
<Scrollable fullscreen={fullscreen} ref={this.setRef}>
|
||||
{alwaysPrepend && prepend}
|
||||
|
||||
<div className='empty-column-indicator'>
|
||||
@@ -393,7 +389,7 @@ class ScrollableList extends PureComponent {
|
||||
</div>
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
</Scrollable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { cloneElement, Component } from 'react';
|
||||
|
||||
import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
|
||||
import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
|
||||
import getRectFromEntry from '../../features/ui/util/get_rect_from_entry';
|
||||
import scheduleIdleTask from '../../features/ui/util/schedule_idle_task';
|
||||
import { Article } from './components';
|
||||
|
||||
// Diff these props in the "unrendered" state
|
||||
const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
|
||||
@@ -108,23 +109,22 @@ export default class IntersectionObserverArticle extends Component {
|
||||
|
||||
if (!isIntersecting && (isHidden || cachedHeight)) {
|
||||
return (
|
||||
<article
|
||||
<Article
|
||||
ref={this.handleRef}
|
||||
aria-posinset={index + 1}
|
||||
aria-setsize={listLength}
|
||||
style={{ height: `${this.height || cachedHeight}px`, opacity: 0, overflow: 'hidden' }}
|
||||
data-id={id}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{children && cloneElement(children, { hidden: true })}
|
||||
</article>
|
||||
</Article>
|
||||
);
|
||||
}
|
||||
|
||||
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 })}
|
||||
</article>
|
||||
</Article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
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) => ({
|
||||
cachedHeight: state.getIn(['height_cache', props.saveHeightKey, props.id]),
|
||||
|
||||
@@ -12,6 +12,11 @@ import { Account } from 'mastodon/components/account';
|
||||
import { ColumnBackButton } from 'mastodon/components/column_back_button';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
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 BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
|
||||
import Column from 'mastodon/features/ui/components/column';
|
||||
@@ -115,7 +120,7 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
||||
<Column>
|
||||
<ColumnBackButton />
|
||||
|
||||
<div className='scrollable scrollable--flex'>
|
||||
<Scrollable>
|
||||
{accountId && (
|
||||
<AccountHeader accountId={accountId} hideTabs={forceEmptyState} />
|
||||
)}
|
||||
@@ -127,15 +132,17 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
||||
defaultMessage='Collections'
|
||||
/>
|
||||
</h4>
|
||||
<section>
|
||||
<ItemList>
|
||||
{publicCollections.map((item, index) => (
|
||||
<CollectionListItem
|
||||
key={item.id}
|
||||
collection={item}
|
||||
withoutBorder={index === publicCollections.length - 1}
|
||||
positionInList={index + 1}
|
||||
listSize={publicCollections.length}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
</ItemList>
|
||||
</>
|
||||
)}
|
||||
{!featuredTags.isEmpty() && (
|
||||
@@ -146,9 +153,18 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
||||
defaultMessage='Hashtags'
|
||||
/>
|
||||
</h4>
|
||||
{featuredTags.map((tag) => (
|
||||
<FeaturedTag key={tag.get('id')} tag={tag} account={acct} />
|
||||
))}
|
||||
<ItemList>
|
||||
{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() && (
|
||||
@@ -159,13 +175,22 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
||||
defaultMessage='Profiles'
|
||||
/>
|
||||
</h4>
|
||||
{featuredAccountIds.map((featuredAccountId) => (
|
||||
<Account key={featuredAccountId} id={featuredAccountId} />
|
||||
))}
|
||||
<ItemList>
|
||||
{featuredAccountIds.map((featuredAccountId, index) => (
|
||||
<Article
|
||||
focusable
|
||||
key={featuredAccountId}
|
||||
aria-posinset={index + 1}
|
||||
aria-setsize={featuredAccountIds.size}
|
||||
>
|
||||
<Account id={featuredAccountId} />
|
||||
</Article>
|
||||
))}
|
||||
</ItemList>
|
||||
</>
|
||||
)}
|
||||
<RemoteHint accountId={accountId} />
|
||||
</div>
|
||||
</Scrollable>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Link } from 'react-router-dom';
|
||||
|
||||
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
|
||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||
import { Article } from 'mastodon/components/scrollable_list/components';
|
||||
|
||||
import classes from './collection_list_item.module.scss';
|
||||
import { CollectionMenu } from './collection_menu';
|
||||
@@ -68,19 +69,22 @@ export const CollectionMetaData: React.FC<{
|
||||
export const CollectionListItem: React.FC<{
|
||||
collection: ApiCollectionJSON;
|
||||
withoutBorder?: boolean;
|
||||
}> = ({ collection, withoutBorder }) => {
|
||||
positionInList: number;
|
||||
listSize: number;
|
||||
}> = ({ collection, withoutBorder, positionInList, listSize }) => {
|
||||
const { id, name } = collection;
|
||||
const linkId = useId();
|
||||
|
||||
return (
|
||||
<article
|
||||
<Article
|
||||
focusable
|
||||
className={classNames(
|
||||
classes.wrapper,
|
||||
'focusable',
|
||||
withoutBorder && classes.wrapperWithoutBorder,
|
||||
)}
|
||||
tabIndex={-1}
|
||||
aria-labelledby={linkId}
|
||||
aria-posinset={positionInList}
|
||||
aria-setsize={listSize}
|
||||
>
|
||||
<div className={classes.content}>
|
||||
<h2 id={linkId}>
|
||||
@@ -92,6 +96,6 @@ export const CollectionListItem: React.FC<{
|
||||
</div>
|
||||
|
||||
<CollectionMenu context='list' collection={collection} />
|
||||
</article>
|
||||
</Article>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,7 +19,11 @@ import {
|
||||
LinkedDisplayName,
|
||||
} from 'mastodon/components/display_name';
|
||||
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 { useAccount } from 'mastodon/hooks/useAccount';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
@@ -202,24 +206,27 @@ export const CollectionDetailPage: React.FC<{
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='collection-detail'
|
||||
emptyMessage={intl.formatMessage(messages.empty)}
|
||||
showLoading={isLoading}
|
||||
bindToDocument={!multiColumn}
|
||||
alwaysPrepend
|
||||
prepend={
|
||||
collection ? <CollectionHeader collection={collection} /> : null
|
||||
}
|
||||
>
|
||||
{collection?.items.map(({ account_id }) => (
|
||||
<CollectionAccountItem
|
||||
key={account_id}
|
||||
accountId={account_id}
|
||||
collectionOwnerId={collection.account_id}
|
||||
/>
|
||||
))}
|
||||
</ScrollableList>
|
||||
<Scrollable>
|
||||
{collection && <CollectionHeader collection={collection} />}
|
||||
<ItemList
|
||||
isLoading={isLoading}
|
||||
emptyMessage={intl.formatMessage(messages.empty)}
|
||||
>
|
||||
{collection?.items.map(({ account_id }, index, items) => (
|
||||
<Article
|
||||
key={account_id}
|
||||
data-id={account_id}
|
||||
aria-posinset={index + 1}
|
||||
aria-setsize={items.length}
|
||||
>
|
||||
<CollectionAccountItem
|
||||
accountId={account_id}
|
||||
collectionOwnerId={collection.account_id}
|
||||
/>
|
||||
</Article>
|
||||
))}
|
||||
</ItemList>
|
||||
</Scrollable>
|
||||
|
||||
<Helmet>
|
||||
<title>{pageTitle}</title>
|
||||
|
||||
@@ -21,7 +21,11 @@ import { EmptyState } from 'mastodon/components/empty_state';
|
||||
import { FormStack, Combobox } from 'mastodon/components/form_fields';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
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 { useAccount } from 'mastodon/hooks/useAccount';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
@@ -390,9 +394,8 @@ export const CollectionAccounts: React.FC<{
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<div className={classes.scrollableWrapper}>
|
||||
<ScrollableList
|
||||
scrollKey='collection-items'
|
||||
<Scrollable className={classes.scrollableWrapper}>
|
||||
<ItemList
|
||||
className={classes.scrollableInner}
|
||||
emptyMessage={
|
||||
<EmptyState
|
||||
@@ -413,18 +416,22 @@ export const CollectionAccounts: React.FC<{
|
||||
}
|
||||
/>
|
||||
}
|
||||
// TODO: Re-add `bindToDocument={!multiColumn}`
|
||||
>
|
||||
{accountIds.map((accountId) => (
|
||||
<AddedAccountItem
|
||||
{accountIds.map((accountId, index) => (
|
||||
<Article
|
||||
key={accountId}
|
||||
accountId={accountId}
|
||||
isRemovable={!isEditMode || !hasMinAccounts}
|
||||
onRemove={handleRemoveAccountItem}
|
||||
/>
|
||||
aria-posinset={index}
|
||||
aria-setsize={accountIds.length}
|
||||
>
|
||||
<AddedAccountItem
|
||||
accountId={accountId}
|
||||
isRemovable={!isEditMode || !hasMinAccounts}
|
||||
onRemove={handleRemoveAccountItem}
|
||||
/>
|
||||
</Article>
|
||||
))}
|
||||
</ScrollableList>
|
||||
</div>
|
||||
</ItemList>
|
||||
</Scrollable>
|
||||
</FormStack>
|
||||
{!isEditMode && (
|
||||
<div className={classes.stickyFooter}>
|
||||
|
||||
@@ -49,12 +49,7 @@
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.scrollableWrapper {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
margin-inline: -8px;
|
||||
}
|
||||
|
||||
.scrollableWrapper,
|
||||
.scrollableInner {
|
||||
margin-inline: -8px;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import {
|
||||
ItemList,
|
||||
Scrollable,
|
||||
} from 'mastodon/components/scrollable_list/components';
|
||||
import {
|
||||
fetchAccountCollections,
|
||||
selectAccountCollections,
|
||||
@@ -85,16 +88,18 @@ export const Collections: React.FC<{
|
||||
}
|
||||
/>
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='collections'
|
||||
emptyMessage={emptyMessage}
|
||||
isLoading={status === 'loading'}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{collections.map((item) => (
|
||||
<CollectionListItem key={item.id} collection={item} />
|
||||
))}
|
||||
</ScrollableList>
|
||||
<Scrollable>
|
||||
<ItemList emptyMessage={emptyMessage} isLoading={status === 'loading'}>
|
||||
{collections.map((item, index) => (
|
||||
<CollectionListItem
|
||||
key={item.id}
|
||||
collection={item}
|
||||
positionInList={index + 1}
|
||||
listSize={collections.length}
|
||||
/>
|
||||
))}
|
||||
</ItemList>
|
||||
</Scrollable>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.heading)}</title>
|
||||
|
||||
@@ -322,6 +322,7 @@ class EmojiPickerDropdown extends PureComponent {
|
||||
onPickEmoji: PropTypes.func.isRequired,
|
||||
onSkinTone: PropTypes.func.isRequired,
|
||||
skinTone: PropTypes.number.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
state = {
|
||||
@@ -384,7 +385,7 @@ class EmojiPickerDropdown extends PureComponent {
|
||||
};
|
||||
|
||||
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 { active, loading, placement } = this.state;
|
||||
|
||||
@@ -396,6 +397,8 @@ class EmojiPickerDropdown extends PureComponent {
|
||||
active={active}
|
||||
iconComponent={MoodIcon}
|
||||
onClick={this.onToggle}
|
||||
disabled={disabled}
|
||||
id="emoji"
|
||||
inverted
|
||||
/>
|
||||
|
||||
|
||||
@@ -169,7 +169,9 @@ export function focusItemSibling(index: number, direction: 1 | -1) {
|
||||
}
|
||||
|
||||
// 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.
|
||||
if (!targetElement && siblingItem.matches('.load-more')) {
|
||||
|
||||
@@ -276,6 +276,8 @@
|
||||
"callout.dismiss": "Dismiss",
|
||||
"carousel.current": "<sr>Slide</sr> {current, number} / {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_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",
|
||||
|
||||
@@ -10,6 +10,7 @@ import jsxA11Y from 'eslint-plugin-jsx-a11y';
|
||||
import promisePlugin from 'eslint-plugin-promise';
|
||||
import react from 'eslint-plugin-react';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
// @ts-expect-error -- No types available for this package
|
||||
import storybook from 'eslint-plugin-storybook';
|
||||
import { globalIgnores } from 'eslint/config';
|
||||
import globals from 'globals';
|
||||
@@ -387,6 +388,7 @@ export default tseslint.config([
|
||||
files: ['**/*.stories.ts', '**/*.stories.tsx', '.storybook/*'],
|
||||
rules: {
|
||||
'import/no-default-export': 'off',
|
||||
'react-hooks/rules-of-hooks': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user