From c49aec2ae0d5695dde17067e0c39c3be102b7875 Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 4 Oct 2022 15:08:22 -0400 Subject: [PATCH 1/7] 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 ecec3de1f9..4dc38997d5 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 8166273263..59f6ee1bc5 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 f959cdd517..994da6affd 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 e203a14600..969f7ae65c 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 64257ecf9a..5f60f553f5 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 2e07368097..7669f3d2a8 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 2/7] 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 994da6affd..a109da608d 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 3/7] 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 5f60f553f5..b161d4949f 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 6dddaea73668c71ec481a07edd0b8c2e14151b28 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 12 Oct 2022 18:24:23 -0500 Subject: [PATCH 4/7] Audio: convert to TSX+FC --- app/soapbox/features/audio/index.js | Bin 15957 -> 0 bytes app/soapbox/features/audio/index.tsx | 600 +++++++++++++++++++++++++++ 2 files changed, 600 insertions(+) delete mode 100644 app/soapbox/features/audio/index.js create mode 100644 app/soapbox/features/audio/index.tsx diff --git a/app/soapbox/features/audio/index.js b/app/soapbox/features/audio/index.js deleted file mode 100644 index 98bfc66b505f820681dc5705ab505c5d4bdc4cc9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15957 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 O2tIlr>^;Wj(f void, + backgroundColor?: string, + foregroundColor?: string, + accentColor?: string, + currentTime?: number, + autoPlay?: boolean, + volume?: number, + muted?: boolean, + deployPictureInPicture?: (type: string, opts: Record) => void, +} + +const Audio: React.FC = (props) => { + const { + src, + alt = '', + poster, + accentColor, + backgroundColor, + foregroundColor, + cacheWidth, + fullscreen, + autoPlay, + editable, + deployPictureInPicture = false, + } = props; + + const intl = useIntl(); + + const [width, setWidth] = useState(props.width); + const [height, setHeight] = useState(props.height); + const [currentTime, setCurrentTime] = useState(0); + const [buffer, setBuffer] = useState(0); + const [duration, setDuration] = useState(undefined); + const [paused, setPaused] = useState(true); + const [muted, setMuted] = useState(false); + const [volume, setVolume] = useState(0.5); + const [dragging, setDragging] = useState(false); + const [hovered, setHovered] = useState(false); + + const visualizer = useRef(new Visualizer(TICK_SIZE)); + const audioContext = useRef(null); + + const player = useRef(null); + const audio = useRef(null); + const seek = useRef(null); + const slider = useRef(null); + const canvas = useRef(null); + + const _pack = () => ({ + src: props.src, + volume: audio.current?.volume, + muted: audio.current?.muted, + currentTime: audio.current?.currentTime, + poster: props.poster, + backgroundColor: props.backgroundColor, + foregroundColor: props.foregroundColor, + accentColor: props.accentColor, + }); + + const _setDimensions = () => { + if (player.current) { + const width = player.current.offsetWidth; + const height = fullscreen ? player.current.offsetHeight : (width / (16 / 9)); + + if (cacheWidth) { + cacheWidth(width); + } + + setWidth(width); + setHeight(height); + } + }; + + useEffect(() => { + if (player.current) { + _setDimensions(); + } + }, [player.current]); + + useEffect(() => { + if (audio.current) { + setVolume(audio.current.volume); + setMuted(audio.current.muted); + } + }, [audio.current]); + + useEffect(() => { + if (canvas.current && visualizer.current) { + visualizer.current.setCanvas(canvas.current); + } + }, [canvas.current, visualizer.current]); + + useEffect(() => { + window.addEventListener('scroll', handleScroll); + window.addEventListener('resize', handleResize, { passive: true }); + + return () => { + window.removeEventListener('scroll', handleScroll); + window.removeEventListener('resize', handleResize); + + if (!paused && audio.current && deployPictureInPicture) { + deployPictureInPicture('audio', _pack()); + } + }; + }, []); + + useEffect(() => { + _clear(); + _draw(); + }, [src, width, height, accentColor]); + + const togglePlay = () => { + if (!audioContext.current) { + _initAudioContext(); + } + + if (paused) { + audio.current?.play(); + } else { + audio.current?.pause(); + } + + setPaused(!paused); + }; + + const handleResize = debounce(() => { + if (player.current) { + _setDimensions(); + } + }, 250, { + trailing: true, + }); + + const handlePlay = () => { + setPaused(false); + + if (audioContext.current?.state === 'suspended') { + audioContext.current?.resume(); + } + + _renderCanvas(); + }; + + const handlePause = () => { + setPaused(true); + audioContext.current?.suspend(); + }; + + const handleProgress = () => { + if (audio.current) { + const lastTimeRange = audio.current.buffered.length - 1; + + if (lastTimeRange > -1) { + setBuffer(Math.ceil(audio.current.buffered.end(lastTimeRange) / audio.current.duration * 100)); + } + } + }; + + const toggleMute = () => { + const nextMuted = !muted; + + setMuted(nextMuted); + + if (audio.current) { + audio.current.muted = nextMuted; + } + }; + + const handleVolumeMouseDown: React.MouseEventHandler = e => { + document.addEventListener('mousemove', handleMouseVolSlide, true); + document.addEventListener('mouseup', handleVolumeMouseUp, true); + document.addEventListener('touchmove', handleMouseVolSlide, true); + document.addEventListener('touchend', handleVolumeMouseUp, true); + + handleMouseVolSlide(e); + + e.preventDefault(); + e.stopPropagation(); + }; + + const handleVolumeMouseUp = () => { + document.removeEventListener('mousemove', handleMouseVolSlide, true); + document.removeEventListener('mouseup', handleVolumeMouseUp, true); + document.removeEventListener('touchmove', handleMouseVolSlide, true); + document.removeEventListener('touchend', handleVolumeMouseUp, true); + }; + + const handleMouseDown: React.MouseEventHandler = e => { + document.addEventListener('mousemove', handleMouseMove, true); + document.addEventListener('mouseup', handleMouseUp, true); + document.addEventListener('touchmove', handleMouseMove, true); + document.addEventListener('touchend', handleMouseUp, true); + + setDragging(true); + audio.current?.pause(); + handleMouseMove(e); + + e.preventDefault(); + e.stopPropagation(); + }; + + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove, true); + document.removeEventListener('mouseup', handleMouseUp, true); + document.removeEventListener('touchmove', handleMouseMove, true); + document.removeEventListener('touchend', handleMouseUp, true); + + setDragging(false); + audio.current?.play(); + }; + + const handleMouseMove = throttle((e) => { + if (audio.current && seek.current) { + const { x } = getPointerPosition(seek.current, e); + const currentTime = audio.current.duration * x; + + if (!isNaN(currentTime)) { + setCurrentTime(currentTime); + audio.current.currentTime = currentTime; + } + } + }, 15); + + const handleTimeUpdate = () => { + if (audio.current) { + setCurrentTime(audio.current.currentTime); + setDuration(audio.current.duration); + } + }; + + const handleMouseVolSlide = throttle(e => { + if (audio.current && slider.current) { + const { x } = getPointerPosition(slider.current, e); + + if (!isNaN(x)) { + setVolume(x); + audio.current.volume = x; + } + } + }, 15); + + const handleScroll = throttle(() => { + if (!canvas.current || !audio.current) { + return; + } + + const { top, height } = canvas.current.getBoundingClientRect(); + const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0); + + if (!paused && !inView) { + audio.current.pause(); + + if (deployPictureInPicture) { + deployPictureInPicture('audio', _pack()); + } + + setPaused(true); + } + }, 150, { trailing: true }); + + const handleMouseEnter = () => { + setHovered(true); + }; + + const handleMouseLeave = () => { + setHovered(false); + }; + + const handleLoadedData = () => { + if (audio.current) { + setDuration(audio.current.duration); + + if (currentTime) { + audio.current.currentTime = currentTime; + } + + if (volume !== undefined) { + audio.current.volume = volume; + } + + if (muted !== undefined) { + audio.current.muted = muted; + } + + if (autoPlay) { + togglePlay(); + } + } + }; + + const _initAudioContext = () => { + if (audio.current) { + // @ts-ignore + // eslint-disable-next-line compat/compat + const AudioContext = window.AudioContext || window.webkitAudioContext; + const context = new AudioContext(); + const source = context.createMediaElementSource(audio.current); + + visualizer.current.setAudioContext(context, source); + source.connect(context.destination); + + audioContext.current = context; + } + }; + + // const handleDownload = () => { + // fetch(src).then(res => res.blob()).then(blob => { + // const element = document.createElement('a'); + // const objectURL = URL.createObjectURL(blob); + + // element.setAttribute('href', objectURL); + // element.setAttribute('download', fileNameFromURL(src)); + + // document.body.appendChild(element); + // element.click(); + // document.body.removeChild(element); + + // URL.revokeObjectURL(objectURL); + // }).catch(err => { + // console.error(err); + // }); + // }; + + const _renderCanvas = () => { + requestAnimationFrame(() => { + if (!audio.current) return; + + handleTimeUpdate(); + _clear(); + _draw(); + + if (!paused) { + _renderCanvas(); + } + }); + }; + + const _clear = () => { + visualizer.current?.clear(width || 0, height || 0); + }; + + const _draw = () => { + visualizer.current?.draw(_getCX(), _getCY(), _getAccentColor(), _getRadius(), _getScaleCoefficient()); + }; + + const _getRadius = (): number => { + return ((height || props.height || 0) - (PADDING * _getScaleCoefficient()) * 2) / 2; + }; + + const _getScaleCoefficient = (): number => { + return (height || props.height || 0) / 982; + }; + + const _getCX = (): number => { + return Math.floor((width || 0) / 2); + }; + + const _getCY = (): number => { + return Math.floor(_getRadius() + (PADDING * _getScaleCoefficient())); + }; + + const _getAccentColor = (): string => { + return accentColor || '#ffffff'; + }; + + const _getBackgroundColor = (): string => { + return backgroundColor || '#000000'; + }; + + const _getForegroundColor = (): string => { + return foregroundColor || '#ffffff'; + }; + + const seekBy = (time: number) => { + if (audio.current) { + const currentTime = audio.current.currentTime + time; + + if (!isNaN(currentTime)) { + setCurrentTime(currentTime); + audio.current.currentTime = currentTime; + } + } + }; + + const handleAudioKeyDown: React.KeyboardEventHandler = e => { + // On the audio element or the seek bar, we can safely use the space bar + // for playback control because there are no buttons to press + + if (e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + togglePlay(); + } + }; + + const handleKeyDown: React.KeyboardEventHandler = e => { + switch (e.key) { + case 'k': + e.preventDefault(); + e.stopPropagation(); + togglePlay(); + break; + case 'm': + e.preventDefault(); + e.stopPropagation(); + toggleMute(); + break; + case 'j': + e.preventDefault(); + e.stopPropagation(); + seekBy(-10); + break; + case 'l': + e.preventDefault(); + e.stopPropagation(); + seekBy(10); + break; + } + }; + + const getDuration = () => duration || props.duration || 0; + const progress = Math.min((currentTime / getDuration()) * 100, 100); + + return ( +
+