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..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',
@@ -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..b161d4949 100644
--- a/app/soapbox/components/ui/stack/stack.tsx
+++ b/app/soapbox/components/ui/stack/stack.tsx
@@ -1,13 +1,11 @@
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',
+ [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',
@@ -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,
}
diff --git a/app/soapbox/features/audio/index.js b/app/soapbox/features/audio/index.js
deleted file mode 100644
index 98bfc66b5..000000000
--- a/app/soapbox/features/audio/index.js
+++ /dev/null
@@ -1,535 +0,0 @@
-import classNames from 'clsx';
-import debounce from 'lodash/debounce';
-import throttle from 'lodash/throttle';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { defineMessages, injectIntl } from 'react-intl';
-
-import Icon from 'soapbox/components/icon';
-import { formatTime, getPointerPosition, fileNameFromURL } from 'soapbox/features/video';
-
-import Visualizer from './visualizer';
-
-const messages = defineMessages({
- play: { id: 'video.play', defaultMessage: 'Play' },
- pause: { id: 'video.pause', defaultMessage: 'Pause' },
- mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
- unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
- download: { id: 'video.download', defaultMessage: 'Download file' },
-});
-
-const TICK_SIZE = 10;
-const PADDING = 180;
-
-export default @injectIntl
-class Audio extends React.PureComponent {
-
- static propTypes = {
- src: PropTypes.string.isRequired,
- alt: PropTypes.string,
- poster: PropTypes.string,
- duration: PropTypes.number,
- width: PropTypes.number,
- height: PropTypes.number,
- editable: PropTypes.bool,
- fullscreen: PropTypes.bool,
- intl: PropTypes.object.isRequired,
- cacheWidth: PropTypes.func,
- backgroundColor: PropTypes.string,
- foregroundColor: PropTypes.string,
- accentColor: PropTypes.string,
- currentTime: PropTypes.number,
- autoPlay: PropTypes.bool,
- volume: PropTypes.number,
- muted: PropTypes.bool,
- deployPictureInPicture: PropTypes.func,
- };
-
- state = {
- width: this.props.width,
- currentTime: 0,
- buffer: 0,
- duration: null,
- paused: true,
- muted: false,
- volume: 0.5,
- dragging: false,
- };
-
- constructor(props) {
- super(props);
- this.visualizer = new Visualizer(TICK_SIZE);
- }
-
- setPlayerRef = c => {
- this.player = c;
-
- if (this.player) {
- this._setDimensions();
- }
- }
-
- _pack() {
- return {
- src: this.props.src,
- volume: this.audio.volume,
- muted: this.audio.muted,
- currentTime: this.audio.currentTime,
- poster: this.props.poster,
- backgroundColor: this.props.backgroundColor,
- foregroundColor: this.props.foregroundColor,
- accentColor: this.props.accentColor,
- };
- }
-
- _setDimensions() {
- const width = this.player.offsetWidth;
- const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16 / 9));
-
- if (this.props.cacheWidth) {
- this.props.cacheWidth(width);
- }
-
- this.setState({ width, height });
- }
-
- setSeekRef = c => {
- this.seek = c;
- }
-
- setVolumeRef = c => {
- this.volume = c;
- }
-
- setAudioRef = c => {
- this.audio = c;
-
- if (this.audio) {
- this.setState({ volume: this.audio.volume, muted: this.audio.muted });
- }
- }
-
- setCanvasRef = c => {
- this.canvas = c;
-
- this.visualizer.setCanvas(c);
- }
-
- componentDidMount() {
- window.addEventListener('scroll', this.handleScroll);
- window.addEventListener('resize', this.handleResize, { passive: true });
- }
-
- componentDidUpdate(prevProps, prevState) {
- if (prevProps.src !== this.props.src || this.state.width !== prevState.width || this.state.height !== prevState.height || prevProps.accentColor !== this.props.accentColor) {
- this._clear();
- this._draw();
- }
- }
-
- componentWillUnmount() {
- window.removeEventListener('scroll', this.handleScroll);
- window.removeEventListener('resize', this.handleResize);
-
- if (!this.state.paused && this.audio && this.props.deployPictureInPicture) {
- this.props.deployPictureInPicture('audio', this._pack());
- }
- }
-
- togglePlay = () => {
- if (!this.audioContext) {
- this._initAudioContext();
- }
-
- if (this.state.paused) {
- this.setState({ paused: false }, () => this.audio.play());
- } else {
- this.setState({ paused: true }, () => this.audio.pause());
- }
- }
-
- handleResize = debounce(() => {
- if (this.player) {
- this._setDimensions();
- }
- }, 250, {
- trailing: true,
- });
-
- handlePlay = () => {
- this.setState({ paused: false });
-
- if (this.audioContext && this.audioContext.state === 'suspended') {
- this.audioContext.resume();
- }
-
- this._renderCanvas();
- }
-
- handlePause = () => {
- this.setState({ paused: true });
-
- if (this.audioContext) {
- this.audioContext.suspend();
- }
- }
-
- handleProgress = () => {
- const lastTimeRange = this.audio.buffered.length - 1;
-
- if (lastTimeRange > -1) {
- this.setState({ buffer: Math.ceil(this.audio.buffered.end(lastTimeRange) / this.audio.duration * 100) });
- }
- }
-
- toggleMute = () => {
- const muted = !this.state.muted;
-
- this.setState({ muted }, () => {
- this.audio.muted = muted;
- });
- }
-
- handleVolumeMouseDown = e => {
- document.addEventListener('mousemove', this.handleMouseVolSlide, true);
- document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
- document.addEventListener('touchmove', this.handleMouseVolSlide, true);
- document.addEventListener('touchend', this.handleVolumeMouseUp, true);
-
- this.handleMouseVolSlide(e);
-
- e.preventDefault();
- e.stopPropagation();
- }
-
- handleVolumeMouseUp = () => {
- document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
- document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
- document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
- document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
- }
-
- handleMouseDown = e => {
- document.addEventListener('mousemove', this.handleMouseMove, true);
- document.addEventListener('mouseup', this.handleMouseUp, true);
- document.addEventListener('touchmove', this.handleMouseMove, true);
- document.addEventListener('touchend', this.handleMouseUp, true);
-
- this.setState({ dragging: true });
- this.audio.pause();
- this.handleMouseMove(e);
-
- e.preventDefault();
- e.stopPropagation();
- }
-
- handleMouseUp = () => {
- document.removeEventListener('mousemove', this.handleMouseMove, true);
- document.removeEventListener('mouseup', this.handleMouseUp, true);
- document.removeEventListener('touchmove', this.handleMouseMove, true);
- document.removeEventListener('touchend', this.handleMouseUp, true);
-
- this.setState({ dragging: false });
- this.audio.play();
- }
-
- handleMouseMove = throttle(e => {
- const { x } = getPointerPosition(this.seek, e);
- const currentTime = this.audio.duration * x;
-
- if (!isNaN(currentTime)) {
- this.setState({ currentTime }, () => {
- this.audio.currentTime = currentTime;
- });
- }
- }, 15);
-
- handleTimeUpdate = () => {
- this.setState({
- currentTime: this.audio.currentTime,
- duration: this.audio.duration,
- });
- }
-
- handleMouseVolSlide = throttle(e => {
- const { x } = getPointerPosition(this.volume, e);
-
- if (!isNaN(x)) {
- this.setState({ volume: x }, () => {
- this.audio.volume = x;
- });
- }
- }, 15);
-
- handleScroll = throttle(() => {
- if (!this.canvas || !this.audio) {
- return;
- }
-
- const { top, height } = this.canvas.getBoundingClientRect();
- const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
-
- if (!this.state.paused && !inView) {
- this.audio.pause();
-
- if (this.props.deployPictureInPicture) {
- this.props.deployPictureInPicture('audio', this._pack());
- }
-
- this.setState({ paused: true });
- }
- }, 150, { trailing: true });
-
- handleMouseEnter = () => {
- this.setState({ hovered: true });
- }
-
- handleMouseLeave = () => {
- this.setState({ hovered: false });
- }
-
- handleLoadedData = () => {
- const { autoPlay, currentTime, volume, muted } = this.props;
-
- this.setState({ duration: this.audio.duration });
-
- if (currentTime) {
- this.audio.currentTime = currentTime;
- }
-
- if (volume !== undefined) {
- this.audio.volume = volume;
- }
-
- if (muted !== undefined) {
- this.audio.muted = muted;
- }
-
- if (autoPlay) {
- this.togglePlay();
- }
- }
-
- _initAudioContext() {
- // eslint-disable-next-line compat/compat
- const AudioContext = window.AudioContext || window.webkitAudioContext;
- const context = new AudioContext();
- const source = context.createMediaElementSource(this.audio);
-
- this.visualizer.setAudioContext(context, source);
- source.connect(context.destination);
-
- this.audioContext = context;
- }
-
- handleDownload = () => {
- fetch(this.props.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(this.props.src));
-
- document.body.appendChild(element);
- element.click();
- document.body.removeChild(element);
-
- URL.revokeObjectURL(objectURL);
- }).catch(err => {
- console.error(err);
- });
- }
-
- _renderCanvas() {
- requestAnimationFrame(() => {
- if (!this.audio) return;
-
- this.handleTimeUpdate();
- this._clear();
- this._draw();
-
- if (!this.state.paused) {
- this._renderCanvas();
- }
- });
- }
-
- _clear() {
- this.visualizer.clear(this.state.width, this.state.height);
- }
-
- _draw() {
- this.visualizer.draw(this._getCX(), this._getCY(), this._getAccentColor(), this._getRadius(), this._getScaleCoefficient());
- }
-
- _getRadius() {
- return parseInt(((this.state.height || this.props.height) - (PADDING * this._getScaleCoefficient()) * 2) / 2);
- }
-
- _getScaleCoefficient() {
- return (this.state.height || this.props.height) / 982;
- }
-
- _getCX() {
- return Math.floor(this.state.width / 2) || null;
- }
-
- _getCY() {
- return Math.floor(this._getRadius() + (PADDING * this._getScaleCoefficient())) || null;
- }
-
- _getAccentColor() {
- return this.props.accentColor || '#ffffff';
- }
-
- _getBackgroundColor() {
- return this.props.backgroundColor || '#000000';
- }
-
- _getForegroundColor() {
- return this.props.foregroundColor || '#ffffff';
- }
-
- seekBy(time) {
- const currentTime = this.audio.currentTime + time;
-
- if (!isNaN(currentTime)) {
- this.setState({ currentTime }, () => {
- this.audio.currentTime = currentTime;
- });
- }
- }
-
- handleAudioKeyDown = 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();
- this.togglePlay();
- }
- }
-
- handleKeyDown = e => {
- switch (e.key) {
- case 'k':
- e.preventDefault();
- e.stopPropagation();
- this.togglePlay();
- break;
- case 'm':
- e.preventDefault();
- e.stopPropagation();
- this.toggleMute();
- break;
- case 'j':
- e.preventDefault();
- e.stopPropagation();
- this.seekBy(-10);
- break;
- case 'l':
- e.preventDefault();
- e.stopPropagation();
- this.seekBy(10);
- break;
- }
- }
-
- render() {
- const { src, intl, alt, editable } = this.props;
- const { paused, muted, volume, currentTime, buffer, dragging } = this.state;
- const duration = this.state.duration || this.props.duration;
- const progress = Math.min((currentTime / duration) * 100, 100);
-
- return (
-
-
-
-
-
- {this.props.poster &&

}
-
-
-
-
-
-
-
-
-
-
-
-
- {formatTime(Math.floor(currentTime))}
- {duration && (<>
- /
- {formatTime(Math.floor(duration))}
- >)}
-
-
-
-
-
-
-
- );
- }
-
-}
diff --git a/app/soapbox/features/audio/index.tsx b/app/soapbox/features/audio/index.tsx
new file mode 100644
index 000000000..0bef3c3d9
--- /dev/null
+++ b/app/soapbox/features/audio/index.tsx
@@ -0,0 +1,583 @@
+import classNames from 'clsx';
+import debounce from 'lodash/debounce';
+import throttle from 'lodash/throttle';
+import React, { useEffect, useRef, useState } from 'react';
+import { defineMessages, useIntl } from 'react-intl';
+
+import Icon from 'soapbox/components/icon';
+import { formatTime, getPointerPosition } from 'soapbox/features/video';
+
+import Visualizer from './visualizer';
+
+const messages = defineMessages({
+ play: { id: 'video.play', defaultMessage: 'Play' },
+ pause: { id: 'video.pause', defaultMessage: 'Pause' },
+ mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
+ unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
+ download: { id: 'video.download', defaultMessage: 'Download file' },
+});
+
+const TICK_SIZE = 10;
+const PADDING = 180;
+
+interface IAudio {
+ src: string,
+ alt?: string,
+ poster?: string,
+ duration?: number,
+ width?: number,
+ height?: number,
+ editable?: boolean,
+ fullscreen?: boolean,
+ cacheWidth?: (width: number) => 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);
+ }
+ };
+
+ 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 _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);
+
+ 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]);
+
+ return (
+
+
+
+
+
+ {poster && (
+

+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {formatTime(Math.floor(currentTime))}
+ {getDuration() && (<>
+ /
+ {formatTime(Math.floor(getDuration()))}
+ >)}
+
+
+
+
+
+
+
+ );
+};
+
+export default Audio;
\ No newline at end of file
diff --git a/app/soapbox/features/verification/registration.tsx b/app/soapbox/features/verification/registration.tsx
index a6f5749e8..e4bf2c1e2 100644
--- a/app/soapbox/features/verification/registration.tsx
+++ b/app/soapbox/features/verification/registration.tsx
@@ -20,6 +20,10 @@ const messages = defineMessages({
id: 'registrations.success',
defaultMessage: 'Welcome to {siteTitle}!',
},
+ usernameHint: {
+ id: 'registrations.username.hint',
+ defaultMessage: 'May only contain A-Z, 0-9, and underscores',
+ },
usernameTaken: {
id: 'registrations.unprocessable_entity',
defaultMessage: 'This username has already been taken.',
@@ -104,7 +108,7 @@ const Registration = () => {