Merge remote-tracking branch 'soapbox/develop' into cleanup

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak
2023-02-12 22:26:55 +01:00
71 changed files with 1700 additions and 940 deletions

View File

@ -1,16 +0,0 @@
import React from 'react';
import { render, screen } from '../../jest/test-helpers';
import EmojiSelector from '../emoji-selector';
describe('<EmojiSelector />', () => {
it('renders correctly', () => {
const children = <EmojiSelector />;
// @ts-ignore
children.__proto__.addEventListener = () => {};
render(children);
expect(screen.queryAllByRole('button')).toHaveLength(6);
});
});

View File

@ -1,54 +0,0 @@
import React from 'react';
import { HotKeys } from 'react-hotkeys';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import { EmojiSelector as RealEmojiSelector } from 'soapbox/components/ui';
import type { List as ImmutableList } from 'immutable';
import type { RootState } from 'soapbox/store';
const mapStateToProps = (state: RootState) => ({
allowedEmoji: getSoapboxConfig(state).allowedEmoji,
});
interface IEmojiSelector {
allowedEmoji: ImmutableList<string>,
onReact: (emoji: string) => void,
onUnfocus: () => void,
visible: boolean,
focused?: boolean,
}
class EmojiSelector extends ImmutablePureComponent<IEmojiSelector> {
static defaultProps: Partial<IEmojiSelector> = {
onReact: () => { },
onUnfocus: () => { },
visible: false,
};
handlers = {
open: () => { },
};
render() {
const { visible, focused, allowedEmoji, onReact, onUnfocus } = this.props;
return (
<HotKeys handlers={this.handlers}>
<RealEmojiSelector
emojis={allowedEmoji.toArray()}
onReact={onReact}
visible={visible}
focused={focused}
onUnfocus={onUnfocus}
/>
</HotKeys>
);
}
}
export default connect(mapStateToProps)(EmojiSelector);

View File

