From c49aec2ae0d5695dde17067e0c39c3be102b7875 Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 4 Oct 2022 15:08:22 -0400 Subject: [PATCH 01/50] Refactor UI library types --- .../components/ui/button/useButtonStyles.ts | 50 +++++++++---------- app/soapbox/components/ui/card/card.tsx | 8 +-- app/soapbox/components/ui/hstack/hstack.tsx | 14 +++--- app/soapbox/components/ui/modal/modal.tsx | 4 +- app/soapbox/components/ui/stack/stack.tsx | 12 ++--- app/soapbox/components/ui/text/text.tsx | 26 ++++------ 6 files changed, 52 insertions(+), 62 deletions(-) diff --git a/app/soapbox/components/ui/button/useButtonStyles.ts b/app/soapbox/components/ui/button/useButtonStyles.ts index ecec3de1f..4dc38997d 100644 --- a/app/soapbox/components/ui/button/useButtonStyles.ts +++ b/app/soapbox/components/ui/button/useButtonStyles.ts @@ -1,12 +1,32 @@ import classNames from 'clsx'; -type ButtonThemes = 'primary' | 'secondary' | 'tertiary' | 'accent' | 'danger' | 'transparent' | 'outline' -type ButtonSizes = 'sm' | 'md' | 'lg' +const themes = { + primary: + 'bg-primary-500 hover:bg-primary-400 dark:hover:bg-primary-600 border-transparent focus:bg-primary-500 text-gray-100 focus:ring-primary-300', + secondary: + 'border-transparent bg-primary-100 dark:bg-primary-800 hover:bg-primary-50 dark:hover:bg-primary-700 focus:bg-primary-100 dark:focus:bg-primary-800 text-primary-500 dark:text-primary-200', + tertiary: + 'bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500', + accent: 'border-transparent bg-secondary-500 hover:bg-secondary-400 focus:bg-secondary-500 text-gray-100 focus:ring-secondary-300', + danger: 'border-transparent bg-danger-100 dark:bg-danger-900 text-danger-600 dark:text-danger-200 hover:bg-danger-600 hover:text-gray-100 dark:hover:text-gray-100 dark:hover:bg-danger-500 focus:bg-danger-800 dark:focus:bg-danger-600', + transparent: 'border-transparent text-gray-800 backdrop-blur-sm bg-white/75 hover:bg-white/80', + outline: 'border-gray-100 border-2 bg-transparent text-gray-100 hover:bg-white/10', +}; + +const sizes = { + xs: 'px-3 py-1 text-xs', + sm: 'px-3 py-1.5 text-xs leading-4', + md: 'px-4 py-2 text-sm', + lg: 'px-6 py-3 text-base', +}; + +type ButtonSizes = keyof typeof sizes +type ButtonThemes = keyof typeof themes type IButtonStyles = { - theme: ButtonThemes, - block: boolean, - disabled: boolean, + theme: ButtonThemes + block: boolean + disabled: boolean size: ButtonSizes } @@ -17,26 +37,6 @@ const useButtonStyles = ({ disabled, size, }: IButtonStyles) => { - const themes = { - primary: - 'bg-primary-500 hover:bg-primary-400 dark:hover:bg-primary-600 border-transparent focus:bg-primary-500 text-gray-100 focus:ring-primary-300', - secondary: - 'border-transparent bg-primary-100 dark:bg-primary-800 hover:bg-primary-50 dark:hover:bg-primary-700 focus:bg-primary-100 dark:focus:bg-primary-800 text-primary-500 dark:text-primary-200', - tertiary: - 'bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500', - accent: 'border-transparent bg-secondary-500 hover:bg-secondary-400 focus:bg-secondary-500 text-gray-100 focus:ring-secondary-300', - danger: 'border-transparent bg-danger-100 dark:bg-danger-900 text-danger-600 dark:text-danger-200 hover:bg-danger-600 hover:text-gray-100 dark:hover:text-gray-100 dark:hover:bg-danger-500 focus:bg-danger-800 dark:focus:bg-danger-600', - transparent: 'border-transparent text-gray-800 backdrop-blur-sm bg-white/75 hover:bg-white/80', - outline: 'border-gray-100 border-2 bg-transparent text-gray-100 hover:bg-white/10', - }; - - const sizes = { - xs: 'px-3 py-1 text-xs', - sm: 'px-3 py-1.5 text-xs leading-4', - md: 'px-4 py-2 text-sm', - lg: 'px-6 py-3 text-base', - }; - const buttonStyle = classNames({ 'inline-flex items-center border font-medium rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 appearance-none transition-all': true, 'select-none disabled:opacity-75 disabled:cursor-default': disabled, diff --git a/app/soapbox/components/ui/card/card.tsx b/app/soapbox/components/ui/card/card.tsx index 816627326..59f6ee1bc 100644 --- a/app/soapbox/components/ui/card/card.tsx +++ b/app/soapbox/components/ui/card/card.tsx @@ -18,13 +18,13 @@ const messages = defineMessages({ interface ICard { /** The type of card. */ - variant?: 'default' | 'rounded', + variant?: 'default' | 'rounded' /** Card size preset. */ - size?: 'md' | 'lg' | 'xl', + size?: keyof typeof sizes /** Extra classnames for the
element. */ - className?: string, + className?: string /** Elements inside the card. */ - children: React.ReactNode, + children: React.ReactNode } /** An opaque backdrop to hold a collection of related elements. */ diff --git a/app/soapbox/components/ui/hstack/hstack.tsx b/app/soapbox/components/ui/hstack/hstack.tsx index f959cdd51..994da6aff 100644 --- a/app/soapbox/components/ui/hstack/hstack.tsx +++ b/app/soapbox/components/ui/hstack/hstack.tsx @@ -29,21 +29,21 @@ const spaces = { interface IHStack { /** Vertical alignment of children. */ - alignItems?: 'top' | 'bottom' | 'center' | 'start', + alignItems?: keyof typeof alignItemsOptions /** Extra class names on the
element. */ - className?: string, + className?: string /** Children */ - children?: React.ReactNode, + children?: React.ReactNode /** Horizontal alignment of children. */ - justifyContent?: 'between' | 'center' | 'start' | 'end' | 'around', + justifyContent?: keyof typeof justifyContentOptions /** Size of the gap between elements. */ - space?: 0.5 | 1 | 1.5 | 2 | 3 | 4 | 6 | 8, + space?: keyof typeof spaces /** Whether to let the flexbox grow. */ - grow?: boolean, + grow?: boolean /** Extra CSS styles for the
*/ style?: React.CSSProperties /** Whether to let the flexbox wrap onto multiple lines. */ - wrap?: boolean, + wrap?: boolean } /** Horizontal row of child elements. */ diff --git a/app/soapbox/components/ui/modal/modal.tsx b/app/soapbox/components/ui/modal/modal.tsx index e203a1460..969f7ae65 100644 --- a/app/soapbox/components/ui/modal/modal.tsx +++ b/app/soapbox/components/ui/modal/modal.tsx @@ -10,8 +10,6 @@ const messages = defineMessages({ confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, }); -type Widths = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' - const widths = { xs: 'max-w-xs', sm: 'max-w-sm', @@ -51,7 +49,7 @@ interface IModal { skipFocus?: boolean, /** Title text for the modal. */ title?: React.ReactNode, - width?: Widths, + width?: keyof typeof widths, } /** Displays a modal dialog box. */ diff --git a/app/soapbox/components/ui/stack/stack.tsx b/app/soapbox/components/ui/stack/stack.tsx index 64257ecf9..5f60f553f 100644 --- a/app/soapbox/components/ui/stack/stack.tsx +++ b/app/soapbox/components/ui/stack/stack.tsx @@ -1,8 +1,6 @@ import classNames from 'clsx'; import React from 'react'; -type SIZES = 0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 10 - const spaces = { 0: 'space-y-0', '0.5': 'space-y-0.5', @@ -25,15 +23,15 @@ const alignItemsOptions = { interface IStack extends React.HTMLAttributes { /** Size of the gap between elements. */ - space?: SIZES, + space?: keyof typeof spaces /** Horizontal alignment of children. */ - alignItems?: 'center', + alignItems?: 'center' /** Vertical alignment of children. */ - justifyContent?: 'center', + justifyContent?: 'center' /** Extra class names on the
element. */ - className?: string, + className?: string /** Whether to let the flexbox grow. */ - grow?: boolean, + grow?: boolean } /** Vertical stack of child elements. */ diff --git a/app/soapbox/components/ui/text/text.tsx b/app/soapbox/components/ui/text/text.tsx index 2e0736809..7669f3d2a 100644 --- a/app/soapbox/components/ui/text/text.tsx +++ b/app/soapbox/components/ui/text/text.tsx @@ -1,16 +1,6 @@ import classNames from 'clsx'; import React from 'react'; -type Themes = 'default' | 'danger' | 'primary' | 'muted' | 'subtle' | 'success' | 'inherit' | 'white' -type Weights = 'normal' | 'medium' | 'semibold' | 'bold' -export type Sizes = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' -type Alignments = 'left' | 'center' | 'right' -type TrackingSizes = 'normal' | 'wide' -type TransformProperties = 'uppercase' | 'normal' -type Families = 'sans' | 'mono' -type Tags = 'abbr' | 'p' | 'span' | 'pre' | 'time' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'label' -type Directions = 'ltr' | 'rtl' - const themes = { default: 'text-gray-900 dark:text-gray-100', danger: 'text-danger-600', @@ -60,15 +50,19 @@ const families = { mono: 'font-mono', }; +export type Sizes = keyof typeof sizes +type Tags = 'abbr' | 'p' | 'span' | 'pre' | 'time' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'label' +type Directions = 'ltr' | 'rtl' + interface IText extends Pick, 'dangerouslySetInnerHTML'> { /** How to align the text. */ - align?: Alignments, + align?: keyof typeof alignments, /** Extra class names for the outer element. */ className?: string, /** Text direction. */ direction?: Directions, /** Typeface of the text. */ - family?: Families, + family?: keyof typeof families, /** The "for" attribute specifies which form element a label is bound to. */ htmlFor?: string, /** Font size of the text. */ @@ -76,15 +70,15 @@ interface IText extends Pick, 'danger /** HTML element name of the outer element. */ tag?: Tags, /** Theme for the text. */ - theme?: Themes, + theme?: keyof typeof themes, /** Letter-spacing of the text. */ - tracking?: TrackingSizes, + tracking?: keyof typeof trackingSizes, /** Transform (eg uppercase) for the text. */ - transform?: TransformProperties, + transform?: keyof typeof transformProperties, /** Whether to truncate the text if its container is too small. */ truncate?: boolean, /** Font weight of the text. */ - weight?: Weights, + weight?: keyof typeof weights, /** Tooltip title. */ title?: string, } From c960ad9d33ff48ce9d9f05fef94bbd2e0427a5d7 Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 4 Oct 2022 15:17:26 -0400 Subject: [PATCH 02/50] Ensure space is number --- app/soapbox/components/ui/hstack/hstack.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/components/ui/hstack/hstack.tsx b/app/soapbox/components/ui/hstack/hstack.tsx index 994da6aff..a109da608 100644 --- a/app/soapbox/components/ui/hstack/hstack.tsx +++ b/app/soapbox/components/ui/hstack/hstack.tsx @@ -17,7 +17,7 @@ const alignItemsOptions = { }; const spaces = { - '0.5': 'space-x-0.5', + [0.5]: 'space-x-0.5', 1: 'space-x-1', 1.5: 'space-x-1.5', 2: 'space-x-2', From 1c55e60055abbdca49cb07617b04f4a8c8ce2fee Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 4 Oct 2022 15:17:51 -0400 Subject: [PATCH 03/50] Ensure space is number --- app/soapbox/components/ui/stack/stack.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/soapbox/components/ui/stack/stack.tsx b/app/soapbox/components/ui/stack/stack.tsx index 5f60f553f..b161d4949 100644 --- a/app/soapbox/components/ui/stack/stack.tsx +++ b/app/soapbox/components/ui/stack/stack.tsx @@ -3,9 +3,9 @@ import React from 'react'; const spaces = { 0: 'space-y-0', - '0.5': 'space-y-0.5', + [0.5]: 'space-y-0.5', 1: 'space-y-1', - '1.5': 'space-y-1.5', + [1.5]: 'space-y-1.5', 2: 'space-y-2', 3: 'space-y-3', 4: 'space-y-4', From 61dd57ab81057af963106a04de379d2459540b9c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 4 Oct 2022 16:39:51 -0400 Subject: [PATCH 04/50] AutosuggestInput: use UI input component --- app/soapbox/components/autosuggest_input.tsx | 7 +++---- app/soapbox/components/ui/input/input.tsx | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/soapbox/components/autosuggest_input.tsx b/app/soapbox/components/autosuggest_input.tsx index 744160b2b..d29cd5112 100644 --- a/app/soapbox/components/autosuggest_input.tsx +++ b/app/soapbox/components/autosuggest_input.tsx @@ -6,6 +6,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import AutosuggestEmoji, { Emoji } from 'soapbox/components/autosuggest_emoji'; import Icon from 'soapbox/components/icon'; +import { Input } from 'soapbox/components/ui'; import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest_account'; import { isRtl } from 'soapbox/rtl'; @@ -298,11 +299,9 @@ export default class AutosuggestInput extends ImmutablePureComponent - , 'maxLength' | 'onChange' | 'onBlur' | 'type' | 'autoComplete' | 'autoCorrect' | 'autoCapitalize' | 'required' | 'disabled' | 'onClick' | 'readOnly' | 'min' | 'pattern'> { +interface IInput extends Pick, 'maxLength' | 'onChange' | 'onBlur' | 'type' | 'autoComplete' | 'autoCorrect' | 'autoCapitalize' | 'required' | 'disabled' | 'onClick' | 'readOnly' | 'min' | 'pattern' | 'onKeyDown' | 'onKeyUp' | 'onFocus' | 'style' | 'id'> { /** Put the cursor into the input on mount. */ autoFocus?: boolean, /** The initial text in the input. */ From b9e0e94587dc6eef9d9cee75d12f24f8ef58478e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 4 Oct 2022 17:00:14 -0400 Subject: [PATCH 05/50] Input: support 'theme' prop (deprecate 'isSearch'), pass theme down from higher components --- .../components/autosuggest_account_input.tsx | 2 ++ app/soapbox/components/autosuggest_input.tsx | 5 ++++- app/soapbox/components/ui/input/input.tsx | 22 ++++++++++++------- .../features/compose/components/search.tsx | 5 +++-- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/app/soapbox/components/autosuggest_account_input.tsx b/app/soapbox/components/autosuggest_account_input.tsx index 79d247fd9..e8cd63830 100644 --- a/app/soapbox/components/autosuggest_account_input.tsx +++ b/app/soapbox/components/autosuggest_account_input.tsx @@ -7,6 +7,7 @@ import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest import { useAppDispatch } from 'soapbox/hooks'; import type { Menu } from 'soapbox/components/dropdown_menu'; +import type { InputThemes } from 'soapbox/components/ui/input/input'; const noOp = () => {}; @@ -19,6 +20,7 @@ interface IAutosuggestAccountInput { autoSelect?: boolean, menu?: Menu, onKeyDown?: React.KeyboardEventHandler, + theme?: InputThemes, } const AutosuggestAccountInput: React.FC = ({ diff --git a/app/soapbox/components/autosuggest_input.tsx b/app/soapbox/components/autosuggest_input.tsx index d29cd5112..0c272648d 100644 --- a/app/soapbox/components/autosuggest_input.tsx +++ b/app/soapbox/components/autosuggest_input.tsx @@ -11,6 +11,7 @@ import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest_ import { isRtl } from 'soapbox/rtl'; import type { Menu, MenuItem } from 'soapbox/components/dropdown_menu'; +import type { InputThemes } from 'soapbox/components/ui/input/input'; type CursorMatch = [ tokenStart: number | null, @@ -60,6 +61,7 @@ interface IAutosuggestInput extends Pick, maxLength?: number, menu?: Menu, resultsPosition: string, + theme?: InputThemes, } export default class AutosuggestInput extends ImmutablePureComponent { @@ -285,7 +287,7 @@ export default class AutosuggestInput extends ImmutablePureComponent
, diff --git a/app/soapbox/components/ui/input/input.tsx b/app/soapbox/components/ui/input/input.tsx index 06384870e..71eaad61e 100644 --- a/app/soapbox/components/ui/input/input.tsx +++ b/app/soapbox/components/ui/input/input.tsx @@ -11,6 +11,9 @@ const messages = defineMessages({ hidePassword: { id: 'input.password.hide_password', defaultMessage: 'Hide password' }, }); +/** Possible theme names for an Input. */ +type InputThemes = 'normal' | 'search' | 'transparent'; + interface IInput extends Pick, 'maxLength' | 'onChange' | 'onBlur' | 'type' | 'autoComplete' | 'autoCorrect' | 'autoCapitalize' | 'required' | 'disabled' | 'onClick' | 'readOnly' | 'min' | 'pattern' | 'onKeyDown' | 'onKeyUp' | 'onFocus' | 'style' | 'id'> { /** Put the cursor into the input on mount. */ autoFocus?: boolean, @@ -36,8 +39,8 @@ interface IInput extends Pick, 'maxL prepend?: React.ReactElement, /** An element to display as suffix to input. Cannot be used with password type. */ append?: React.ReactElement, - /** Adds specific styling to denote a searchabe input. */ - isSearch?: boolean, + /** Theme to style the input with. */ + theme?: InputThemes, } /** Form input element. */ @@ -45,7 +48,7 @@ const Input = React.forwardRef( (props, ref) => { const intl = useIntl(); - const { type = 'text', icon, className, outerClassName, hasError, append, prepend, isSearch, ...filteredProps } = props; + const { type = 'text', icon, className, outerClassName, hasError, append, prepend, theme = 'normal', ...filteredProps } = props; const [revealed, setRevealed] = React.useState(false); @@ -59,8 +62,8 @@ const Input = React.forwardRef(
@@ -83,8 +86,8 @@ const Input = React.forwardRef( className={classNames({ 'text-gray-900 dark:text-gray-100 placeholder:text-gray-600 dark:placeholder:text-gray-600 block w-full sm:text-sm 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, - 'rounded-md bg-white dark:bg-gray-900 border-gray-400 dark:border-gray-800': !isSearch, - 'rounded-full bg-gray-200 border-gray-200 dark:bg-gray-800 dark:border-gray-800 focus:bg-white': isSearch, + 'rounded-md bg-white dark:bg-gray-900 border-gray-400 dark:border-gray-800': theme !== 'search', + 'rounded-full bg-gray-200 border-gray-200 dark:bg-gray-800 dark:border-gray-800 focus:bg-white': theme === 'search', 'pr-7': isPassword || append, 'text-red-600 border-red-600': hasError, 'pl-8': typeof icon !== 'undefined', @@ -127,4 +130,7 @@ const Input = React.forwardRef( }, ); -export default Input; +export { + Input as default, + InputThemes, +}; diff --git a/app/soapbox/features/compose/components/search.tsx b/app/soapbox/features/compose/components/search.tsx index 9402b8785..818080f2f 100644 --- a/app/soapbox/features/compose/components/search.tsx +++ b/app/soapbox/features/compose/components/search.tsx @@ -15,6 +15,7 @@ import { submitSearch, } from 'soapbox/actions/search'; import AutosuggestAccountInput from 'soapbox/components/autosuggest_account_input'; +import { Input } from 'soapbox/components/ui'; import SvgIcon from 'soapbox/components/ui/icon/svg-icon'; import { useAppSelector } from 'soapbox/hooks'; @@ -117,7 +118,6 @@ const Search = (props: ISearch) => { const hasValue = value.length > 0 || submitted; const componentProps: any = { - className: 'block w-full pl-3 pr-10 py-2 border border-gray-200 dark:border-gray-800 rounded-full leading-5 bg-gray-200 dark:bg-gray-800 dark:text-white placeholder:text-gray-600 dark:placeholder:text-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500 sm:text-sm', type: 'text', id: 'search', placeholder: intl.formatMessage(messages.placeholder), @@ -126,6 +126,7 @@ const Search = (props: ISearch) => { onKeyDown: handleKeyDown, onFocus: handleFocus, autoFocus: autoFocus, + theme: 'search', }; if (autosuggest) { @@ -142,7 +143,7 @@ const Search = (props: ISearch) => { {autosuggest ? ( ) : ( - + )}
Date: Tue, 4 Oct 2022 17:14:08 -0400 Subject: [PATCH 06/50] Input: support 'transparent' theme --- app/soapbox/components/ui/input/input.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/soapbox/components/ui/input/input.tsx b/app/soapbox/components/ui/input/input.tsx index 71eaad61e..6be89c2d4 100644 --- a/app/soapbox/components/ui/input/input.tsx +++ b/app/soapbox/components/ui/input/input.tsx @@ -85,9 +85,10 @@ const Input = React.forwardRef( ref={ref} className={classNames({ 'text-gray-900 dark:text-gray-100 placeholder:text-gray-600 dark:placeholder:text-gray-600 block w-full sm:text-sm 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, - 'rounded-md bg-white dark:bg-gray-900 border-gray-400 dark:border-gray-800': theme !== 'search', + ['normal', 'search'].includes(theme), + 'rounded-md bg-white dark:bg-gray-900 border-gray-400 dark:border-gray-800': theme === 'normal', 'rounded-full bg-gray-200 border-gray-200 dark:bg-gray-800 dark:border-gray-800 focus:bg-white': theme === 'search', + 'bg-transparent border-none': theme === 'transparent', 'pr-7': isPassword || append, 'text-red-600 border-red-600': hasError, 'pl-8': typeof icon !== 'undefined', From a829e535597c7fa1868d8a1116113c76bc2c427a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 4 Oct 2022 17:25:02 -0400 Subject: [PATCH 07/50] AutosuggestInput: nuke top margin --- app/soapbox/components/autosuggest_input.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/soapbox/components/autosuggest_input.tsx b/app/soapbox/components/autosuggest_input.tsx index 0c272648d..277d87f96 100644 --- a/app/soapbox/components/autosuggest_input.tsx +++ b/app/soapbox/components/autosuggest_input.tsx @@ -304,6 +304,7 @@ export default class AutosuggestInput extends ImmutablePureComponent Date: Wed, 12 Oct 2022 15:22:50 -0500 Subject: [PATCH 08/50] ZoomableImage: convert to TSX --- .../{zoomable_image.js => zoomable_image.tsx} | 66 +++++++++++-------- 1 file changed, 37 insertions(+), 29 deletions(-) rename app/soapbox/features/ui/components/{zoomable_image.js => zoomable_image.tsx} (64%) diff --git a/app/soapbox/features/ui/components/zoomable_image.js b/app/soapbox/features/ui/components/zoomable_image.tsx similarity index 64% rename from app/soapbox/features/ui/components/zoomable_image.js rename to app/soapbox/features/ui/components/zoomable_image.tsx index b9064fa4f..e502d9e94 100644 --- a/app/soapbox/features/ui/components/zoomable_image.js +++ b/app/soapbox/features/ui/components/zoomable_image.tsx @@ -1,28 +1,28 @@ -import PropTypes from 'prop-types'; import React from 'react'; const MIN_SCALE = 1; const MAX_SCALE = 4; -const getMidpoint = (p1, p2) => ({ +type Point = { x: number, y: number }; +type EventRemover = () => void; + +const getMidpoint = (p1: React.Touch, p2: React.Touch): Point => ({ x: (p1.clientX + p2.clientX) / 2, y: (p1.clientY + p2.clientY) / 2, }); -const getDistance = (p1, p2) => +const getDistance = (p1: React.Touch, p2: React.Touch): number => Math.sqrt(Math.pow(p1.clientX - p2.clientX, 2) + Math.pow(p1.clientY - p2.clientY, 2)); -const clamp = (min, max, value) => Math.min(max, Math.max(min, value)); +const clamp = (min: number, max: number, value: number): number => Math.min(max, Math.max(min, value)); -export default class ZoomableImage extends React.PureComponent { +interface IZoomableImage { + alt?: string, + src: string, + onClick?: React.MouseEventHandler, +} - static propTypes = { - alt: PropTypes.string, - src: PropTypes.string.isRequired, - width: PropTypes.number, - height: PropTypes.number, - onClick: PropTypes.func, - } +export default class ZoomableImage extends React.PureComponent { static defaultProps = { alt: '', @@ -34,21 +34,22 @@ export default class ZoomableImage extends React.PureComponent { scale: MIN_SCALE, } - removers = []; - container = null; - image = null; + removers: EventRemover[] = []; + container: HTMLDivElement | null = null; + image: HTMLImageElement | null = null; lastTouchEndTime = 0; lastDistance = 0; + lastMidpoint: Point | undefined = undefined; componentDidMount() { let handler = this.handleTouchStart; - this.container.addEventListener('touchstart', handler); - this.removers.push(() => this.container.removeEventListener('touchstart', handler)); + this.container?.addEventListener('touchstart', handler); + this.removers.push(() => this.container?.removeEventListener('touchstart', handler)); handler = this.handleTouchMove; // on Chrome 56+, touch event listeners will default to passive // https://www.chromestatus.com/features/5093566007214080 - this.container.addEventListener('touchmove', handler, { passive: false }); - this.removers.push(() => this.container.removeEventListener('touchend', handler)); + this.container?.addEventListener('touchmove', handler, { passive: false }); + this.removers.push(() => this.container?.removeEventListener('touchend', handler)); } componentWillUnmount() { @@ -60,13 +61,16 @@ export default class ZoomableImage extends React.PureComponent { this.removers = []; } - handleTouchStart = e => { + handleTouchStart = (e: TouchEvent) => { if (e.touches.length !== 2) return; + const [p1, p2] = Array.from(e.touches); - this.lastDistance = getDistance(...e.touches); + this.lastDistance = getDistance(p1, p2); } - handleTouchMove = e => { + handleTouchMove = (e: TouchEvent) => { + if (!this.container) return; + const { scrollTop, scrollHeight, clientHeight } = this.container; if (e.touches.length === 1 && scrollTop !== scrollHeight - clientHeight) { // prevent propagating event to MediaModal @@ -78,8 +82,9 @@ export default class ZoomableImage extends React.PureComponent { e.preventDefault(); e.stopPropagation(); - const distance = getDistance(...e.touches); - const midpoint = getMidpoint(...e.touches); + const [p1, p2] = Array.from(e.touches); + const distance = getDistance(p1, p2); + const midpoint = getMidpoint(p1, p2); const scale = clamp(MIN_SCALE, MAX_SCALE, this.state.scale * distance / this.lastDistance); this.zoom(scale, midpoint); @@ -88,7 +93,9 @@ export default class ZoomableImage extends React.PureComponent { this.lastDistance = distance; } - zoom(nextScale, midpoint) { + zoom(nextScale: number, midpoint: Point) { + if (!this.container) return; + const { scale } = this.state; const { scrollLeft, scrollTop } = this.container; @@ -102,23 +109,24 @@ export default class ZoomableImage extends React.PureComponent { const nextScrollTop = (scrollTop + midpoint.y) * nextScale / scale - midpoint.y; this.setState({ scale: nextScale }, () => { + if (!this.container) return; this.container.scrollLeft = nextScrollLeft; this.container.scrollTop = nextScrollTop; }); } - handleClick = e => { + handleClick: React.MouseEventHandler = e => { // don't propagate event to MediaModal e.stopPropagation(); const handler = this.props.onClick; - if (handler) handler(); + if (handler) handler(e); } - setContainerRef = c => { + setContainerRef = (c: HTMLDivElement) => { this.container = c; } - setImageRef = c => { + setImageRef = (c: HTMLImageElement) => { this.image = c; } From e6b0d17699c0b954f14d05f4e5265e9b0ab143b9 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 12 Oct 2022 15:26:34 -0500 Subject: [PATCH 09/50] ZoomableImage: refactor, clean up unused code --- .../features/ui/components/zoomable_image.tsx | 25 ++++++------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/app/soapbox/features/ui/components/zoomable_image.tsx b/app/soapbox/features/ui/components/zoomable_image.tsx index e502d9e94..84c66e1cb 100644 --- a/app/soapbox/features/ui/components/zoomable_image.tsx +++ b/app/soapbox/features/ui/components/zoomable_image.tsx @@ -4,7 +4,6 @@ const MIN_SCALE = 1; const MAX_SCALE = 4; type Point = { x: number, y: number }; -type EventRemover = () => void; const getMidpoint = (p1: React.Touch, p2: React.Touch): Point => ({ x: (p1.clientX + p2.clientX) / 2, @@ -22,7 +21,7 @@ interface IZoomableImage { onClick?: React.MouseEventHandler, } -export default class ZoomableImage extends React.PureComponent { +class ZoomableImage extends React.PureComponent { static defaultProps = { alt: '', @@ -34,31 +33,20 @@ export default class ZoomableImage extends React.PureComponent { scale: MIN_SCALE, } - removers: EventRemover[] = []; container: HTMLDivElement | null = null; image: HTMLImageElement | null = null; - lastTouchEndTime = 0; lastDistance = 0; - lastMidpoint: Point | undefined = undefined; componentDidMount() { - let handler = this.handleTouchStart; - this.container?.addEventListener('touchstart', handler); - this.removers.push(() => this.container?.removeEventListener('touchstart', handler)); - handler = this.handleTouchMove; + this.container?.addEventListener('touchstart', this.handleTouchStart); // on Chrome 56+, touch event listeners will default to passive // https://www.chromestatus.com/features/5093566007214080 - this.container?.addEventListener('touchmove', handler, { passive: false }); - this.removers.push(() => this.container?.removeEventListener('touchend', handler)); + this.container?.addEventListener('touchmove', this.handleTouchMove, { passive: false }); } componentWillUnmount() { - this.removeEventListeners(); - } - - removeEventListeners() { - this.removers.forEach(listeners => listeners()); - this.removers = []; + this.container?.removeEventListener('touchstart', this.handleTouchStart); + this.container?.removeEventListener('touchend', this.handleTouchMove); } handleTouchStart = (e: TouchEvent) => { @@ -89,7 +77,6 @@ export default class ZoomableImage extends React.PureComponent { this.zoom(scale, midpoint); - this.lastMidpoint = midpoint; this.lastDistance = distance; } @@ -158,3 +145,5 @@ export default class ZoomableImage extends React.PureComponent { } } + +export default ZoomableImage; \ No newline at end of file From f42e8520b546c81368f8f0b62d7e0f3dcc1d3bc3 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 12 Oct 2022 15:27:26 -0500 Subject: [PATCH 10/50] zoomable_image --> zoomable-image --- app/soapbox/features/ui/components/image_loader.js | 2 +- .../ui/components/{zoomable_image.tsx => zoomable-image.tsx} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename app/soapbox/features/ui/components/{zoomable_image.tsx => zoomable-image.tsx} (100%) diff --git a/app/soapbox/features/ui/components/image_loader.js b/app/soapbox/features/ui/components/image_loader.js index 8e6e8ec55..32eb2a4f9 100644 --- a/app/soapbox/features/ui/components/image_loader.js +++ b/app/soapbox/features/ui/components/image_loader.js @@ -2,7 +2,7 @@ import classNames from 'clsx'; import PropTypes from 'prop-types'; import React from 'react'; -import ZoomableImage from './zoomable_image'; +import ZoomableImage from './zoomable-image'; export default class ImageLoader extends React.PureComponent { diff --git a/app/soapbox/features/ui/components/zoomable_image.tsx b/app/soapbox/features/ui/components/zoomable-image.tsx similarity index 100% rename from app/soapbox/features/ui/components/zoomable_image.tsx rename to app/soapbox/features/ui/components/zoomable-image.tsx From 5885c454af981f04da53822dc0e0c5dd6621a48d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 12 Oct 2022 16:23:16 -0500 Subject: [PATCH 11/50] ImageLoader: convert to TSX --- .../{image_loader.js => image_loader.tsx} | 48 +++++++++++-------- 1 file changed, 27 insertions(+), 21 deletions(-) rename app/soapbox/features/ui/components/{image_loader.js => image_loader.tsx} (75%) diff --git a/app/soapbox/features/ui/components/image_loader.js b/app/soapbox/features/ui/components/image_loader.tsx similarity index 75% rename from app/soapbox/features/ui/components/image_loader.js rename to app/soapbox/features/ui/components/image_loader.tsx index 32eb2a4f9..ae60420ca 100644 --- a/app/soapbox/features/ui/components/image_loader.js +++ b/app/soapbox/features/ui/components/image_loader.tsx @@ -1,19 +1,20 @@ import classNames from 'clsx'; -import PropTypes from 'prop-types'; import React from 'react'; import ZoomableImage from './zoomable-image'; -export default class ImageLoader extends React.PureComponent { +type EventRemover = () => void; - static propTypes = { - alt: PropTypes.string, - src: PropTypes.string.isRequired, - previewSrc: PropTypes.string, - width: PropTypes.number, - height: PropTypes.number, - onClick: PropTypes.func, - } +interface IImageLoader { + alt?: string, + src: string, + previewSrc?: string, + width?: number, + height?: number, + onClick?: React.MouseEventHandler, +} + +class ImageLoader extends React.PureComponent { static defaultProps = { alt: '', @@ -27,8 +28,9 @@ export default class ImageLoader extends React.PureComponent { width: null, } - removers = []; - canvas = null; + removers: EventRemover[] = []; + canvas: HTMLCanvasElement | null = null; + _canvasContext: CanvasRenderingContext2D | null = null; get canvasContext() { if (!this.canvas) { @@ -42,7 +44,7 @@ export default class ImageLoader extends React.PureComponent { this.loadImage(this.props); } - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps: IImageLoader) { if (prevProps.src !== this.props.src) { this.loadImage(this.props); } @@ -52,7 +54,7 @@ export default class ImageLoader extends React.PureComponent { this.removeEventListeners(); } - loadImage(props) { + loadImage(props: IImageLoader) { this.removeEventListeners(); this.setState({ loading: true, error: false }); Promise.all([ @@ -66,7 +68,7 @@ export default class ImageLoader extends React.PureComponent { .catch(() => this.setState({ loading: false, error: true })); } - loadPreviewCanvas = ({ previewSrc, width, height }) => new Promise((resolve, reject) => { + loadPreviewCanvas = ({ previewSrc, width, height }: IImageLoader) => new Promise((resolve, reject) => { const image = new Image(); const removeEventListeners = () => { image.removeEventListener('error', handleError); @@ -78,21 +80,23 @@ export default class ImageLoader extends React.PureComponent { }; const handleLoad = () => { removeEventListeners(); - this.canvasContext.drawImage(image, 0, 0, width, height); + this.canvasContext?.drawImage(image, 0, 0, width || 0, height || 0); resolve(); }; image.addEventListener('error', handleError); image.addEventListener('load', handleLoad); - image.src = previewSrc; + image.src = previewSrc || ''; this.removers.push(removeEventListeners); }) clearPreviewCanvas() { - const { width, height } = this.canvas; - this.canvasContext.clearRect(0, 0, width, height); + if (this.canvas && this.canvasContext) { + const { width, height } = this.canvas; + this.canvasContext.clearRect(0, 0, width, height); + } } - loadOriginalImage = ({ src }) => new Promise((resolve, reject) => { + loadOriginalImage = ({ src }: IImageLoader) => new Promise((resolve, reject) => { const image = new Image(); const removeEventListeners = () => { image.removeEventListener('error', handleError); @@ -122,7 +126,7 @@ export default class ImageLoader extends React.PureComponent { return typeof width === 'number' && typeof height === 'number'; } - setCanvasRef = c => { + setCanvasRef = (c: HTMLCanvasElement) => { this.canvas = c; if (c) this.setState({ width: c.offsetWidth }); } @@ -157,3 +161,5 @@ export default class ImageLoader extends React.PureComponent { } } + +export default ImageLoader; \ No newline at end of file From 18b177d6c9bd43013dd2737e484223ee12cea398 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 12 Oct 2022 16:26:19 -0500 Subject: [PATCH 12/50] image_loader --> image-loader --- .../ui/components/{image_loader.tsx => image-loader.tsx} | 0 app/soapbox/features/ui/components/media_modal.js | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename app/soapbox/features/ui/components/{image_loader.tsx => image-loader.tsx} (100%) diff --git a/app/soapbox/features/ui/components/image_loader.tsx b/app/soapbox/features/ui/components/image-loader.tsx similarity index 100% rename from app/soapbox/features/ui/components/image_loader.tsx rename to app/soapbox/features/ui/components/image-loader.tsx diff --git a/app/soapbox/features/ui/components/media_modal.js b/app/soapbox/features/ui/components/media_modal.js index dc988f270..61e7ff12d 100644 --- a/app/soapbox/features/ui/components/media_modal.js +++ b/app/soapbox/features/ui/components/media_modal.js @@ -13,7 +13,7 @@ import IconButton from 'soapbox/components/icon_button'; import Audio from 'soapbox/features/audio'; import Video from 'soapbox/features/video'; -import ImageLoader from './image_loader'; +import ImageLoader from './image-loader'; const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, From 80ce70e33ea7ed2fa6c9298bd613a4b139699d89 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 12 Oct 2022 17:16:37 -0500 Subject: [PATCH 13/50] MediaModal: convert to TSX+FC --- .../features/ui/components/media_modal.js | 274 ---------------- .../features/ui/components/media_modal.tsx | 300 ++++++++++++++++++ 2 files changed, 300 insertions(+), 274 deletions(-) delete mode 100644 app/soapbox/features/ui/components/media_modal.js create mode 100644 app/soapbox/features/ui/components/media_modal.tsx diff --git a/app/soapbox/features/ui/components/media_modal.js b/app/soapbox/features/ui/components/media_modal.js deleted file mode 100644 index 61e7ff12d..000000000 --- a/app/soapbox/features/ui/components/media_modal.js +++ /dev/null @@ -1,274 +0,0 @@ -import classNames from 'clsx'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { withRouter } from 'react-router-dom'; -import ReactSwipeableViews from 'react-swipeable-views'; - -import ExtendedVideoPlayer from 'soapbox/components/extended_video_player'; -import Icon from 'soapbox/components/icon'; -import IconButton from 'soapbox/components/icon_button'; -import Audio from 'soapbox/features/audio'; -import Video from 'soapbox/features/video'; - -import ImageLoader from './image-loader'; - -const messages = defineMessages({ - close: { id: 'lightbox.close', defaultMessage: 'Close' }, - previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, - next: { id: 'lightbox.next', defaultMessage: 'Next' }, -}); - -export default @injectIntl @withRouter -class MediaModal extends ImmutablePureComponent { - - static propTypes = { - media: ImmutablePropTypes.list.isRequired, - status: ImmutablePropTypes.record, - account: ImmutablePropTypes.record, - index: PropTypes.number.isRequired, - onClose: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - history: PropTypes.object, - }; - - state = { - index: null, - navigationHidden: false, - }; - - handleSwipe = (index) => { - this.setState({ index: index % this.props.media.size }); - } - - handleNextClick = () => { - this.setState({ index: (this.getIndex() + 1) % this.props.media.size }); - } - - handlePrevClick = () => { - this.setState({ index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size }); - } - - handleChangeIndex = (e) => { - const index = Number(e.currentTarget.getAttribute('data-index')); - this.setState({ index: index % this.props.media.size }); - } - - handleKeyDown = (e) => { - switch (e.key) { - case 'ArrowLeft': - this.handlePrevClick(); - e.preventDefault(); - e.stopPropagation(); - break; - case 'ArrowRight': - this.handleNextClick(); - e.preventDefault(); - e.stopPropagation(); - break; - } - } - - componentDidMount() { - window.addEventListener('keydown', this.handleKeyDown, false); - } - - componentWillUnmount() { - window.removeEventListener('keydown', this.handleKeyDown); - } - - getIndex() { - return this.state.index !== null ? this.state.index : this.props.index; - } - - toggleNavigation = () => { - this.setState(prevState => ({ - navigationHidden: !prevState.navigationHidden, - })); - }; - - handleStatusClick = e => { - if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { - e.preventDefault(); - const { status, account } = this.props; - const acct = account.get('acct'); - const statusId = status.get('id'); - this.props.history.push(`/@${acct}/posts/${statusId}`); - this.props.onClose(null, true); - } - } - - handleCloserClick = ({ target }) => { - const whitelist = ['zoomable-image']; - const activeSlide = document.querySelector('.media-modal .react-swipeable-view-container > div[aria-hidden="false"]'); - - const isClickOutside = target === activeSlide || !activeSlide.contains(target); - const isWhitelisted = whitelist.some(w => target.classList.contains(w)); - - if (isClickOutside || isWhitelisted) { - this.props.onClose(); - } - } - - render() { - const { media, status, account, intl, onClose } = this.props; - const { navigationHidden } = this.state; - - const index = this.getIndex(); - let pagination = []; - - const leftNav = media.size > 1 && ( - - ); - - const rightNav = media.size > 1 && ( - - ); - - if (media.size > 1) { - pagination = media.map((item, i) => { - const classes = ['media-modal__button']; - if (i === index) { - classes.push('media-modal__button--active'); - } - return (
  • ); - }); - } - - const isMultiMedia = media.map((image) => { - if (image.get('type') !== 'image') { - return true; - } - - return false; - }).toArray(); - - const content = media.map(attachment => { - const width = attachment.getIn(['meta', 'original', 'width']) || null; - const height = attachment.getIn(['meta', 'original', 'height']) || null; - const link = (status && account && ); - - if (attachment.get('type') === 'image') { - return ( - - ); - } else if (attachment.get('type') === 'video') { - const { time } = this.props; - - return ( -