diff --git a/app/soapbox/actions/mobile.ts b/app/soapbox/actions/mobile.ts deleted file mode 100644 index 1e11f473d..000000000 --- a/app/soapbox/actions/mobile.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { staticClient } from '../api'; - -import type { AppDispatch } from 'soapbox/store'; - -const FETCH_MOBILE_PAGE_REQUEST = 'FETCH_MOBILE_PAGE_REQUEST'; -const FETCH_MOBILE_PAGE_SUCCESS = 'FETCH_MOBILE_PAGE_SUCCESS'; -const FETCH_MOBILE_PAGE_FAIL = 'FETCH_MOBILE_PAGE_FAIL'; - -const fetchMobilePage = (slug = 'index', locale?: string) => - (dispatch: AppDispatch) => { - dispatch({ type: FETCH_MOBILE_PAGE_REQUEST, slug, locale }); - const filename = `${slug}${locale ? `.${locale}` : ''}.html`; - return staticClient.get(`/instance/mobile/${filename}`).then(({ data: html }) => { - dispatch({ type: FETCH_MOBILE_PAGE_SUCCESS, slug, locale, html }); - return html; - }).catch(error => { - dispatch({ type: FETCH_MOBILE_PAGE_FAIL, slug, locale, error }); - throw error; - }); - }; - -export { - FETCH_MOBILE_PAGE_REQUEST, - FETCH_MOBILE_PAGE_SUCCESS, - FETCH_MOBILE_PAGE_FAIL, - fetchMobilePage, -}; \ No newline at end of file diff --git a/app/soapbox/components/announcements/announcement.tsx b/app/soapbox/components/announcements/announcement.tsx index 09767f458..ea96b37fd 100644 --- a/app/soapbox/components/announcements/announcement.tsx +++ b/app/soapbox/components/announcements/announcement.tsx @@ -34,11 +34,11 @@ const Announcement: React.FC = ({ announcement, addReaction, remo {' '} @@ -46,11 +46,11 @@ const Announcement: React.FC = ({ announcement, addReaction, remo {' '} 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 8e3a93ba3..358077538 100644 --- a/app/soapbox/components/autosuggest_input.tsx +++ b/app/soapbox/components/autosuggest_input.tsx @@ -6,10 +6,12 @@ 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'; 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 +62,7 @@ interface IAutosuggestInput extends Pick, menu?: Menu, resultsPosition: string, renderSuggestion?: React.FC<{ id: string }>, + theme?: InputThemes, } export default class AutosuggestInput extends ImmutablePureComponent { @@ -289,7 +292,7 @@ export default class AutosuggestInput extends ImmutablePureComponent - , diff --git a/app/soapbox/components/emoji-button-wrapper.tsx b/app/soapbox/components/emoji-button-wrapper.tsx index 0afdba85f..d85d22627 100644 --- a/app/soapbox/components/emoji-button-wrapper.tsx +++ b/app/soapbox/components/emoji-button-wrapper.tsx @@ -83,10 +83,7 @@ const EmojiButtonWrapper: React.FC = ({ statusId, children if (ownAccount) { dispatch(simpleEmojiReact(status, emoji)); } else { - dispatch(openModal('UNAUTHORIZED', { - action: 'FAVOURITE', - ap_id: status.url, - })); + handleUnauthorized(); } setVisible(false); @@ -96,10 +93,14 @@ const EmojiButtonWrapper: React.FC = ({ statusId, children const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji) || '👍'; if (isUserTouching()) { - if (visible) { - handleReact(meEmojiReact); + if (ownAccount) { + if (visible) { + handleReact(meEmojiReact); + } else { + setVisible(true); + } } else { - setVisible(true); + handleUnauthorized(); } } else { handleReact(meEmojiReact); @@ -109,6 +110,13 @@ const EmojiButtonWrapper: React.FC = ({ statusId, children e.stopPropagation(); }; + const handleUnauthorized = () => { + dispatch(openModal('UNAUTHORIZED', { + action: 'FAVOURITE', + ap_id: status.url, + })); + }; + // const handleUnfocus: React.EventHandler = () => { // setFocused(false); // }; diff --git a/app/soapbox/components/media_gallery.tsx b/app/soapbox/components/media_gallery.tsx index 4f672865b..f899d7ba4 100644 --- a/app/soapbox/components/media_gallery.tsx +++ b/app/soapbox/components/media_gallery.tsx @@ -1,6 +1,5 @@ import classNames from 'clsx'; import React, { useState, useRef, useEffect } from 'react'; -import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import Blurhash from 'soapbox/components/blurhash'; import Icon from 'soapbox/components/icon'; @@ -13,8 +12,6 @@ import { truncateFilename } from 'soapbox/utils/media'; import { isIOS } from '../is_mobile'; import { isPanoramic, isPortrait, isNonConformingRatio, minimumAspectRatio, maximumAspectRatio } from '../utils/media_aspect_ratio'; -import { Button, Text } from './ui'; - import type { Property } from 'csstype'; import type { List as ImmutableList } from 'immutable'; @@ -39,10 +36,6 @@ interface SizeData { width: number, } -const messages = defineMessages({ - toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Hide' }, -}); - const withinLimits = (aspectRatio: number) => { return aspectRatio >= minimumAspectRatio && aspectRatio <= maximumAspectRatio; }; @@ -276,35 +269,16 @@ interface IMediaGallery { const MediaGallery: React.FC = (props) => { const { media, - sensitive = false, defaultWidth = 0, - onToggleVisibility, onOpenMedia, cacheWidth, compact, height, } = props; - - const intl = useIntl(); - - const settings = useSettings(); - const displayMedia = settings.get('displayMedia') as string | undefined; - - const [visible, setVisible] = useState(props.visible !== undefined ? props.visible : (displayMedia !== 'hide_all' && !sensitive || displayMedia === 'show_all')); const [width, setWidth] = useState(defaultWidth); const node = useRef(null); - const handleOpen: React.MouseEventHandler = (e) => { - e.stopPropagation(); - - if (onToggleVisibility) { - onToggleVisibility(); - } else { - setVisible(!visible); - } - }; - const handleClick = (index: number) => { onOpenMedia(media, index); }; @@ -545,20 +519,13 @@ const MediaGallery: React.FC = (props) => { index={i} size={sizeData.size} displayWidth={sizeData.width} - visible={visible} + visible={!!props.visible} dimensions={sizeData.itemsDimensions[i]} last={i === ATTACHMENT_LIMIT - 1} total={media.size} /> )); - let warning; - - if (sensitive) { - warning = ; - } else { - warning = ; - } useEffect(() => { if (node.current) { @@ -572,60 +539,8 @@ const MediaGallery: React.FC = (props) => { } }, [node.current]); - useEffect(() => { - setVisible(!!props.visible); - }, [props.visible]); - return (
-
- {sensitive && ( - (visible || compact) ? ( - -
-
- ) - )} - - {children} ); diff --git a/app/soapbox/components/relative-timestamp.tsx b/app/soapbox/components/relative-timestamp.tsx index d530051d8..6af0883c6 100644 --- a/app/soapbox/components/relative-timestamp.tsx +++ b/app/soapbox/components/relative-timestamp.tsx @@ -17,11 +17,11 @@ const messages = defineMessages({ }); const dateFormatOptions: FormatDateOptions = { - hour12: false, + hour12: true, year: 'numeric', month: 'short', day: '2-digit', - hour: '2-digit', + hour: 'numeric', minute: '2-digit', }; @@ -32,8 +32,8 @@ const shortDateFormatOptions: FormatDateOptions = { const SECOND = 1000; const MINUTE = 1000 * 60; -const HOUR = 1000 * 60 * 60; -const DAY = 1000 * 60 * 60 * 24; +const HOUR = 1000 * 60 * 60; +const DAY = 1000 * 60 * 60 * 24; const MAX_DELAY = 2147483647; @@ -170,12 +170,12 @@ class RelativeTimestamp extends React.Component { this.setState({ now: Date.now() }); diff --git a/app/soapbox/components/status-media.tsx b/app/soapbox/components/status-media.tsx index f0b8a5eca..2de080525 100644 --- a/app/soapbox/components/status-media.tsx +++ b/app/soapbox/components/status-media.tsx @@ -32,7 +32,7 @@ const StatusMedia: React.FC = ({ muted = false, onClick, showMedia = true, - onToggleVisibility = () => {}, + onToggleVisibility = () => { }, excludeBanner = false, }) => { const dispatch = useAppDispatch(); @@ -64,7 +64,7 @@ const StatusMedia: React.FC = ({ }; const openMedia = (media: ImmutableList, index: number) => { - dispatch(openModal('MEDIA', { media, index })); + dispatch(openModal('MEDIA', { media, status, index })); }; const openVideo = (media: Attachment, time: number): void => { diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index 60f82999c..6f3bc22e9 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -19,8 +19,8 @@ import StatusActionBar from './status-action-bar'; import StatusMedia from './status-media'; import StatusReplyMentions from './status-reply-mentions'; import StatusContent from './status_content'; -import ModerationOverlay from './statuses/moderation-overlay'; -import { Card, HStack, Text } from './ui'; +import SensitiveContentOverlay from './statuses/sensitive-content-overlay'; +import { Card, HStack, Stack, Text } from './ui'; import type { Map as ImmutableMap } from 'immutable'; import type { @@ -118,9 +118,9 @@ const Status: React.FC = (props) => { if (firstAttachment) { if (firstAttachment.type === 'video') { - dispatch(openModal('VIDEO', { media: firstAttachment, time: 0 })); + dispatch(openModal('VIDEO', { status, media: firstAttachment, time: 0 })); } else { - dispatch(openModal('MEDIA', { media: status.media_attachments, index: 0 })); + dispatch(openModal('MEDIA', { status, media: status.media_attachments, index: 0 })); } } }; @@ -302,6 +302,7 @@ const Status: React.FC = (props) => { const accountAction = props.accountAction || reblogElement; const inReview = status.visibility === 'self'; + const isSensitive = status.sensitive; return ( @@ -352,47 +353,55 @@ const Status: React.FC = (props) => { /> -
- {inReview ? ( - - ) : null} - - {!group && actualStatus.group && ( -
- Posted in {String(actualStatus.getIn(['group', 'title']))} -
- )} - - - - {actualStatus.event ? : ( - <> - - - + + {(inReview || isSensitive) ? ( + + ) : null} - {quote} - - )} + {!group && actualStatus.group && ( +
+ Posted in {String(actualStatus.getIn(['group', 'title']))} +
+ )} + + + + {actualStatus.event ? : ( + <> + + + + + {quote} + + )} +
{!hideActionBar && (
diff --git a/app/soapbox/components/status_list.tsx b/app/soapbox/components/status_list.tsx index 3bee7a03a..09fbe3275 100644 --- a/app/soapbox/components/status_list.tsx +++ b/app/soapbox/components/status_list.tsx @@ -19,7 +19,7 @@ import useAds from 'soapbox/queries/ads'; import type { OrderedSet as ImmutableOrderedSet } from 'immutable'; import type { VirtuosoHandle } from 'react-virtuoso'; import type { IScrollableList } from 'soapbox/components/scrollable_list'; -import type { Ad as AdEntity } from 'soapbox/features/ads/providers'; +import type { Ad as AdEntity } from 'soapbox/types/soapbox'; interface IStatusList extends Omit { /** Unique key to preserve the scroll position when navigating back. */ @@ -141,12 +141,7 @@ const StatusList: React.FC = ({ const renderAd = (ad: AdEntity, index: number) => { return ( - + ); }; diff --git a/app/soapbox/components/statuses/__tests__/moderation-overlay.test.tsx b/app/soapbox/components/statuses/__tests__/moderation-overlay.test.tsx deleted file mode 100644 index a94923c55..000000000 --- a/app/soapbox/components/statuses/__tests__/moderation-overlay.test.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; - -import { fireEvent, render, screen } from '../../../jest/test-helpers'; -import ModerationOverlay from '../moderation-overlay'; - -describe('', () => { - it('defaults to enabled', () => { - render(); - expect(screen.getByTestId('moderation-overlay')).toHaveTextContent('Content Under Review'); - }); - - it('can be toggled', () => { - render(); - - fireEvent.click(screen.getByTestId('button')); - expect(screen.getByTestId('moderation-overlay')).not.toHaveTextContent('Content Under Review'); - expect(screen.getByTestId('moderation-overlay')).toHaveTextContent('Hide'); - }); -}); diff --git a/app/soapbox/components/statuses/__tests__/sensitive-content-overlay.test.tsx b/app/soapbox/components/statuses/__tests__/sensitive-content-overlay.test.tsx new file mode 100644 index 000000000..4b2823840 --- /dev/null +++ b/app/soapbox/components/statuses/__tests__/sensitive-content-overlay.test.tsx @@ -0,0 +1,111 @@ +import { Map as ImmutableMap } from 'immutable'; +import React from 'react'; + +import { normalizeStatus } from 'soapbox/normalizers'; +import { ReducerStatus } from 'soapbox/reducers/statuses'; + +import { fireEvent, render, rootState, screen } from '../../../jest/test-helpers'; +import SensitiveContentOverlay from '../sensitive-content-overlay'; + +describe('', () => { + let status: ReducerStatus; + + describe('when the Status is marked as sensitive', () => { + beforeEach(() => { + status = normalizeStatus({ sensitive: true }) as ReducerStatus; + }); + + it('displays the "Sensitive content" warning', () => { + render(); + expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Sensitive content'); + }); + + it('can be toggled', () => { + render(); + + fireEvent.click(screen.getByTestId('button')); + expect(screen.getByTestId('sensitive-overlay')).not.toHaveTextContent('Sensitive content'); + expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Hide'); + + fireEvent.click(screen.getByTestId('button')); + expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Sensitive content'); + expect(screen.getByTestId('sensitive-overlay')).not.toHaveTextContent('Hide'); + }); + }); + + describe('when the Status is marked as in review', () => { + beforeEach(() => { + status = normalizeStatus({ visibility: 'self', sensitive: false }) as ReducerStatus; + }); + + it('displays the "Under review" warning', () => { + render(); + expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Content Under Review'); + }); + + it('can be toggled', () => { + render(); + + fireEvent.click(screen.getByTestId('button')); + expect(screen.getByTestId('sensitive-overlay')).not.toHaveTextContent('Content Under Review'); + expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Hide'); + + fireEvent.click(screen.getByTestId('button')); + expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Content Under Review'); + expect(screen.getByTestId('sensitive-overlay')).not.toHaveTextContent('Hide'); + }); + }); + + describe('when the Status is marked as in review and sensitive', () => { + beforeEach(() => { + status = normalizeStatus({ visibility: 'self', sensitive: true }) as ReducerStatus; + }); + + it('displays the "Under review" warning', () => { + render(); + expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Content Under Review'); + }); + + it('can be toggled', () => { + render(); + + fireEvent.click(screen.getByTestId('button')); + expect(screen.getByTestId('sensitive-overlay')).not.toHaveTextContent('Content Under Review'); + expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Hide'); + + fireEvent.click(screen.getByTestId('button')); + expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Content Under Review'); + expect(screen.getByTestId('sensitive-overlay')).not.toHaveTextContent('Hide'); + }); + }); + + describe('when the Status is marked as sensitive and displayMedia set to "show_all"', () => { + let store: any; + + beforeEach(() => { + status = normalizeStatus({ sensitive: true }) as ReducerStatus; + store = rootState + .set('settings', ImmutableMap({ + displayMedia: 'show_all', + })); + }); + + it('displays the "Under review" warning', () => { + render(, undefined, store); + expect(screen.getByTestId('sensitive-overlay')).not.toHaveTextContent('Sensitive content'); + expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Hide'); + }); + + it('can be toggled', () => { + render(, undefined, store); + + fireEvent.click(screen.getByTestId('button')); + expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Sensitive content'); + expect(screen.getByTestId('sensitive-overlay')).not.toHaveTextContent('Hide'); + + fireEvent.click(screen.getByTestId('button')); + expect(screen.getByTestId('sensitive-overlay')).not.toHaveTextContent('Sensitive content'); + expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Hide'); + }); + }); +}); diff --git a/app/soapbox/components/statuses/moderation-overlay.tsx b/app/soapbox/components/statuses/moderation-overlay.tsx deleted file mode 100644 index 6d572eb3a..000000000 --- a/app/soapbox/components/statuses/moderation-overlay.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import classNames from 'clsx'; -import React, { useState } from 'react'; -import { defineMessages, useIntl } from 'react-intl'; - -import { useSoapboxConfig } from 'soapbox/hooks'; - -import { Button, HStack, Text } from '../ui'; - -const messages = defineMessages({ - hide: { id: 'moderation_overlay.hide', defaultMessage: 'Hide' }, - title: { id: 'moderation_overlay.title', defaultMessage: 'Content Under Review' }, - subtitle: { id: 'moderation_overlay.subtitle', defaultMessage: 'This Post has been sent to Moderation for review and is only visible to you. If you believe this is an error please contact Support.' }, - contact: { id: 'moderation_overlay.contact', defaultMessage: 'Contact' }, - show: { id: 'moderation_overlay.show', defaultMessage: 'Show Content' }, -}); - -const ModerationOverlay = () => { - const intl = useIntl(); - - const { links } = useSoapboxConfig(); - - const [visible, setVisible] = useState(false); - - const toggleVisibility = (event: React.MouseEvent) => { - event.stopPropagation(); - - setVisible((prevValue) => !prevValue); - }; - - return ( -
- {visible ? ( - - - )} - - - -
- )} -
- ); -}; - -export default ModerationOverlay; \ No newline at end of file diff --git a/app/soapbox/components/statuses/sensitive-content-overlay.tsx b/app/soapbox/components/statuses/sensitive-content-overlay.tsx new file mode 100644 index 000000000..7aebfe521 --- /dev/null +++ b/app/soapbox/components/statuses/sensitive-content-overlay.tsx @@ -0,0 +1,124 @@ +import classNames from 'clsx'; +import React, { useEffect, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { useSettings, useSoapboxConfig } from 'soapbox/hooks'; +import { defaultMediaVisibility } from 'soapbox/utils/status'; + +import { Button, HStack, Text } from '../ui'; + +import type { Status as StatusEntity } from 'soapbox/types/entities'; + +const messages = defineMessages({ + hide: { id: 'moderation_overlay.hide', defaultMessage: 'Hide content' }, + sensitiveTitle: { id: 'status.sensitive_warning', defaultMessage: 'Sensitive content' }, + underReviewTitle: { id: 'moderation_overlay.title', defaultMessage: 'Content Under Review' }, + underReviewSubtitle: { id: 'moderation_overlay.subtitle', defaultMessage: 'This Post has been sent to Moderation for review and is only visible to you. If you believe this is an error please contact Support.' }, + sensitiveSubtitle: { id: 'status.sensitive_warning.subtitle', defaultMessage: 'This content may not be suitable for all audiences.' }, + contact: { id: 'moderation_overlay.contact', defaultMessage: 'Contact' }, + show: { id: 'moderation_overlay.show', defaultMessage: 'Show Content' }, +}); + +interface ISensitiveContentOverlay { + status: StatusEntity + onToggleVisibility?(): void + visible?: boolean +} + +const SensitiveContentOverlay = (props: ISensitiveContentOverlay) => { + const { onToggleVisibility, status } = props; + const isUnderReview = status.visibility === 'self'; + + const settings = useSettings(); + const displayMedia = settings.get('displayMedia') as string; + + const intl = useIntl(); + + const { links } = useSoapboxConfig(); + + const [visible, setVisible] = useState(defaultMediaVisibility(status, displayMedia)); + + const toggleVisibility = (event: React.MouseEvent) => { + event.stopPropagation(); + + if (onToggleVisibility) { + onToggleVisibility(); + } else { + setVisible((prevValue) => !prevValue); + } + }; + + useEffect(() => { + if (typeof props.visible !== 'undefined') { + setVisible(!!props.visible); + } + }, [props.visible]); + + return ( +
+ {visible ? ( + + + )} + + ) : null} + + + +
+ )} +
+ ); +}; + +export default SensitiveContentOverlay; \ No newline at end of file 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/input/input.tsx b/app/soapbox/components/ui/input/input.tsx index c77ae8169..6be89c2d4 100644 --- a/app/soapbox/components/ui/input/input.tsx +++ b/app/soapbox/components/ui/input/input.tsx @@ -11,7 +11,10 @@ const messages = defineMessages({ hidePassword: { id: 'input.password.hide_password', defaultMessage: 'Hide password' }, }); -interface IInput extends Pick, 'maxLength' | 'onChange' | 'onBlur' | 'type' | 'autoComplete' | 'autoCorrect' | 'autoCapitalize' | 'required' | 'disabled' | 'onClick' | 'readOnly' | 'min' | 'pattern'> { +/** 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, /** The initial text in the input. */ @@ -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(
@@ -82,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': !isSearch, - 'rounded-full bg-gray-200 border-gray-200 dark:bg-gray-800 dark:border-gray-800 focus:bg-white': isSearch, + ['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', @@ -127,4 +131,7 @@ const Input = React.forwardRef( }, ); -export default Input; +export { + Input as default, + InputThemes, +}; 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 06d4bba44..e48b4ffed 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 | 6 | 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', @@ -18,6 +16,7 @@ const spaces = { const justifyContentOptions = { center: 'justify-center', + end: 'justify-end', }; const alignItemsOptions = { @@ -28,15 +27,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' | 'start' | 'end', /** Vertical alignment of children. */ - justifyContent?: 'center', + justifyContent?: keyof typeof justifyContentOptions /** 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/tabs/tabs.css b/app/soapbox/components/ui/tabs/tabs.css index 95f06f2dc..366211c32 100644 --- a/app/soapbox/components/ui/tabs/tabs.css +++ b/app/soapbox/components/ui/tabs/tabs.css @@ -13,8 +13,7 @@ [data-reach-tab] { @apply flex-1 flex justify-center items-center py-4 px-1 text-center font-medium text-sm text-gray-700 - dark:text-gray-600 hover:text-gray-800 dark:hover:text-gray-500 - focus:ring-primary-300 focus:ring-2; + dark:text-gray-600 hover:text-gray-800 dark:hover:text-gray-500; } [data-reach-tab][data-selected] { 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/containers/soapbox.tsx b/app/soapbox/containers/soapbox.tsx index 099bb3b96..2841ec3ca 100644 --- a/app/soapbox/containers/soapbox.tsx +++ b/app/soapbox/containers/soapbox.tsx @@ -138,7 +138,6 @@ const SoapboxMount = () => { )} - {(features.accountCreation && instance.registrations) && ( diff --git a/app/soapbox/features/account/components/header.tsx b/app/soapbox/features/account/components/header.tsx index 925a63b16..21e44f33b 100644 --- a/app/soapbox/features/account/components/header.tsx +++ b/app/soapbox/features/account/components/header.tsx @@ -1,6 +1,6 @@ 'use strict'; -import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; +import { List as ImmutableList } from 'immutable'; import React from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { Link, useHistory } from 'react-router-dom'; @@ -23,6 +23,7 @@ import MovedNote from 'soapbox/features/account_timeline/components/moved_note'; import ActionButton from 'soapbox/features/ui/components/action-button'; import SubscriptionButton from 'soapbox/features/ui/components/subscription-button'; import { useAppDispatch, useFeatures, useOwnAccount } from 'soapbox/hooks'; +import { normalizeAttachment } from 'soapbox/normalizers'; import { Account } from 'soapbox/types/entities'; import { isRemote } from 'soapbox/utils/accounts'; @@ -207,12 +208,9 @@ const Header: React.FC = ({ account }) => { }; const onAvatarClick = () => { - const avatar_url = account.avatar; - const avatar = ImmutableMap({ + const avatar = normalizeAttachment({ type: 'image', - preview_url: avatar_url, - url: avatar_url, - description: '', + url: account.avatar, }); dispatch(openModal('MEDIA', { media: ImmutableList.of(avatar), index: 0 })); }; @@ -225,12 +223,9 @@ const Header: React.FC = ({ account }) => { }; const onHeaderClick = () => { - const header_url = account.header; - const header = ImmutableMap({ + const header = normalizeAttachment({ type: 'image', - preview_url: header_url, - url: header_url, - description: '', + url: account.header, }); dispatch(openModal('MEDIA', { media: ImmutableList.of(header), index: 0 })); }; diff --git a/app/soapbox/features/admin/components/report_status.tsx b/app/soapbox/features/admin/components/report_status.tsx index 6dd89742a..6300c4e33 100644 --- a/app/soapbox/features/admin/components/report_status.tsx +++ b/app/soapbox/features/admin/components/report_status.tsx @@ -27,7 +27,7 @@ const ReportStatus: React.FC = ({ status }) => { const dispatch = useAppDispatch(); const handleOpenMedia = (media: Attachment, index: number) => { - dispatch(openModal('MEDIA', { media, index })); + dispatch(openModal('MEDIA', { media, status, index })); }; const handleDeleteStatus = () => { diff --git a/app/soapbox/features/admin/moderation_log.tsx b/app/soapbox/features/admin/moderation_log.tsx index b7a9bce2e..606858ec8 100644 --- a/app/soapbox/features/admin/moderation_log.tsx +++ b/app/soapbox/features/admin/moderation_log.tsx @@ -32,7 +32,7 @@ const ModerationLog = () => { setIsLoading(false); setLastPage(1); }) - .catch(() => {}); + .catch(() => { }); }, []); const handleLoadMore = () => { @@ -43,7 +43,7 @@ const ModerationLog = () => { .then(() => { setIsLoading(false); setLastPage(page); - }).catch(() => {}); + }).catch(() => { }); }; return ( @@ -62,11 +62,11 @@ const ModerationLog = () => {
diff --git a/app/soapbox/features/ads/components/ad.tsx b/app/soapbox/features/ads/components/ad.tsx index 5c1ff6a41..a0db9f78d 100644 --- a/app/soapbox/features/ads/components/ad.tsx +++ b/app/soapbox/features/ads/components/ad.tsx @@ -1,4 +1,4 @@ -import { useQueryClient } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import React, { useState, useEffect, useRef } from 'react'; import { FormattedMessage } from 'react-intl'; @@ -7,19 +7,14 @@ import IconButton from 'soapbox/components/ui/icon-button/icon-button'; import StatusCard from 'soapbox/features/status/components/card'; import { useAppSelector } from 'soapbox/hooks'; -import type { Card as CardEntity } from 'soapbox/types/entities'; +import type { Ad as AdEntity } from 'soapbox/types/soapbox'; interface IAd { - /** Embedded ad data in Card format (almost like OEmbed). */ - card: CardEntity, - /** Impression URL to fetch upon display. */ - impression?: string, - /** Time when the ad expires and should no longer be displayed. */ - expires?: Date, + ad: AdEntity, } /** Displays an ad in sponsored post format. */ -const Ad: React.FC = ({ card, impression, expires }) => { +const Ad: React.FC = ({ ad }) => { const queryClient = useQueryClient(); const instance = useAppSelector(state => state.instance); @@ -27,6 +22,14 @@ const Ad: React.FC = ({ card, impression, expires }) => { const infobox = useRef(null); const [showInfo, setShowInfo] = useState(false); + // Fetch the impression URL (if any) upon displaying the ad. + // Don't fetch it more than once. + useQuery(['ads', 'impression', ad.impression], () => { + if (ad.impression) { + return fetch(ad.impression); + } + }, { cacheTime: Infinity, staleTime: Infinity }); + /** Invalidate query cache for ads. */ const bustCache = (): void => { queryClient.invalidateQueries(['ads']); @@ -53,18 +56,10 @@ const Ad: React.FC = ({ card, impression, expires }) => { }; }, [infobox]); - // Fetch the impression URL (if any) upon displaying the ad. - // It's common for ad providers to provide this. - useEffect(() => { - if (impression) { - fetch(impression); - } - }, [impression]); - // Wait until the ad expires, then invalidate cache. useEffect(() => { - if (expires) { - const delta = expires.getTime() - (new Date()).getTime(); + if (ad.expires_at) { + const delta = new Date(ad.expires_at).getTime() - (new Date()).getTime(); timer.current = setTimeout(bustCache, delta); } @@ -73,7 +68,7 @@ const Ad: React.FC = ({ card, impression, expires }) => { clearTimeout(timer.current); } }; - }, [expires]); + }, [ad.expires_at]); return (
@@ -112,7 +107,7 @@ const Ad: React.FC = ({ card, impression, expires }) => { - {}} horizontal /> + {}} horizontal /> @@ -125,11 +120,15 @@ const Ad: React.FC = ({ card, impression, expires }) => { - + {ad.reason ? ( + ad.reason + ) : ( + + )} diff --git a/app/soapbox/features/ads/providers/index.ts b/app/soapbox/features/ads/providers/index.ts index b9c504bff..bd17fa91c 100644 --- a/app/soapbox/features/ads/providers/index.ts +++ b/app/soapbox/features/ads/providers/index.ts @@ -7,6 +7,7 @@ import type { Card } from 'soapbox/types/entities'; const PROVIDERS: Record Promise> = { soapbox: async() => (await import(/* webpackChunkName: "features/ads/soapbox" */'./soapbox-config')).default, rumble: async() => (await import(/* webpackChunkName: "features/ads/rumble" */'./rumble')).default, + truth: async() => (await import(/* webpackChunkName: "features/ads/truth" */'./truth')).default, }; /** Ad server implementation. */ @@ -21,7 +22,9 @@ interface Ad { /** Impression URL to fetch when displaying the ad. */ impression?: string, /** Time when the ad expires and should no longer be displayed. */ - expires?: Date, + expires_at?: string, + /** Reason the ad is displayed. */ + reason?: string, } /** Gets the current provider based on config. */ diff --git a/app/soapbox/features/ads/providers/rumble.ts b/app/soapbox/features/ads/providers/rumble.ts index ace4021f0..bc86e8686 100644 --- a/app/soapbox/features/ads/providers/rumble.ts +++ b/app/soapbox/features/ads/providers/rumble.ts @@ -1,6 +1,6 @@ import { getSettings } from 'soapbox/actions/settings'; import { getSoapboxConfig } from 'soapbox/actions/soapbox'; -import { normalizeCard } from 'soapbox/normalizers'; +import { normalizeAd, normalizeCard } from 'soapbox/normalizers'; import type { AdProvider } from '.'; @@ -36,14 +36,14 @@ const RumbleAdProvider: AdProvider = { if (response.ok) { const data = await response.json() as RumbleApiResponse; - return data.ads.map(item => ({ + return data.ads.map(item => normalizeAd({ impression: item.impression, card: normalizeCard({ type: item.type === 1 ? 'link' : 'rich', image: item.asset, url: item.click, }), - expires: new Date(item.expires * 1000), + expires_at: new Date(item.expires * 1000), })); } } diff --git a/app/soapbox/features/ads/providers/truth.ts b/app/soapbox/features/ads/providers/truth.ts new file mode 100644 index 000000000..92f2e99f8 --- /dev/null +++ b/app/soapbox/features/ads/providers/truth.ts @@ -0,0 +1,39 @@ +import { getSettings } from 'soapbox/actions/settings'; +import { normalizeCard } from 'soapbox/normalizers'; + +import type { AdProvider } from '.'; +import type { Card } from 'soapbox/types/entities'; + +/** TruthSocial ad API entity. */ +interface TruthAd { + impression: string, + card: Card, + expires_at: string, + reason: string, +} + +/** Provides ads from the TruthSocial API. */ +const TruthAdProvider: AdProvider = { + getAds: async(getState) => { + const state = getState(); + const settings = getSettings(state); + + const response = await fetch('/api/v2/truth/ads?device=desktop', { + headers: { + 'Accept-Language': settings.get('locale', '*') as string, + }, + }); + + if (response.ok) { + const data = await response.json() as TruthAd[]; + return data.map(item => ({ + ...item, + card: normalizeCard(item.card), + })); + } + + return []; + }, +}; + +export default TruthAdProvider; 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 ( -
-