@ -14,8 +14,8 @@ import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions
import { initMuteModal } from 'soapbox/actions/mutes';
import { initReport } from 'soapbox/actions/reports';
import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses';
import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper';
import StatusActionButton from 'soapbox/components/status-action-button';
import StatusReactionWrapper from 'soapbox/components/status-reaction-wrapper';
import { HStack } from 'soapbox/components/ui';
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks';
@ -629,7 +629,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
)}
{features.emojiReacts ? (
<EmojiButtonWrapper statusId={status.id}>
<StatusReactionWrapper statusId={status.id}>
<StatusActionButton
title={meEmojiTitle}
icon={require('@tabler/icons/heart.svg')}
@ -640,7 +640,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
emoji={meEmojiReact}
text={withLabels ? meEmojiTitle : undefined}
/>
</EmojiButtonWrapper>
</StatusReactionWrapper>
) : (
<StatusActionButton
title={intl.formatMessage(messages.favourite)}

View File

@ -1,6 +1,4 @@
import clsx from 'clsx';
import React, { useState, useEffect, useRef } from 'react';
import { usePopper } from 'react-popper';
import { simpleEmojiReact } from 'soapbox/actions/emoji-reacts';
import { openModal } from 'soapbox/actions/modals';
@ -9,13 +7,13 @@ import { useAppDispatch, useAppSelector, useOwnAccount, useSoapboxConfig } from
import { isUserTouching } from 'soapbox/is-mobile';
import { getReactForStatus } from 'soapbox/utils/emoji-reacts';
interface IEmojiButtonWrapper {
interface IStatusReactionWrapper {
statusId: string,
children: JSX.Element,
}
/** Provides emoji reaction functionality to the underlying button component */
const EmojiButtonWrapper: React.FC<IEmojiButtonWrapper> = ({ statusId, children }): JSX.Element | null => {
const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, children }): JSX.Element | null => {
const dispatch = useAppDispatch();
const ownAccount = useOwnAccount();
const status = useAppSelector(state => state.statuses.get(statusId));
@ -23,24 +21,8 @@ const EmojiButtonWrapper: React.FC<IEmojiButtonWrapper> = ({ statusId, children
const timeout = useRef<NodeJS.Timeout>();
const [visible, setVisible] = useState(false);
// const [focused, setFocused] = useState(false);
// `useRef` won't trigger a re-render, while `useState` does.
// https://popper.js.org/react-popper/v2/
const [referenceElement, setReferenceElement] = useState<HTMLDivElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: 'top-start',
modifiers: [
{
name: 'offset',
options: {
offset: [-10, 0],
},
},
],
});
useEffect(() => {
return () => {
@ -116,29 +98,6 @@ const EmojiButtonWrapper: React.FC<IEmojiButtonWrapper> = ({ statusId, children
}));
};
const handleUnfocus: React.EventHandler<React.KeyboardEvent> = () => {
setVisible(false);
};
const selector = (
<div
className={clsx('z-50 transition-opacity duration-100', {
'opacity-0 pointer-events-none': !visible,
})}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<EmojiSelector
emojis={soapboxConfig.allowedEmoji}
onReact={handleReact}
visible={visible}
// focused={focused}
onUnfocus={handleUnfocus}
/>
</div>
);
return (
<div className='relative' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
{React.cloneElement(children, {
@ -146,9 +105,14 @@ const EmojiButtonWrapper: React.FC<IEmojiButtonWrapper> = ({ statusId, children
ref: setReferenceElement,
})}
{selector}
<EmojiSelector
placement='top-start'
referenceElement={referenceElement}
onReact={handleReact}
visible={visible}
/>
</div>
);
};
export default EmojiButtonWrapper;
export default StatusReactionWrapper;

View File

@ -289,8 +289,10 @@ const Status: React.FC<IStatus> = (props) => {
return (
<HotKeys handlers={minHandlers}>
<div className={clsx('status__wrapper', 'status__wrapper--filtered', { focusable })} tabIndex={focusable ? 0 : undefined} ref={node}>
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />
<div className={clsx('status__wrapper text-center', { focusable })} tabIndex={focusable ? 0 : undefined} ref={node}>
<Text theme='muted'>
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />
</Text>
</div>
</HotKeys>
);

View File

@ -1,15 +1,16 @@
import { Placement } from '@popperjs/core';
import clsx from 'clsx';
import React, { useRef } from 'react';
import React, { useEffect, useState } from 'react';
import { usePopper } from 'react-popper';
import { Emoji, HStack } from 'soapbox/components/ui';
import { useSoapboxConfig } from 'soapbox/hooks';
interface IEmojiButton {
/** Unicode emoji character. */
emoji: string,
/** Event handler when the emoji is clicked. */
onClick: React.EventHandler<React.MouseEvent>,
/** Keyboard event handler. */
onKeyDown?: React.EventHandler<React.KeyboardEvent>,
onClick(emoji: string): void
/** Extra class name on the <button> element. */
className?: string,
/** Tab order of the button. */
@ -17,104 +18,104 @@ interface IEmojiButton {
}
/** Clickable emoji button that scales when hovered. */
const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, onKeyDown, tabIndex }): JSX.Element => {
const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabIndex }): JSX.Element => {
const handleClick: React.EventHandler<React.MouseEvent> = (event) => {
event.preventDefault();
event.stopPropagation();
onClick(emoji);
};
return (
<button className={clsx(className)} onClick={onClick} tabIndex={tabIndex}>
<Emoji className='h-8 w-8 duration-100' emoji={emoji} />
<button className={clsx(className)} onClick={handleClick} tabIndex={tabIndex}>
<Emoji className='h-6 w-6 duration-100 hover:scale-110' emoji={emoji} />
</button>
);
};
interface IEmojiSelector {
/** List of Unicode emoji characters. */
emojis: Iterable<string>,
onClose?(): void
/** Event handler when an emoji is clicked. */
onReact: (emoji: string) => void,
/** Event handler when selector is escaped. */
onUnfocus: React.KeyboardEventHandler<HTMLDivElement>,
onReact(emoji: string): void
/** Element that triggers the EmojiSelector Popper */
referenceElement: HTMLElement | null
placement?: Placement
/** Whether the selector should be visible. */
visible?: boolean,
/** Whether the selector should be focused. */
focused?: boolean,
visible?: boolean
}
/** Panel with a row of emoji buttons. */
const EmojiSelector: React.FC<IEmojiSelector> = ({ emojis, onReact, onUnfocus, visible = false, focused = false }): JSX.Element => {
const emojiList = Array.from(emojis);
const node = useRef<HTMLDivElement>(null);
const EmojiSelector: React.FC<IEmojiSelector> = ({
referenceElement,
onClose,
onReact,
placement = 'top',
visible = false,
}): JSX.Element => {
const soapboxConfig = useSoapboxConfig();
const handleReact = (emoji: string): React.EventHandler<React.MouseEvent> => {
return (e) => {
onReact(emoji);
e.preventDefault();
e.stopPropagation();
// `useRef` won't trigger a re-render, while `useState` does.
// https://popper.js.org/react-popper/v2/
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const handleClickOutside = (event: MouseEvent) => {
if (referenceElement?.contains(event.target as Node) || popperElement?.contains(event.target as Node)) {
return;
}
if (onClose) {
onClose();
}
};
const { styles, attributes, update } = usePopper(referenceElement, popperElement, {
placement,
modifiers: [
{
name: 'offset',
options: {
offset: [-10, 0],
},
},
],
});
useEffect(() => {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
};
}, [referenceElement]);
const selectPreviousEmoji = (i: number): void => {
if (!node.current) return;
if (i !== 0) {
const button: HTMLButtonElement | null = node.current.querySelector(`.emoji-react-selector__emoji:nth-child(${i})`);
button?.focus();
} else {
const button: HTMLButtonElement | null = node.current.querySelector('.emoji-react-selector__emoji:last-child');
button?.focus();
useEffect(() => {
if (visible && update) {
update();
}
};
const selectNextEmoji = (i: number) => {
if (!node.current) return;
if (i !== emojiList.length - 1) {
const button: HTMLButtonElement | null = node.current.querySelector(`.emoji-react-selector__emoji:nth-child(${i + 2})`);
button?.focus();
} else {
const button: HTMLButtonElement | null = node.current.querySelector('.emoji-react-selector__emoji:first-child');
button?.focus();
}
};
const handleKeyDown = (i: number): React.KeyboardEventHandler<HTMLDivElement> => e => {
switch (e.key) {
case 'Enter':
handleReact(emojiList[i])(e as any);
break;
case 'Tab':
e.preventDefault();
if (e.shiftKey) selectPreviousEmoji(i);
else selectNextEmoji(i);
break;
case 'Left':
case 'ArrowLeft':
selectPreviousEmoji(i);
break;
case 'Right':
case 'ArrowRight':
selectNextEmoji(i);
break;
case 'Escape':
onUnfocus(e);
break;
}
};
}, [visible, update]);
return (
<HStack
className={clsx('emoji-react-selector z-[999] w-max max-w-[100vw] flex-wrap gap-2 rounded-full bg-white p-3 shadow-md dark:bg-gray-900')}
ref={node}
<div
className={clsx('z-50 transition-opacity duration-100', {
'opacity-0 pointer-events-none': !visible,
})}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
{emojiList.map((emoji, i) => (
<EmojiButton
className='emoji-react-selector__emoji hover:scale-125 focus:scale-125'
key={i}
emoji={emoji}
onClick={handleReact(emoji)}
onKeyDown={handleKeyDown(i)}
tabIndex={(visible || focused) ? 0 : -1}
/>
))}
</HStack>
<HStack
className={clsx('z-[999] flex w-max max-w-[100vw] flex-wrap space-x-3 rounded-full bg-white px-3 py-2.5 shadow-lg focus:outline-none dark:bg-gray-900 dark:ring-2 dark:ring-primary-700')}
>
{Array.from(soapboxConfig.allowedEmoji).map((emoji, i) => (
<EmojiButton
key={i}
emoji={emoji}
onClick={onReact}
tabIndex={visible ? 0 : -1}
/>
))}
</HStack>
</div>
);
};

View File

@ -15,6 +15,8 @@ interface IIconButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {
transparent?: boolean,
/** Predefined styles to display for the button. */
theme?: 'seamless' | 'outlined',
/** Override the data-testid */
'data-testid'?: string
}
/** A clickable icon. */
@ -31,7 +33,7 @@ const IconButton = React.forwardRef((props: IIconButton, ref: React.ForwardedRef
'opacity-50': filteredProps.disabled,
}, className)}
{...filteredProps}
data-testid='icon-button'
data-testid={filteredProps['data-testid'] || 'icon-button'}
>
<SvgIcon src={src} className={iconClassName} />

View File

@ -1,13 +1,32 @@
import clsx from 'clsx';
import React from 'react';
import { spring } from 'react-motion';
import Motion from 'soapbox/features/ui/util/optional-motion';
interface IProgressBar {
progress: number,
/** Number between 0 and 1 to represent the percentage complete. */
progress: number
/** Height of the progress bar. */
size?: 'sm' | 'md'
}
/** A horizontal meter filled to the given percentage. */
const ProgressBar: React.FC<IProgressBar> = ({ progress }) => (
<div className='h-2.5 w-full overflow-hidden rounded-full bg-gray-300 dark:bg-primary-800'>
<div className='h-full bg-secondary-500' style={{ width: `${Math.floor(progress * 100)}%` }} />
const ProgressBar: React.FC<IProgressBar> = ({ progress, size = 'md' }) => (
<div
className={clsx('h-2.5 w-full overflow-hidden rounded-lg bg-gray-300 dark:bg-primary-800', {
'h-2.5': size === 'md',
'h-[6px]': size === 'sm',
})}
>
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress * 100) }}>
{({ width }) => (
<div
className='h-full bg-secondary-500'
style={{ width: `${width}%` }}
/>
)}
</Motion>
</div>
);

View File

@ -46,7 +46,7 @@ const AnimatedTabs: React.FC<IAnimatedInterface> = ({ children, ...rest }) => {
ref={ref}
>
<div
className='absolute h-[3px] w-full bg-primary-200 dark:bg-primary-700'
className='absolute h-[3px] w-full bg-primary-200 dark:bg-gray-800'
style={{ top }}
/>
<div

View File

@ -26,6 +26,8 @@ interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElemen
hasError?: boolean,
/** Whether or not you can resize the teztarea */
isResizeable?: boolean,
/** Textarea theme. */
theme?: 'default' | 'transparent',
}
/** Textarea with custom styles. */
@ -37,6 +39,7 @@ const Textarea = React.forwardRef(({
autoGrow = false,
maxRows = 10,
minRows = 1,
theme = 'default',
...props
}: ITextarea, ref: React.ForwardedRef<HTMLTextAreaElement>) => {
const [rows, setRows] = useState<number>(autoGrow ? 1 : 4);
@ -72,9 +75,10 @@ const Textarea = React.forwardRef(({
ref={ref}
rows={rows}
onChange={handleChange}
className={clsx({
'bg-white dark:bg-transparent shadow-sm block w-full sm:text-sm rounded-md text-gray-900 dark:text-gray-100 placeholder:text-gray-600 dark:placeholder:text-gray-600 border-gray-400 dark:border-gray-800 dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500':
true,
className={clsx('block w-full rounded-md text-gray-900 placeholder:text-gray-600 dark:text-gray-100 dark:placeholder:text-gray-600 sm:text-sm', {
'bg-white dark:bg-transparent shadow-sm border-gray-400 dark:border-gray-800 dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500':
theme === 'default',
'bg-transparent border-0 focus:border-0 focus:ring-0': theme === 'transparent',
'font-mono': isCodeEditor,
'text-red-600 border-red-600': hasError,
'resize-none': !isResizeable,

View File

@ -1,13 +1,11 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { spring } from 'react-motion';
import { HStack, Icon, Stack, Text } from 'soapbox/components/ui';
import Motion from 'soapbox/features/ui/util/optional-motion';
import { HStack, Icon, ProgressBar, Stack, Text } from 'soapbox/components/ui';
interface IUploadProgress {
/** Number between 0 and 1 to represent the percentage complete. */
progress: number,
/** Number between 0 and 100 to represent the percentage complete. */
progress: number
}
/** Displays a progress bar for uploading files. */
@ -24,16 +22,7 @@ const UploadProgress: React.FC<IUploadProgress> = ({ progress }) => {
<FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />
</Text>
<div className='relative h-1.5 w-full rounded-lg bg-gray-200'>
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
{({ width }) =>
(<div
className='absolute left-0 top-0 h-1.5 rounded-lg bg-primary-600'
style={{ width: `${width}%` }}
/>)
}
</Motion>
</div>
<ProgressBar progress={progress / 100} size='sm' />
</Stack>
</HStack>
);