diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index aaca197c3a..01c6314c9f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -148,6 +148,8 @@ docker: image: docker:20.10.17 services: - docker:20.10.17-dind + tags: + - dind # https://medium.com/devops-with-valentine/how-to-build-a-docker-image-and-push-it-to-the-gitlab-container-registry-from-a-gitlab-ci-pipeline-acac0d1f26df script: - echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER $CI_REGISTRY --password-stdin diff --git a/app/images/reticle.png b/app/images/reticle.png deleted file mode 100644 index 1bcb3d261a..0000000000 Binary files a/app/images/reticle.png and /dev/null differ diff --git a/app/soapbox/actions/mobile.ts b/app/soapbox/actions/mobile.ts deleted file mode 100644 index 1e11f473d7..0000000000 --- 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 f6344f7b5d..ea96b37fd1 100644 --- a/app/soapbox/components/announcements/announcement.tsx +++ b/app/soapbox/components/announcements/announcement.tsx @@ -34,22 +34,23 @@ const Announcement: React.FC = ({ announcement, addReaction, remo {' '} - {' '} diff --git a/app/soapbox/components/attachment-thumbs.tsx b/app/soapbox/components/attachment-thumbs.tsx index 37b9fc9c6e..3ac1dbf5ff 100644 --- a/app/soapbox/components/attachment-thumbs.tsx +++ b/app/soapbox/components/attachment-thumbs.tsx @@ -6,9 +6,10 @@ import Bundle from 'soapbox/features/ui/components/bundle'; import { MediaGallery } from 'soapbox/features/ui/util/async-components'; import type { List as ImmutableList } from 'immutable'; +import type { Attachment } from 'soapbox/types/entities'; interface IAttachmentThumbs { - media: ImmutableList> + media: ImmutableList onClick?(): void sensitive?: boolean } @@ -18,7 +19,7 @@ const AttachmentThumbs = (props: IAttachmentThumbs) => { const dispatch = useDispatch(); const renderLoading = () =>
; - const onOpenMedia = (media: Immutable.Record, index: number) => dispatch(openModal('MEDIA', { media, index })); + const onOpenMedia = (media: ImmutableList, index: number) => dispatch(openModal('MEDIA', { media, index })); return (
@@ -30,6 +31,7 @@ const AttachmentThumbs = (props: IAttachmentThumbs) => { height={50} compact sensitive={sensitive} + visible /> )} diff --git a/app/soapbox/components/media_gallery.tsx b/app/soapbox/components/media_gallery.tsx index dfb9c7df52..4f672865b0 100644 --- a/app/soapbox/components/media_gallery.tsx +++ b/app/soapbox/components/media_gallery.tsx @@ -263,14 +263,13 @@ const Item: React.FC = ({ interface IMediaGallery { sensitive?: boolean, media: ImmutableList, - size: number, height: number, onOpenMedia: (media: ImmutableList, index: number) => void, - defaultWidth: number, - cacheWidth: (width: number) => void, + defaultWidth?: number, + cacheWidth?: (width: number) => void, visible?: boolean, onToggleVisibility?: () => void, - displayMedia: string, + displayMedia?: string, compact: boolean, } @@ -278,7 +277,7 @@ const MediaGallery: React.FC = (props) => { const { media, sensitive = false, - defaultWidth, + defaultWidth = 0, onToggleVisibility, onOpenMedia, cacheWidth, diff --git a/app/soapbox/components/profile-hover-card.tsx b/app/soapbox/components/profile-hover-card.tsx index 3775a107cc..5baabeb86a 100644 --- a/app/soapbox/components/profile-hover-card.tsx +++ b/app/soapbox/components/profile-hover-card.tsx @@ -1,6 +1,6 @@ import classNames from 'clsx'; import React, { useEffect, useState } from 'react'; -import { FormattedMessage } from 'react-intl'; +import { useIntl, FormattedMessage } from 'react-intl'; import { usePopper } from 'react-popper'; import { useHistory } from 'react-router-dom'; @@ -15,9 +15,10 @@ import BundleContainer from 'soapbox/features/ui/containers/bundle_container'; import { UserPanel } from 'soapbox/features/ui/util/async-components'; import { useAppSelector, useAppDispatch } from 'soapbox/hooks'; import { makeGetAccount } from 'soapbox/selectors'; +import { isLocal } from 'soapbox/utils/accounts'; import { showProfileHoverCard } from './hover_ref_wrapper'; -import { Card, CardBody, Stack, Text } from './ui'; +import { Card, CardBody, HStack, Icon, Stack, Text } from './ui'; import type { AppDispatch } from 'soapbox/store'; import type { Account } from 'soapbox/types/entities'; @@ -60,6 +61,7 @@ interface IProfileHoverCard { export const ProfileHoverCard: React.FC = ({ visible = true }) => { const dispatch = useAppDispatch(); const history = useHistory(); + const intl = useIntl(); const [popperElement, setPopperElement] = useState(null); @@ -88,6 +90,7 @@ export const ProfileHoverCard: React.FC = ({ visible = true } if (!account) return null; const accountBio = { __html: account.note_emojified }; + const memberSinceDate = intl.formatDate(account.created_at, { month: 'long', year: 'numeric' }); const followedBy = me !== account.id && account.relationship?.followed_by === true; return ( @@ -116,6 +119,23 @@ export const ProfileHoverCard: React.FC = ({ visible = true } )} + {isLocal(account) ? ( + + + + + + + + ) : null} + {account.source.get('note', '').length > 0 && ( )} diff --git a/app/soapbox/components/relative-timestamp.tsx b/app/soapbox/components/relative-timestamp.tsx index d530051d8f..6af0883c63 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-action-bar.tsx b/app/soapbox/components/status-action-bar.tsx index 4e3bcba3b1..75bdda7355 100644 --- a/app/soapbox/components/status-action-bar.tsx +++ b/app/soapbox/components/status-action-bar.tsx @@ -374,7 +374,7 @@ const StatusActionBar: React.FC = ({ menu.push({ text: intl.formatMessage(status.pinned ? messages.unpin : messages.pin), action: handlePinClick, - icon: mutingConversation ? require('@tabler/icons/pinned-off.svg') : require('@tabler/icons/pin.svg'), + icon: status.pinned ? require('@tabler/icons/pinned-off.svg') : require('@tabler/icons/pin.svg'), }); } else { if (status.visibility === 'private') { 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..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', @@ -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 f10df6d3a6..b29cf79060 100644 --- a/app/soapbox/components/ui/modal/modal.tsx +++ b/app/soapbox/components/ui/modal/modal.tsx @@ -11,8 +11,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', @@ -52,7 +50,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 ab347ad6d9..4f3d126593 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', @@ -27,15 +25,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', /** 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/tabs/tabs.css b/app/soapbox/components/ui/tabs/tabs.css index 95f06f2dca..366211c32a 100644 Binary files a/app/soapbox/components/ui/tabs/tabs.css and b/app/soapbox/components/ui/tabs/tabs.css differ 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, } diff --git a/app/soapbox/containers/soapbox.tsx b/app/soapbox/containers/soapbox.tsx index ff4d442ae3..e26154ba5d 100644 --- a/app/soapbox/containers/soapbox.tsx +++ b/app/soapbox/containers/soapbox.tsx @@ -139,7 +139,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 ea57ebb841..1db50793c1 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/moderation_log.tsx b/app/soapbox/features/admin/moderation_log.tsx index b7a9bce2ea..606858ec8d 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/aliases/components/account.tsx b/app/soapbox/features/aliases/components/account.tsx index be9178b8d4..802d3e0f6b 100644 --- a/app/soapbox/features/aliases/components/account.tsx +++ b/app/soapbox/features/aliases/components/account.tsx @@ -23,7 +23,7 @@ interface IAccount { const Account: React.FC = ({ accountId, aliases }) => { const intl = useIntl(); const dispatch = useAppDispatch(); - + const getAccount = useCallback(makeGetAccount(), []); const account = useAppSelector((state) => getAccount(state, accountId)); diff --git a/app/soapbox/features/audio/index.js b/app/soapbox/features/audio/index.js deleted file mode 100644 index 98bfc66b50..0000000000 Binary files a/app/soapbox/features/audio/index.js and /dev/null differ diff --git a/app/soapbox/features/audio/index.tsx b/app/soapbox/features/audio/index.tsx new file mode 100644 index 0000000000..0bef3c3d92 --- /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 ( +
+
diff --git a/app/soapbox/features/status/components/thread-login-cta.tsx b/app/soapbox/features/status/components/thread-login-cta.tsx index 07c244bee4..e73a8e18bb 100644 --- a/app/soapbox/features/status/components/thread-login-cta.tsx +++ b/app/soapbox/features/status/components/thread-login-cta.tsx @@ -2,12 +2,15 @@ import React from 'react'; import { FormattedMessage } from 'react-intl'; import { Card, CardTitle, Text, Stack, Button } from 'soapbox/components/ui'; -import { useAppSelector } from 'soapbox/hooks'; +import { useAppSelector, useSoapboxConfig } from 'soapbox/hooks'; /** Prompts logged-out users to log in when viewing a thread. */ const ThreadLoginCta: React.FC = () => { + const { displayCta } = useSoapboxConfig(); const siteTitle = useAppSelector(state => state.instance.title); + if (!displayCta) return null; + return ( diff --git a/app/soapbox/features/ui/components/compare_history_modal.tsx b/app/soapbox/features/ui/components/compare_history_modal.tsx index 62898a8cb1..dd73c8d79a 100644 --- a/app/soapbox/features/ui/components/compare_history_modal.tsx +++ b/app/soapbox/features/ui/components/compare_history_modal.tsx @@ -79,7 +79,7 @@ const CompareHistoryModal: React.FC = ({ onClose, statusId )} - +
); diff --git a/app/soapbox/features/ui/components/cta-banner.tsx b/app/soapbox/features/ui/components/cta-banner.tsx index 9d83c67023..fa4ceba909 100644 --- a/app/soapbox/features/ui/components/cta-banner.tsx +++ b/app/soapbox/features/ui/components/cta-banner.tsx @@ -5,11 +5,11 @@ import { Banner, Button, HStack, Stack, Text } from 'soapbox/components/ui'; import { useAppSelector, useSoapboxConfig } from 'soapbox/hooks'; const CtaBanner = () => { - const { singleUserMode } = useSoapboxConfig(); + const { displayCta, singleUserMode } = useSoapboxConfig(); const siteTitle = useAppSelector((state) => state.instance.title); const me = useAppSelector((state) => state.me); - if (me || singleUserMode) return null; + if (me || !displayCta || singleUserMode) return null; return (
diff --git a/app/soapbox/features/ui/components/focal_point_modal.js b/app/soapbox/features/ui/components/focal_point_modal.js deleted file mode 100644 index b45b367eb4..0000000000 Binary files a/app/soapbox/features/ui/components/focal_point_modal.js and /dev/null differ diff --git a/app/soapbox/features/ui/components/image_loader.js b/app/soapbox/features/ui/components/image-loader.tsx similarity index 74% rename from app/soapbox/features/ui/components/image_loader.js rename to app/soapbox/features/ui/components/image-loader.tsx index 8e6e8ec55b..ae60420caa 100644 Binary files a/app/soapbox/features/ui/components/image_loader.js and b/app/soapbox/features/ui/components/image-loader.tsx differ diff --git a/app/soapbox/features/ui/components/media-modal.tsx b/app/soapbox/features/ui/components/media-modal.tsx new file mode 100644 index 0000000000..a04808ece8 --- /dev/null +++ b/app/soapbox/features/ui/components/media-modal.tsx @@ -0,0 +1,300 @@ +import classNames from 'clsx'; +import React, { useEffect, useState } from 'react'; +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; +import { useHistory } from 'react-router-dom'; +import ReactSwipeableViews from 'react-swipeable-views'; + +import ExtendedVideoPlayer from 'soapbox/components/extended_video_player'; +import Icon from 'soapbox/components/icon'; +import IconButton from 'soapbox/components/icon_button'; +import Audio from 'soapbox/features/audio'; +import Video from 'soapbox/features/video'; + +import ImageLoader from './image-loader'; + +import type { List as ImmutableList } from 'immutable'; +import type { Account, Attachment, Status } from 'soapbox/types/entities'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, + previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, + next: { id: 'lightbox.next', defaultMessage: 'Next' }, +}); + +interface IMediaModal { + media: ImmutableList, + status: Status, + account: Account, + index: number, + time?: number, + onClose: () => void, +} + +const MediaModal: React.FC = (props) => { + const { + media, + status, + account, + onClose, + time = 0, + } = props; + + const intl = useIntl(); + const history = useHistory(); + + const [index, setIndex] = useState(null); + const [navigationHidden, setNavigationHidden] = useState(false); + + const handleSwipe = (index: number) => { + setIndex(index % media.size); + }; + + const handleNextClick = () => { + setIndex((getIndex() + 1) % media.size); + }; + + const handlePrevClick = () => { + setIndex((media.size + getIndex() - 1) % media.size); + }; + + const handleChangeIndex: React.MouseEventHandler = (e) => { + const index = Number(e.currentTarget.getAttribute('data-index')); + setIndex(index % media.size); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case 'ArrowLeft': + handlePrevClick(); + e.preventDefault(); + e.stopPropagation(); + break; + case 'ArrowRight': + handleNextClick(); + e.preventDefault(); + e.stopPropagation(); + break; + } + }; + + useEffect(() => { + window.addEventListener('keydown', handleKeyDown, false); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, []); + + const getIndex = () => { + return index !== null ? index : props.index; + }; + + const toggleNavigation = () => { + setNavigationHidden(!navigationHidden); + }; + + const handleStatusClick: React.MouseEventHandler = e => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + history.push(`/@${account.acct}/posts/${status.id}`); + onClose(); + } + }; + + const handleCloserClick: React.MouseEventHandler = ({ currentTarget }) => { + const whitelist = ['zoomable-image']; + const activeSlide = document.querySelector('.media-modal .react-swipeable-view-container > div[aria-hidden="false"]'); + + const isClickOutside = currentTarget === activeSlide || !activeSlide?.contains(currentTarget); + const isWhitelisted = whitelist.some(w => currentTarget.classList.contains(w)); + + if (isClickOutside || isWhitelisted) { + onClose(); + } + }; + + let pagination: React.ReactNode[] = []; + + const leftNav = media.size > 1 && ( + + ); + + const rightNav = media.size > 1 && ( + + ); + + if (media.size > 1) { + pagination = media.toArray().map((item, i) => { + const classes = ['media-modal__button']; + if (i === getIndex()) { + classes.push('media-modal__button--active'); + } + return ( +
  • + +
  • + ); + }); + } + + const isMultiMedia = media.map((image) => { + if (image.type !== 'image') { + return true; + } + + return false; + }).toArray(); + + const content = media.map(attachment => { + const width = (attachment.meta.getIn(['original', 'width']) || undefined) as number | undefined; + const height = (attachment.meta.getIn(['original', 'height']) || undefined) as number | undefined; + + const link = (status && account && ( + + + + )); + + if (attachment.type === 'image') { + return ( + + ); + } else if (attachment.type === 'video') { + return ( +