Merge remote-tracking branch 'origin/develop' into chats
This commit is contained in:
commit
65a8bf9aa1
|
@ -148,6 +148,8 @@ docker:
|
||||||
image: docker:20.10.17
|
image: docker:20.10.17
|
||||||
services:
|
services:
|
||||||
- docker:20.10.17-dind
|
- 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
|
# 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:
|
script:
|
||||||
- echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER $CI_REGISTRY --password-stdin
|
- echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER $CI_REGISTRY --password-stdin
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 1.4 KiB |
|
@ -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,
|
|
||||||
};
|
|
|
@ -34,22 +34,23 @@ const Announcement: React.FC<IAnnouncement> = ({ announcement, addReaction, remo
|
||||||
<Text theme='muted'>
|
<Text theme='muted'>
|
||||||
<FormattedDate
|
<FormattedDate
|
||||||
value={startsAt}
|
value={startsAt}
|
||||||
hour12={false}
|
hour12
|
||||||
year={(skipYear || startsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'}
|
year={(skipYear || startsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'}
|
||||||
month='short'
|
month='short'
|
||||||
day='2-digit'
|
day='2-digit'
|
||||||
hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'}
|
hour={skipTime ? undefined : 'numeric'}
|
||||||
|
minute={skipTime ? undefined : '2-digit'}
|
||||||
/>
|
/>
|
||||||
{' '}
|
{' '}
|
||||||
-
|
-
|
||||||
{' '}
|
{' '}
|
||||||
<FormattedDate
|
<FormattedDate
|
||||||
value={endsAt}
|
value={endsAt}
|
||||||
hour12={false}
|
hour12
|
||||||
year={(skipYear || endsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'}
|
year={(skipYear || endsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'}
|
||||||
month={skipEndDate ? undefined : 'short'}
|
month={skipEndDate ? undefined : 'short'}
|
||||||
day={skipEndDate ? undefined : '2-digit'}
|
day={skipEndDate ? undefined : '2-digit'}
|
||||||
hour={skipTime ? undefined : '2-digit'}
|
hour={skipTime ? undefined : 'numeric'}
|
||||||
minute={skipTime ? undefined : '2-digit'}
|
minute={skipTime ? undefined : '2-digit'}
|
||||||
/>
|
/>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
|
@ -6,9 +6,10 @@ import Bundle from 'soapbox/features/ui/components/bundle';
|
||||||
import { MediaGallery } from 'soapbox/features/ui/util/async-components';
|
import { MediaGallery } from 'soapbox/features/ui/util/async-components';
|
||||||
|
|
||||||
import type { List as ImmutableList } from 'immutable';
|
import type { List as ImmutableList } from 'immutable';
|
||||||
|
import type { Attachment } from 'soapbox/types/entities';
|
||||||
|
|
||||||
interface IAttachmentThumbs {
|
interface IAttachmentThumbs {
|
||||||
media: ImmutableList<Immutable.Record<any>>
|
media: ImmutableList<Attachment>
|
||||||
onClick?(): void
|
onClick?(): void
|
||||||
sensitive?: boolean
|
sensitive?: boolean
|
||||||
}
|
}
|
||||||
|
@ -18,7 +19,7 @@ const AttachmentThumbs = (props: IAttachmentThumbs) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
const renderLoading = () => <div className='media-gallery--compact' />;
|
const renderLoading = () => <div className='media-gallery--compact' />;
|
||||||
const onOpenMedia = (media: Immutable.Record<any>, index: number) => dispatch(openModal('MEDIA', { media, index }));
|
const onOpenMedia = (media: ImmutableList<Attachment>, index: number) => dispatch(openModal('MEDIA', { media, index }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='attachment-thumbs'>
|
<div className='attachment-thumbs'>
|
||||||
|
@ -30,6 +31,7 @@ const AttachmentThumbs = (props: IAttachmentThumbs) => {
|
||||||
height={50}
|
height={50}
|
||||||
compact
|
compact
|
||||||
sensitive={sensitive}
|
sensitive={sensitive}
|
||||||
|
visible
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Bundle>
|
</Bundle>
|
||||||
|
|
|
@ -263,14 +263,13 @@ const Item: React.FC<IItem> = ({
|
||||||
interface IMediaGallery {
|
interface IMediaGallery {
|
||||||
sensitive?: boolean,
|
sensitive?: boolean,
|
||||||
media: ImmutableList<Attachment>,
|
media: ImmutableList<Attachment>,
|
||||||
size: number,
|
|
||||||
height: number,
|
height: number,
|
||||||
onOpenMedia: (media: ImmutableList<Attachment>, index: number) => void,
|
onOpenMedia: (media: ImmutableList<Attachment>, index: number) => void,
|
||||||
defaultWidth: number,
|
defaultWidth?: number,
|
||||||
cacheWidth: (width: number) => void,
|
cacheWidth?: (width: number) => void,
|
||||||
visible?: boolean,
|
visible?: boolean,
|
||||||
onToggleVisibility?: () => void,
|
onToggleVisibility?: () => void,
|
||||||
displayMedia: string,
|
displayMedia?: string,
|
||||||
compact: boolean,
|
compact: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -278,7 +277,7 @@ const MediaGallery: React.FC<IMediaGallery> = (props) => {
|
||||||
const {
|
const {
|
||||||
media,
|
media,
|
||||||
sensitive = false,
|
sensitive = false,
|
||||||
defaultWidth,
|
defaultWidth = 0,
|
||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
onOpenMedia,
|
onOpenMedia,
|
||||||
cacheWidth,
|
cacheWidth,
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import classNames from 'clsx';
|
import classNames from 'clsx';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { useIntl, FormattedMessage } from 'react-intl';
|
||||||
import { usePopper } from 'react-popper';
|
import { usePopper } from 'react-popper';
|
||||||
import { useHistory } from 'react-router-dom';
|
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 { UserPanel } from 'soapbox/features/ui/util/async-components';
|
||||||
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
||||||
import { makeGetAccount } from 'soapbox/selectors';
|
import { makeGetAccount } from 'soapbox/selectors';
|
||||||
|
import { isLocal } from 'soapbox/utils/accounts';
|
||||||
|
|
||||||
import { showProfileHoverCard } from './hover_ref_wrapper';
|
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 { AppDispatch } from 'soapbox/store';
|
||||||
import type { Account } from 'soapbox/types/entities';
|
import type { Account } from 'soapbox/types/entities';
|
||||||
|
@ -60,6 +61,7 @@ interface IProfileHoverCard {
|
||||||
export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }) => {
|
export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
const [popperElement, setPopperElement] = useState<HTMLElement | null>(null);
|
const [popperElement, setPopperElement] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
@ -88,6 +90,7 @@ export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }
|
||||||
|
|
||||||
if (!account) return null;
|
if (!account) return null;
|
||||||
const accountBio = { __html: account.note_emojified };
|
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;
|
const followedBy = me !== account.id && account.relationship?.followed_by === true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -116,6 +119,23 @@ export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }
|
||||||
)}
|
)}
|
||||||
</BundleContainer>
|
</BundleContainer>
|
||||||
|
|
||||||
|
{isLocal(account) ? (
|
||||||
|
<HStack alignItems='center' space={0.5}>
|
||||||
|
<Icon
|
||||||
|
src={require('@tabler/icons/calendar.svg')}
|
||||||
|
className='w-4 h-4 text-gray-800 dark:text-gray-200'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text size='sm'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account.member_since' defaultMessage='Joined {date}' values={{
|
||||||
|
date: memberSinceDate,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{account.source.get('note', '').length > 0 && (
|
{account.source.get('note', '').length > 0 && (
|
||||||
<Text size='sm' dangerouslySetInnerHTML={accountBio} />
|
<Text size='sm' dangerouslySetInnerHTML={accountBio} />
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -17,11 +17,11 @@ const messages = defineMessages({
|
||||||
});
|
});
|
||||||
|
|
||||||
const dateFormatOptions: FormatDateOptions = {
|
const dateFormatOptions: FormatDateOptions = {
|
||||||
hour12: false,
|
hour12: true,
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
hour: '2-digit',
|
hour: 'numeric',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -374,7 +374,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||||
menu.push({
|
menu.push({
|
||||||
text: intl.formatMessage(status.pinned ? messages.unpin : messages.pin),
|
text: intl.formatMessage(status.pinned ? messages.unpin : messages.pin),
|
||||||
action: handlePinClick,
|
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 {
|
} else {
|
||||||
if (status.visibility === 'private') {
|
if (status.visibility === 'private') {
|
||||||
|
|
|
@ -1,23 +1,6 @@
|
||||||
import classNames from 'clsx';
|
import classNames from 'clsx';
|
||||||
|
|
||||||
type ButtonThemes = 'primary' | 'secondary' | 'tertiary' | 'accent' | 'danger' | 'transparent' | 'outline'
|
const themes = {
|
||||||
type ButtonSizes = 'sm' | 'md' | 'lg'
|
|
||||||
|
|
||||||
type IButtonStyles = {
|
|
||||||
theme: ButtonThemes,
|
|
||||||
block: boolean,
|
|
||||||
disabled: boolean,
|
|
||||||
size: ButtonSizes
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Provides class names for the <Button> component. */
|
|
||||||
const useButtonStyles = ({
|
|
||||||
theme,
|
|
||||||
block,
|
|
||||||
disabled,
|
|
||||||
size,
|
|
||||||
}: IButtonStyles) => {
|
|
||||||
const themes = {
|
|
||||||
primary:
|
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',
|
'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:
|
secondary:
|
||||||
|
@ -28,15 +11,32 @@ const useButtonStyles = ({
|
||||||
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',
|
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',
|
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',
|
outline: 'border-gray-100 border-2 bg-transparent text-gray-100 hover:bg-white/10',
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
xs: 'px-3 py-1 text-xs',
|
xs: 'px-3 py-1 text-xs',
|
||||||
sm: 'px-3 py-1.5 text-xs leading-4',
|
sm: 'px-3 py-1.5 text-xs leading-4',
|
||||||
md: 'px-4 py-2 text-sm',
|
md: 'px-4 py-2 text-sm',
|
||||||
lg: 'px-6 py-3 text-base',
|
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
|
||||||
|
size: ButtonSizes
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provides class names for the <Button> component. */
|
||||||
|
const useButtonStyles = ({
|
||||||
|
theme,
|
||||||
|
block,
|
||||||
|
disabled,
|
||||||
|
size,
|
||||||
|
}: IButtonStyles) => {
|
||||||
const buttonStyle = classNames({
|
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,
|
'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,
|
'select-none disabled:opacity-75 disabled:cursor-default': disabled,
|
||||||
|
|
|
@ -18,13 +18,13 @@ const messages = defineMessages({
|
||||||
|
|
||||||
interface ICard {
|
interface ICard {
|
||||||
/** The type of card. */
|
/** The type of card. */
|
||||||
variant?: 'default' | 'rounded',
|
variant?: 'default' | 'rounded'
|
||||||
/** Card size preset. */
|
/** Card size preset. */
|
||||||
size?: 'md' | 'lg' | 'xl',
|
size?: keyof typeof sizes
|
||||||
/** Extra classnames for the <div> element. */
|
/** Extra classnames for the <div> element. */
|
||||||
className?: string,
|
className?: string
|
||||||
/** Elements inside the card. */
|
/** Elements inside the card. */
|
||||||
children: React.ReactNode,
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An opaque backdrop to hold a collection of related elements. */
|
/** An opaque backdrop to hold a collection of related elements. */
|
||||||
|
|
|
@ -17,7 +17,7 @@ const alignItemsOptions = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const spaces = {
|
const spaces = {
|
||||||
'0.5': 'space-x-0.5',
|
[0.5]: 'space-x-0.5',
|
||||||
1: 'space-x-1',
|
1: 'space-x-1',
|
||||||
1.5: 'space-x-1.5',
|
1.5: 'space-x-1.5',
|
||||||
2: 'space-x-2',
|
2: 'space-x-2',
|
||||||
|
@ -29,21 +29,21 @@ const spaces = {
|
||||||
|
|
||||||
interface IHStack {
|
interface IHStack {
|
||||||
/** Vertical alignment of children. */
|
/** Vertical alignment of children. */
|
||||||
alignItems?: 'top' | 'bottom' | 'center' | 'start',
|
alignItems?: keyof typeof alignItemsOptions
|
||||||
/** Extra class names on the <div> element. */
|
/** Extra class names on the <div> element. */
|
||||||
className?: string,
|
className?: string
|
||||||
/** Children */
|
/** Children */
|
||||||
children?: React.ReactNode,
|
children?: React.ReactNode
|
||||||
/** Horizontal alignment of children. */
|
/** Horizontal alignment of children. */
|
||||||
justifyContent?: 'between' | 'center' | 'start' | 'end' | 'around',
|
justifyContent?: keyof typeof justifyContentOptions
|
||||||
/** Size of the gap between elements. */
|
/** 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. */
|
/** Whether to let the flexbox grow. */
|
||||||
grow?: boolean,
|
grow?: boolean
|
||||||
/** Extra CSS styles for the <div> */
|
/** Extra CSS styles for the <div> */
|
||||||
style?: React.CSSProperties
|
style?: React.CSSProperties
|
||||||
/** Whether to let the flexbox wrap onto multiple lines. */
|
/** Whether to let the flexbox wrap onto multiple lines. */
|
||||||
wrap?: boolean,
|
wrap?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Horizontal row of child elements. */
|
/** Horizontal row of child elements. */
|
||||||
|
|
|
@ -11,8 +11,6 @@ const messages = defineMessages({
|
||||||
confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
||||||
});
|
});
|
||||||
|
|
||||||
type Widths = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl'
|
|
||||||
|
|
||||||
const widths = {
|
const widths = {
|
||||||
xs: 'max-w-xs',
|
xs: 'max-w-xs',
|
||||||
sm: 'max-w-sm',
|
sm: 'max-w-sm',
|
||||||
|
@ -52,7 +50,7 @@ interface IModal {
|
||||||
skipFocus?: boolean,
|
skipFocus?: boolean,
|
||||||
/** Title text for the modal. */
|
/** Title text for the modal. */
|
||||||
title?: React.ReactNode,
|
title?: React.ReactNode,
|
||||||
width?: Widths,
|
width?: keyof typeof widths,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Displays a modal dialog box. */
|
/** Displays a modal dialog box. */
|
||||||
|
|
|
@ -1,13 +1,11 @@
|
||||||
import classNames from 'clsx';
|
import classNames from 'clsx';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
type SIZES = 0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 10
|
|
||||||
|
|
||||||
const spaces = {
|
const spaces = {
|
||||||
0: 'space-y-0',
|
0: 'space-y-0',
|
||||||
'0.5': 'space-y-0.5',
|
[0.5]: 'space-y-0.5',
|
||||||
1: 'space-y-1',
|
1: 'space-y-1',
|
||||||
'1.5': 'space-y-1.5',
|
[1.5]: 'space-y-1.5',
|
||||||
2: 'space-y-2',
|
2: 'space-y-2',
|
||||||
3: 'space-y-3',
|
3: 'space-y-3',
|
||||||
4: 'space-y-4',
|
4: 'space-y-4',
|
||||||
|
@ -27,15 +25,15 @@ const alignItemsOptions = {
|
||||||
|
|
||||||
interface IStack extends React.HTMLAttributes<HTMLDivElement> {
|
interface IStack extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
/** Size of the gap between elements. */
|
/** Size of the gap between elements. */
|
||||||
space?: SIZES,
|
space?: keyof typeof spaces
|
||||||
/** Horizontal alignment of children. */
|
/** Horizontal alignment of children. */
|
||||||
alignItems?: 'center' | 'start',
|
alignItems?: 'center' | 'start',
|
||||||
/** Vertical alignment of children. */
|
/** Vertical alignment of children. */
|
||||||
justifyContent?: 'center',
|
justifyContent?: 'center'
|
||||||
/** Extra class names on the <div> element. */
|
/** Extra class names on the <div> element. */
|
||||||
className?: string,
|
className?: string
|
||||||
/** Whether to let the flexbox grow. */
|
/** Whether to let the flexbox grow. */
|
||||||
grow?: boolean,
|
grow?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vertical stack of child elements. */
|
/** Vertical stack of child elements. */
|
||||||
|
|
|
@ -13,8 +13,7 @@
|
||||||
[data-reach-tab] {
|
[data-reach-tab] {
|
||||||
@apply flex-1 flex justify-center items-center
|
@apply flex-1 flex justify-center items-center
|
||||||
py-4 px-1 text-center font-medium text-sm text-gray-700
|
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
|
dark:text-gray-600 hover:text-gray-800 dark:hover:text-gray-500;
|
||||||
focus:ring-primary-300 focus:ring-2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-reach-tab][data-selected] {
|
[data-reach-tab][data-selected] {
|
||||||
|
|
|
@ -1,16 +1,6 @@
|
||||||
import classNames from 'clsx';
|
import classNames from 'clsx';
|
||||||
import React from 'react';
|
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 = {
|
const themes = {
|
||||||
default: 'text-gray-900 dark:text-gray-100',
|
default: 'text-gray-900 dark:text-gray-100',
|
||||||
danger: 'text-danger-600',
|
danger: 'text-danger-600',
|
||||||
|
@ -60,15 +50,19 @@ const families = {
|
||||||
mono: 'font-mono',
|
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<React.HTMLAttributes<HTMLParagraphElement>, 'dangerouslySetInnerHTML'> {
|
interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'dangerouslySetInnerHTML'> {
|
||||||
/** How to align the text. */
|
/** How to align the text. */
|
||||||
align?: Alignments,
|
align?: keyof typeof alignments,
|
||||||
/** Extra class names for the outer element. */
|
/** Extra class names for the outer element. */
|
||||||
className?: string,
|
className?: string,
|
||||||
/** Text direction. */
|
/** Text direction. */
|
||||||
direction?: Directions,
|
direction?: Directions,
|
||||||
/** Typeface of the text. */
|
/** Typeface of the text. */
|
||||||
family?: Families,
|
family?: keyof typeof families,
|
||||||
/** The "for" attribute specifies which form element a label is bound to. */
|
/** The "for" attribute specifies which form element a label is bound to. */
|
||||||
htmlFor?: string,
|
htmlFor?: string,
|
||||||
/** Font size of the text. */
|
/** Font size of the text. */
|
||||||
|
@ -76,15 +70,15 @@ interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'danger
|
||||||
/** HTML element name of the outer element. */
|
/** HTML element name of the outer element. */
|
||||||
tag?: Tags,
|
tag?: Tags,
|
||||||
/** Theme for the text. */
|
/** Theme for the text. */
|
||||||
theme?: Themes,
|
theme?: keyof typeof themes,
|
||||||
/** Letter-spacing of the text. */
|
/** Letter-spacing of the text. */
|
||||||
tracking?: TrackingSizes,
|
tracking?: keyof typeof trackingSizes,
|
||||||
/** Transform (eg uppercase) for the text. */
|
/** Transform (eg uppercase) for the text. */
|
||||||
transform?: TransformProperties,
|
transform?: keyof typeof transformProperties,
|
||||||
/** Whether to truncate the text if its container is too small. */
|
/** Whether to truncate the text if its container is too small. */
|
||||||
truncate?: boolean,
|
truncate?: boolean,
|
||||||
/** Font weight of the text. */
|
/** Font weight of the text. */
|
||||||
weight?: Weights,
|
weight?: keyof typeof weights,
|
||||||
/** Tooltip title. */
|
/** Tooltip title. */
|
||||||
title?: string,
|
title?: string,
|
||||||
}
|
}
|
||||||
|
|
|
@ -139,7 +139,6 @@ const SoapboxMount = () => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Route exact path='/about/:slug?' component={PublicLayout} />
|
<Route exact path='/about/:slug?' component={PublicLayout} />
|
||||||
<Route exact path='/mobile/:slug?' component={PublicLayout} />
|
|
||||||
<Route path='/login' component={AuthLayout} />
|
<Route path='/login' component={AuthLayout} />
|
||||||
|
|
||||||
{(features.accountCreation && instance.registrations) && (
|
{(features.accountCreation && instance.registrations) && (
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
import { List as ImmutableList } from 'immutable';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
import { Link, useHistory } from 'react-router-dom';
|
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 ActionButton from 'soapbox/features/ui/components/action-button';
|
||||||
import SubscriptionButton from 'soapbox/features/ui/components/subscription-button';
|
import SubscriptionButton from 'soapbox/features/ui/components/subscription-button';
|
||||||
import { useAppDispatch, useFeatures, useOwnAccount } from 'soapbox/hooks';
|
import { useAppDispatch, useFeatures, useOwnAccount } from 'soapbox/hooks';
|
||||||
|
import { normalizeAttachment } from 'soapbox/normalizers';
|
||||||
import { Account } from 'soapbox/types/entities';
|
import { Account } from 'soapbox/types/entities';
|
||||||
import { isRemote } from 'soapbox/utils/accounts';
|
import { isRemote } from 'soapbox/utils/accounts';
|
||||||
|
|
||||||
|
@ -207,12 +208,9 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAvatarClick = () => {
|
const onAvatarClick = () => {
|
||||||
const avatar_url = account.avatar;
|
const avatar = normalizeAttachment({
|
||||||
const avatar = ImmutableMap({
|
|
||||||
type: 'image',
|
type: 'image',
|
||||||
preview_url: avatar_url,
|
url: account.avatar,
|
||||||
url: avatar_url,
|
|
||||||
description: '',
|
|
||||||
});
|
});
|
||||||
dispatch(openModal('MEDIA', { media: ImmutableList.of(avatar), index: 0 }));
|
dispatch(openModal('MEDIA', { media: ImmutableList.of(avatar), index: 0 }));
|
||||||
};
|
};
|
||||||
|
@ -225,12 +223,9 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const onHeaderClick = () => {
|
const onHeaderClick = () => {
|
||||||
const header_url = account.header;
|
const header = normalizeAttachment({
|
||||||
const header = ImmutableMap({
|
|
||||||
type: 'image',
|
type: 'image',
|
||||||
preview_url: header_url,
|
url: account.header,
|
||||||
url: header_url,
|
|
||||||
description: '',
|
|
||||||
});
|
});
|
||||||
dispatch(openModal('MEDIA', { media: ImmutableList.of(header), index: 0 }));
|
dispatch(openModal('MEDIA', { media: ImmutableList.of(header), index: 0 }));
|
||||||
};
|
};
|
||||||
|
|
|
@ -32,7 +32,7 @@ const ModerationLog = () => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setLastPage(1);
|
setLastPage(1);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => { });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLoadMore = () => {
|
const handleLoadMore = () => {
|
||||||
|
@ -43,7 +43,7 @@ const ModerationLog = () => {
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setLastPage(page);
|
setLastPage(page);
|
||||||
}).catch(() => {});
|
}).catch(() => { });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -62,11 +62,11 @@ const ModerationLog = () => {
|
||||||
<div className='logentry__timestamp'>
|
<div className='logentry__timestamp'>
|
||||||
<FormattedDate
|
<FormattedDate
|
||||||
value={new Date(item.time * 1000)}
|
value={new Date(item.time * 1000)}
|
||||||
hour12={false}
|
hour12
|
||||||
year='numeric'
|
year='numeric'
|
||||||
month='short'
|
month='short'
|
||||||
day='2-digit'
|
day='2-digit'
|
||||||
hour='2-digit'
|
hour='numeric'
|
||||||
minute='2-digit'
|
minute='2-digit'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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 (
|
|
||||||
<div className={classNames('audio-player', { editable })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex='0' onKeyDown={this.handleKeyDown}>
|
|
||||||
<audio
|
|
||||||
src={src}
|
|
||||||
ref={this.setAudioRef}
|
|
||||||
preload='auto'
|
|
||||||
onPlay={this.handlePlay}
|
|
||||||
onPause={this.handlePause}
|
|
||||||
onProgress={this.handleProgress}
|
|
||||||
onLoadedData={this.handleLoadedData}
|
|
||||||
crossOrigin='anonymous'
|
|
||||||
/>
|
|
||||||
|
|
||||||
<canvas
|
|
||||||
role='button'
|
|
||||||
tabIndex='0'
|
|
||||||
className='audio-player__canvas'
|
|
||||||
width={this.state.width}
|
|
||||||
height={this.state.height}
|
|
||||||
style={{ width: '100%', position: 'absolute', top: 0, left: 0 }}
|
|
||||||
ref={this.setCanvasRef}
|
|
||||||
onClick={this.togglePlay}
|
|
||||||
onKeyDown={this.handleAudioKeyDown}
|
|
||||||
title={alt}
|
|
||||||
aria-label={alt}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{this.props.poster && <img
|
|
||||||
src={this.props.poster}
|
|
||||||
alt=''
|
|
||||||
width={(this._getRadius() - TICK_SIZE) * 2 || null}
|
|
||||||
height={(this._getRadius() - TICK_SIZE) * 2 || null}
|
|
||||||
style={{ position: 'absolute', left: this._getCX(), top: this._getCY(), transform: 'translate(-50%, -50%)', borderRadius: '50%', pointerEvents: 'none' }}
|
|
||||||
/>}
|
|
||||||
|
|
||||||
<div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
|
|
||||||
<div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
|
|
||||||
<div className='video-player__seek__progress' style={{ width: `${progress}%`, backgroundColor: this._getAccentColor() }} />
|
|
||||||
|
|
||||||
<span
|
|
||||||
className={classNames('video-player__seek__handle', { active: dragging })}
|
|
||||||
tabIndex='0'
|
|
||||||
style={{ left: `${progress}%`, backgroundColor: this._getAccentColor() }}
|
|
||||||
onKeyDown={this.handleAudioKeyDown}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='video-player__controls active'>
|
|
||||||
<div className='video-player__buttons-bar'>
|
|
||||||
<div className='video-player__buttons left'>
|
|
||||||
<button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} className='player-button' onClick={this.togglePlay}><Icon src={paused ? require('@tabler/icons/player-play.svg') : require('@tabler/icons/player-pause.svg')} /></button>
|
|
||||||
<button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} className='player-button' onClick={this.toggleMute}><Icon src={muted ? require('@tabler/icons/volume-3.svg') : require('@tabler/icons/volume.svg')} /></button>
|
|
||||||
|
|
||||||
<div className={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}>
|
|
||||||
<div className='video-player__volume__current' style={{ width: `${volume * 100}%`, backgroundColor: this._getAccentColor() }} />
|
|
||||||
|
|
||||||
<span
|
|
||||||
className='video-player__volume__handle'
|
|
||||||
tabIndex='0'
|
|
||||||
style={{ left: `${volume * 100}%`, backgroundColor: this._getAccentColor() }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className='video-player__time'>
|
|
||||||
<span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
|
|
||||||
{duration && (<>
|
|
||||||
<span className='video-player__time-sep'>/</span>
|
|
||||||
<span className='video-player__time-total'>{formatTime(Math.floor(duration))}</span>
|
|
||||||
</>)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='video-player__buttons right'>
|
|
||||||
<a
|
|
||||||
title={intl.formatMessage(messages.download)}
|
|
||||||
aria-label={intl.formatMessage(messages.download)}
|
|
||||||
className='video-player__download__icon player-button'
|
|
||||||
href={this.props.src}
|
|
||||||
download
|
|
||||||
target='_blank'
|
|
||||||
>
|
|
||||||
<Icon src={require('@tabler/icons/download.svg')} />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -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<string, any>) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
const Audio: React.FC<IAudio> = (props) => {
|
||||||
|
const {
|
||||||
|
src,
|
||||||
|
alt = '',
|
||||||
|
poster,
|
||||||
|
accentColor,
|
||||||
|
backgroundColor,
|
||||||
|
foregroundColor,
|
||||||
|
cacheWidth,
|
||||||
|
fullscreen,
|
||||||
|
autoPlay,
|
||||||
|
editable,
|
||||||
|
deployPictureInPicture = false,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const [width, setWidth] = useState<number | undefined>(props.width);
|
||||||
|
const [height, setHeight] = useState<number | undefined>(props.height);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [buffer, setBuffer] = useState(0);
|
||||||
|
const [duration, setDuration] = useState<number | undefined>(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<Visualizer>(new Visualizer(TICK_SIZE));
|
||||||
|
const audioContext = useRef<AudioContext | null>(null);
|
||||||
|
|
||||||
|
const player = useRef<HTMLDivElement>(null);
|
||||||
|
const audio = useRef<HTMLAudioElement>(null);
|
||||||
|
const seek = useRef<HTMLDivElement>(null);
|
||||||
|
const slider = useRef<HTMLDivElement>(null);
|
||||||
|
const canvas = useRef<HTMLCanvasElement>(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 (
|
||||||
|
<div
|
||||||
|
className={classNames('audio-player', { editable })}
|
||||||
|
ref={player}
|
||||||
|
style={{
|
||||||
|
backgroundColor: _getBackgroundColor(),
|
||||||
|
color: _getForegroundColor(),
|
||||||
|
width: '100%',
|
||||||
|
height: fullscreen ? '100%' : (height || props.height),
|
||||||
|
}}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<audio
|
||||||
|
src={src}
|
||||||
|
ref={audio}
|
||||||
|
preload='auto'
|
||||||
|
onPlay={handlePlay}
|
||||||
|
onPause={handlePause}
|
||||||
|
onProgress={handleProgress}
|
||||||
|
onLoadedData={handleLoadedData}
|
||||||
|
crossOrigin='anonymous'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<canvas
|
||||||
|
role='button'
|
||||||
|
tabIndex={0}
|
||||||
|
className='audio-player__canvas'
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
style={{ width: '100%', position: 'absolute', top: 0, left: 0 }}
|
||||||
|
ref={canvas}
|
||||||
|
onClick={togglePlay}
|
||||||
|
onKeyDown={handleAudioKeyDown}
|
||||||
|
title={alt}
|
||||||
|
aria-label={alt}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{poster && (
|
||||||
|
<img
|
||||||
|
src={poster}
|
||||||
|
alt=''
|
||||||
|
width={(_getRadius() - TICK_SIZE) * 2}
|
||||||
|
height={(_getRadius() - TICK_SIZE) * 2}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: _getCX(),
|
||||||
|
top: _getCY(),
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='video-player__seek' onMouseDown={handleMouseDown} ref={seek}>
|
||||||
|
|
||||||
|
<div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
className='video-player__seek__progress'
|
||||||
|
style={{ width: `${progress}%`, backgroundColor: _getAccentColor() }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={classNames('video-player__seek__handle', { active: dragging })}
|
||||||
|
tabIndex={0}
|
||||||
|
style={{ left: `${progress}%`, backgroundColor: _getAccentColor() }}
|
||||||
|
onKeyDown={handleAudioKeyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='video-player__controls active'>
|
||||||
|
<div className='video-player__buttons-bar'>
|
||||||
|
<div className='video-player__buttons left'>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
title={intl.formatMessage(paused ? messages.play : messages.pause)}
|
||||||
|
aria-label={intl.formatMessage(paused ? messages.play : messages.pause)}
|
||||||
|
className='player-button'
|
||||||
|
onClick={togglePlay}
|
||||||
|
>
|
||||||
|
<Icon src={paused ? require('@tabler/icons/player-play.svg') : require('@tabler/icons/player-pause.svg')} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
title={intl.formatMessage(muted ? messages.unmute : messages.mute)}
|
||||||
|
aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)}
|
||||||
|
className='player-button'
|
||||||
|
onClick={toggleMute}
|
||||||
|
>
|
||||||
|
<Icon src={muted ? require('@tabler/icons/volume-3.svg') : require('@tabler/icons/volume.svg')} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={classNames('video-player__volume', { active: hovered })}
|
||||||
|
ref={slider}
|
||||||
|
onMouseDown={handleVolumeMouseDown}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='video-player__volume__current'
|
||||||
|
style={{
|
||||||
|
width: `${volume * 100}%`,
|
||||||
|
backgroundColor: _getAccentColor(),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className='video-player__volume__handle'
|
||||||
|
tabIndex={0}
|
||||||
|
style={{ left: `${volume * 100}%`, backgroundColor: _getAccentColor() }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className='video-player__time'>
|
||||||
|
<span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
|
||||||
|
{getDuration() && (<>
|
||||||
|
<span className='video-player__time-sep'>/</span>
|
||||||
|
<span className='video-player__time-total'>{formatTime(Math.floor(getDuration()))}</span>
|
||||||
|
</>)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='video-player__buttons right'>
|
||||||
|
<a
|
||||||
|
title={intl.formatMessage(messages.download)}
|
||||||
|
aria-label={intl.formatMessage(messages.download)}
|
||||||
|
className='video-player__download__icon player-button'
|
||||||
|
href={src}
|
||||||
|
download
|
||||||
|
target='_blank'
|
||||||
|
>
|
||||||
|
<Icon src={require('@tabler/icons/download.svg')} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Audio;
|
|
@ -31,11 +31,11 @@ const AuthToken: React.FC<IAuthToken> = ({ token }) => {
|
||||||
<Text size='sm' theme='muted'>
|
<Text size='sm' theme='muted'>
|
||||||
<FormattedDate
|
<FormattedDate
|
||||||
value={new Date(token.valid_until)}
|
value={new Date(token.valid_until)}
|
||||||
hour12={false}
|
hour12
|
||||||
year='numeric'
|
year='numeric'
|
||||||
month='short'
|
month='short'
|
||||||
day='2-digit'
|
day='2-digit'
|
||||||
hour='2-digit'
|
hour='numeric'
|
||||||
minute='2-digit'
|
minute='2-digit'
|
||||||
/>
|
/>
|
||||||
</Text>
|
</Text>
|
||||||
|
@ -51,7 +51,7 @@ const AuthToken: React.FC<IAuthToken> = ({ token }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AuthTokenList: React.FC = () =>{
|
const AuthTokenList: React.FC = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const tokens = useAppSelector(state => state.security.get('tokens').reverse());
|
const tokens = useAppSelector(state => state.security.get('tokens').reverse());
|
||||||
|
|
|
@ -185,6 +185,7 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat, autosize }) => {
|
||||||
media={ImmutableList([attachment])}
|
media={ImmutableList([attachment])}
|
||||||
height={120}
|
height={120}
|
||||||
onOpenMedia={onOpenMedia}
|
onOpenMedia={onOpenMedia}
|
||||||
|
visible
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Bundle>
|
</Bundle>
|
||||||
|
|
|
@ -0,0 +1,115 @@
|
||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { connectHashtagStream } from 'soapbox/actions/streaming';
|
||||||
|
import { expandHashtagTimeline, clearTimeline } from 'soapbox/actions/timelines';
|
||||||
|
import ColumnHeader from 'soapbox/components/column_header';
|
||||||
|
import { Column } from 'soapbox/components/ui';
|
||||||
|
import Timeline from 'soapbox/features/ui/components/timeline';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import type { Tag as TagEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
type Mode = 'any' | 'all' | 'none';
|
||||||
|
|
||||||
|
type Tag = { value: string };
|
||||||
|
type Tags = { [k in Mode]: Tag[] };
|
||||||
|
|
||||||
|
interface IHashtagTimeline {
|
||||||
|
params?: {
|
||||||
|
id?: string,
|
||||||
|
tags?: Tags,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
|
||||||
|
const id = params?.id || '';
|
||||||
|
const tags = params?.tags || { any: [], all: [], none: [] };
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const hasUnread = useAppSelector<boolean>(state => (state.timelines.getIn([`hashtag:${id}`, 'unread']) as number) > 0);
|
||||||
|
const disconnects = useRef<(() => void)[]>([]);
|
||||||
|
|
||||||
|
// Mastodon supports displaying results from multiple hashtags.
|
||||||
|
// https://github.com/mastodon/mastodon/issues/6359
|
||||||
|
const title = () => {
|
||||||
|
const title: React.ReactNode[] = [`#${id}`];
|
||||||
|
|
||||||
|
if (additionalFor('any')) {
|
||||||
|
title.push(' ', <FormattedMessage key='any' id='hashtag.column_header.tag_mode.any' values={{ additional: additionalFor('any') }} defaultMessage='or {additional}' />);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (additionalFor('all')) {
|
||||||
|
title.push(' ', <FormattedMessage key='all' id='hashtag.column_header.tag_mode.all' values={{ additional: additionalFor('all') }} defaultMessage='and {additional}' />);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (additionalFor('none')) {
|
||||||
|
title.push(' ', <FormattedMessage key='none' id='hashtag.column_header.tag_mode.none' values={{ additional: additionalFor('none') }} defaultMessage='without {additional}' />);
|
||||||
|
}
|
||||||
|
|
||||||
|
return title;
|
||||||
|
};
|
||||||
|
|
||||||
|
const additionalFor = (mode: Mode) => {
|
||||||
|
if (tags && (tags[mode] || []).length > 0) {
|
||||||
|
return tags[mode].map(tag => tag.value).join('/');
|
||||||
|
} else {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscribe = () => {
|
||||||
|
const any = tags.any.map(tag => tag.value);
|
||||||
|
const all = tags.all.map(tag => tag.value);
|
||||||
|
const none = tags.none.map(tag => tag.value);
|
||||||
|
|
||||||
|
[id, ...any].map(tag => {
|
||||||
|
disconnects.current.push(dispatch(connectHashtagStream(id, tag, status => {
|
||||||
|
const tags = status.tags.map((tag: TagEntity) => tag.name);
|
||||||
|
|
||||||
|
return all.filter(tag => tags.includes(tag)).length === all.length &&
|
||||||
|
none.filter(tag => tags.includes(tag)).length === 0;
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsubscribe = () => {
|
||||||
|
disconnects.current.map(disconnect => disconnect());
|
||||||
|
disconnects.current = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadMore = (maxId: string) => {
|
||||||
|
dispatch(expandHashtagTimeline(id, { maxId, tags }));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
subscribe();
|
||||||
|
dispatch(expandHashtagTimeline(id, { tags }));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
unsubscribe();
|
||||||
|
subscribe();
|
||||||
|
dispatch(clearTimeline(`hashtag:${id}`));
|
||||||
|
dispatch(expandHashtagTimeline(id, { tags }));
|
||||||
|
}, [id, tags]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column label={`#${id}`} transparent withHeader={false}>
|
||||||
|
<ColumnHeader active={hasUnread} title={title()} />
|
||||||
|
<Timeline
|
||||||
|
scrollKey='hashtag_timeline'
|
||||||
|
timelineId={`hashtag:${id}`}
|
||||||
|
onLoadMore={handleLoadMore}
|
||||||
|
emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
|
||||||
|
divideType='space'
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HashtagTimeline;
|
|
@ -1,128 +0,0 @@
|
||||||
import isEqual from 'lodash/isEqual';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { connectHashtagStream } from '../../actions/streaming';
|
|
||||||
import { expandHashtagTimeline, clearTimeline } from '../../actions/timelines';
|
|
||||||
import ColumnHeader from '../../components/column_header';
|
|
||||||
import { Column } from '../../components/ui';
|
|
||||||
import Timeline from '../ui/components/timeline';
|
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => ({
|
|
||||||
hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default @connect(mapStateToProps)
|
|
||||||
class HashtagTimeline extends React.PureComponent {
|
|
||||||
|
|
||||||
disconnects = [];
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
params: PropTypes.object.isRequired,
|
|
||||||
dispatch: PropTypes.func.isRequired,
|
|
||||||
hasUnread: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
title = () => {
|
|
||||||
const title = [`#${this.props.params.id}`];
|
|
||||||
|
|
||||||
// TODO: wtf is all this?
|
|
||||||
// It exists in Mastodon's codebase, but undocumented
|
|
||||||
if (this.additionalFor('any')) {
|
|
||||||
title.push(' ', <FormattedMessage key='any' id='hashtag.column_header.tag_mode.any' values={{ additional: this.additionalFor('any') }} defaultMessage='or {additional}' />);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.additionalFor('all')) {
|
|
||||||
title.push(' ', <FormattedMessage key='all' id='hashtag.column_header.tag_mode.all' values={{ additional: this.additionalFor('all') }} defaultMessage='and {additional}' />);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.additionalFor('none')) {
|
|
||||||
title.push(' ', <FormattedMessage key='none' id='hashtag.column_header.tag_mode.none' values={{ additional: this.additionalFor('none') }} defaultMessage='without {additional}' />);
|
|
||||||
}
|
|
||||||
|
|
||||||
return title;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: wtf is this?
|
|
||||||
// It exists in Mastodon's codebase, but undocumented
|
|
||||||
additionalFor = (mode) => {
|
|
||||||
const { tags } = this.props.params;
|
|
||||||
|
|
||||||
if (tags && (tags[mode] || []).length > 0) {
|
|
||||||
return tags[mode].map(tag => tag.value).join('/');
|
|
||||||
} else {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_subscribe(dispatch, id, tags = {}) {
|
|
||||||
const any = (tags.any || []).map(tag => tag.value);
|
|
||||||
const all = (tags.all || []).map(tag => tag.value);
|
|
||||||
const none = (tags.none || []).map(tag => tag.value);
|
|
||||||
|
|
||||||
[id, ...any].map(tag => {
|
|
||||||
this.disconnects.push(dispatch(connectHashtagStream(id, tag, status => {
|
|
||||||
const tags = status.tags.map(tag => tag.name);
|
|
||||||
|
|
||||||
return all.filter(tag => tags.includes(tag)).length === all.length &&
|
|
||||||
none.filter(tag => tags.includes(tag)).length === 0;
|
|
||||||
})));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
_unsubscribe() {
|
|
||||||
this.disconnects.map(disconnect => disconnect());
|
|
||||||
this.disconnects = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
const { dispatch } = this.props;
|
|
||||||
const { id, tags } = this.props.params;
|
|
||||||
|
|
||||||
this._subscribe(dispatch, id, tags);
|
|
||||||
dispatch(expandHashtagTimeline(id, { tags }));
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
const { dispatch } = this.props;
|
|
||||||
const { id, tags } = this.props.params;
|
|
||||||
const { id: prevId, tags: prevTags } = prevProps.params;
|
|
||||||
|
|
||||||
if (id !== prevId || !isEqual(tags, prevTags)) {
|
|
||||||
this._unsubscribe();
|
|
||||||
this._subscribe(dispatch, id, tags);
|
|
||||||
dispatch(clearTimeline(`hashtag:${id}`));
|
|
||||||
dispatch(expandHashtagTimeline(id, { tags }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
this._unsubscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleLoadMore = maxId => {
|
|
||||||
const { id, tags } = this.props.params;
|
|
||||||
this.props.dispatch(expandHashtagTimeline(id, { maxId, tags }));
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { hasUnread } = this.props;
|
|
||||||
const { id } = this.props.params;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Column label={`#${id}`} transparent withHeader={false}>
|
|
||||||
<ColumnHeader active={hasUnread} title={this.title()} />
|
|
||||||
<Timeline
|
|
||||||
scrollKey='hashtag_timeline'
|
|
||||||
timelineId={`hashtag:${id}`}
|
|
||||||
onLoadMore={this.handleLoadMore}
|
|
||||||
emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
|
|
||||||
divideType='space'
|
|
||||||
/>
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,108 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { injectIntl, FormattedMessage } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { fetchMobilePage } from 'soapbox/actions/mobile';
|
|
||||||
import { getSettings } from 'soapbox/actions/settings';
|
|
||||||
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
|
|
||||||
|
|
||||||
import { languages } from '../preferences';
|
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
|
||||||
locale: getSettings(state).get('locale'),
|
|
||||||
mobilePages: getSoapboxConfig(state).get('mobilePages'),
|
|
||||||
});
|
|
||||||
|
|
||||||
@connect(mapStateToProps)
|
|
||||||
@injectIntl
|
|
||||||
class MobilePage extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
state = {
|
|
||||||
pageHtml: '',
|
|
||||||
locale: this.props.locale,
|
|
||||||
}
|
|
||||||
|
|
||||||
loadPageHtml = () => {
|
|
||||||
const { dispatch, match, mobilePages } = this.props;
|
|
||||||
const { locale } = this.state;
|
|
||||||
const { slug } = match.params;
|
|
||||||
const page = mobilePages.get(slug || 'mobile');
|
|
||||||
const fetchLocale = page && locale !== page.get('default') && page.get('locales').includes(locale);
|
|
||||||
dispatch(fetchMobilePage(slug, fetchLocale && locale)).then(html => {
|
|
||||||
this.setState({ pageHtml: html });
|
|
||||||
}).catch(error => {
|
|
||||||
// TODO: Better error handling. 404 page?
|
|
||||||
this.setState({ pageHtml: '<h1>Page not found</h1>' });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setLocale = (locale) => () => {
|
|
||||||
this.setState({ locale });
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.loadPageHtml();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps, prevState) {
|
|
||||||
const { locale, match, mobilePages } = this.props;
|
|
||||||
const { locale: prevLocale, mobilePages: prevMobilePages } = prevProps;
|
|
||||||
const { locale: stateLocale } = this.state;
|
|
||||||
const { locale: prevStateLocale } = prevState;
|
|
||||||
|
|
||||||
const { slug } = match.params;
|
|
||||||
const { slug: prevSlug } = prevProps.match.params;
|
|
||||||
|
|
||||||
if (locale !== prevLocale) this.setState({ locale });
|
|
||||||
|
|
||||||
if (
|
|
||||||
slug !== prevSlug ||
|
|
||||||
stateLocale !== prevStateLocale ||
|
|
||||||
(!prevMobilePages.get(slug || 'mobile') && mobilePages.get(slug || 'mobile'))
|
|
||||||
)
|
|
||||||
this.loadPageHtml();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { match, mobilePages } = this.props;
|
|
||||||
const { slug } = match.params;
|
|
||||||
|
|
||||||
const page = mobilePages.get(slug || 'mobile');
|
|
||||||
const defaultLocale = page && page.get('default');
|
|
||||||
const alsoAvailable = page && (
|
|
||||||
<div className='rich-formatting also-available'>
|
|
||||||
<FormattedMessage id='mobile.also_available' defaultMessage='Available in:' />
|
|
||||||
{' '}
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<a href='#' onClick={this.setLocale(defaultLocale)}>
|
|
||||||
{languages[defaultLocale] || defaultLocale}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{
|
|
||||||
page.get('locales').map(locale => (
|
|
||||||
<li key={locale}>
|
|
||||||
<a href='#' onClick={this.setLocale(locale)}>
|
|
||||||
{languages[locale] || locale}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
dangerouslySetInnerHTML={{ __html: this.state.pageHtml }}
|
|
||||||
/>
|
|
||||||
{alsoAvailable}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default MobilePage;
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Stack } from 'soapbox/components/ui';
|
||||||
|
|
||||||
|
import { randomIntFromInterval, generateText } from '../utils';
|
||||||
|
|
||||||
|
export default ({ limit }: { limit: number }) => {
|
||||||
|
const trend = randomIntFromInterval(6, 3);
|
||||||
|
const stat = randomIntFromInterval(10, 3);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{new Array(limit).fill(undefined).map((_, idx) => (
|
||||||
|
<Stack key={idx} className='animate-pulse text-primary-200 dark:text-primary-700'>
|
||||||
|
<p>{generateText(trend)}</p>
|
||||||
|
<p>{generateText(stat)}</p>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -7,7 +7,6 @@ import { isStandalone } from 'soapbox/utils/state';
|
||||||
|
|
||||||
import AboutPage from '../about';
|
import AboutPage from '../about';
|
||||||
import LandingPage from '../landing_page';
|
import LandingPage from '../landing_page';
|
||||||
import MobilePage from '../mobile';
|
|
||||||
|
|
||||||
import Footer from './components/footer';
|
import Footer from './components/footer';
|
||||||
import Header from './components/header';
|
import Header from './components/header';
|
||||||
|
@ -31,7 +30,6 @@ const PublicLayout = () => {
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route exact path='/' component={LandingPage} />
|
<Route exact path='/' component={LandingPage} />
|
||||||
<Route exact path='/about/:slug?' component={AboutPage} />
|
<Route exact path='/about/:slug?' component={AboutPage} />
|
||||||
<Route exact path='/mobile/:slug?' component={MobilePage} />
|
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -48,6 +48,7 @@ const messages = defineMessages({
|
||||||
promoPanelIconsLink: { id: 'soapbox_config.hints.promo_panel_icons.link', defaultMessage: 'Soapbox Icons List' },
|
promoPanelIconsLink: { id: 'soapbox_config.hints.promo_panel_icons.link', defaultMessage: 'Soapbox Icons List' },
|
||||||
authenticatedProfileLabel: { id: 'soapbox_config.authenticated_profile_label', defaultMessage: 'Profiles require authentication' },
|
authenticatedProfileLabel: { id: 'soapbox_config.authenticated_profile_label', defaultMessage: 'Profiles require authentication' },
|
||||||
authenticatedProfileHint: { id: 'soapbox_config.authenticated_profile_hint', defaultMessage: 'Users must be logged-in to view replies and media on user profiles.' },
|
authenticatedProfileHint: { id: 'soapbox_config.authenticated_profile_hint', defaultMessage: 'Users must be logged-in to view replies and media on user profiles.' },
|
||||||
|
displayCtaLabel: { id: 'soapbox_config.cta_label', defaultMessage: 'Display call to action panels if not authenticated' },
|
||||||
singleUserModeLabel: { id: 'soapbox_config.single_user_mode_label', defaultMessage: 'Single user mode' },
|
singleUserModeLabel: { id: 'soapbox_config.single_user_mode_label', defaultMessage: 'Single user mode' },
|
||||||
singleUserModeHint: { id: 'soapbox_config.single_user_mode_hint', defaultMessage: 'Front page will redirect to a given user profile.' },
|
singleUserModeHint: { id: 'soapbox_config.single_user_mode_hint', defaultMessage: 'Front page will redirect to a given user profile.' },
|
||||||
singleUserModeProfileLabel: { id: 'soapbox_config.single_user_mode_profile_label', defaultMessage: 'Main user handle' },
|
singleUserModeProfileLabel: { id: 'soapbox_config.single_user_mode_profile_label', defaultMessage: 'Main user handle' },
|
||||||
|
@ -261,6 +262,13 @@ const SoapboxConfig: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem label={intl.formatMessage(messages.displayCtaLabel)}>
|
||||||
|
<Toggle
|
||||||
|
checked={soapbox.displayCta === true}
|
||||||
|
onChange={handleChange(['displayCta'], (e) => e.target.checked)}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<ListItem
|
<ListItem
|
||||||
label={intl.formatMessage(messages.authenticatedProfileLabel)}
|
label={intl.formatMessage(messages.authenticatedProfileLabel)}
|
||||||
hint={intl.formatMessage(messages.authenticatedProfileHint)}
|
hint={intl.formatMessage(messages.authenticatedProfileHint)}
|
||||||
|
|
|
@ -108,7 +108,7 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
|
||||||
<span>
|
<span>
|
||||||
<a href={actualStatus.url} target='_blank' rel='noopener' className='hover:underline'>
|
<a href={actualStatus.url} target='_blank' rel='noopener' className='hover:underline'>
|
||||||
<Text tag='span' theme='muted' size='sm'>
|
<Text tag='span' theme='muted' size='sm'>
|
||||||
<FormattedDate value={new Date(actualStatus.created_at)} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
|
<FormattedDate value={new Date(actualStatus.created_at)} hour12 year='numeric' month='short' day='2-digit' hour='numeric' minute='2-digit' />
|
||||||
</Text>
|
</Text>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
@ -122,7 +122,7 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<Text tag='span' theme='muted' size='sm'>
|
<Text tag='span' theme='muted' size='sm'>
|
||||||
<FormattedMessage id='actualStatus.edited' defaultMessage='Edited {date}' values={{ date: intl.formatDate(new Date(actualStatus.edited_at), { hour12: false, month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }} />
|
<FormattedMessage id='actualStatus.edited' defaultMessage='Edited {date}' values={{ date: intl.formatDate(new Date(actualStatus.edited_at), { hour12: true, month: 'short', day: '2-digit', hour: 'numeric', minute: '2-digit' }) }} />
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -2,12 +2,15 @@ import React from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { Card, CardTitle, Text, Stack, Button } from 'soapbox/components/ui';
|
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. */
|
/** Prompts logged-out users to log in when viewing a thread. */
|
||||||
const ThreadLoginCta: React.FC = () => {
|
const ThreadLoginCta: React.FC = () => {
|
||||||
|
const { displayCta } = useSoapboxConfig();
|
||||||
const siteTitle = useAppSelector(state => state.instance.title);
|
const siteTitle = useAppSelector(state => state.instance.title);
|
||||||
|
|
||||||
|
if (!displayCta) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className='px-6 py-12 space-y-6 text-center' variant='rounded'>
|
<Card className='px-6 py-12 space-y-6 text-center' variant='rounded'>
|
||||||
<Stack>
|
<Stack>
|
||||||
|
|
|
@ -79,7 +79,7 @@ const CompareHistoryModal: React.FC<ICompareHistoryModal> = ({ onClose, statusId
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Text align='right' tag='span' theme='muted' size='sm'>
|
<Text align='right' tag='span' theme='muted' size='sm'>
|
||||||
<FormattedDate value={new Date(version.created_at)} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
|
<FormattedDate value={new Date(version.created_at)} hour12 year='numeric' month='short' day='2-digit' hour='numeric' minute='2-digit' />
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -5,11 +5,11 @@ import { Banner, Button, HStack, Stack, Text } from 'soapbox/components/ui';
|
||||||
import { useAppSelector, useSoapboxConfig } from 'soapbox/hooks';
|
import { useAppSelector, useSoapboxConfig } from 'soapbox/hooks';
|
||||||
|
|
||||||
const CtaBanner = () => {
|
const CtaBanner = () => {
|
||||||
const { singleUserMode } = useSoapboxConfig();
|
const { displayCta, singleUserMode } = useSoapboxConfig();
|
||||||
const siteTitle = useAppSelector((state) => state.instance.title);
|
const siteTitle = useAppSelector((state) => state.instance.title);
|
||||||
const me = useAppSelector((state) => state.me);
|
const me = useAppSelector((state) => state.me);
|
||||||
|
|
||||||
if (me || singleUserMode) return null;
|
if (me || !displayCta || singleUserMode) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid='cta-banner' className='hidden lg:block'>
|
<div data-testid='cta-banner' className='hidden lg:block'>
|
||||||
|
|
|
@ -1,125 +0,0 @@
|
||||||
import classNames from 'clsx';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { changeUploadCompose } from '../../../actions/compose';
|
|
||||||
import { getPointerPosition } from '../../video';
|
|
||||||
|
|
||||||
import ImageLoader from './image_loader';
|
|
||||||
|
|
||||||
const mapStateToProps = (state, { id }) => ({
|
|
||||||
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch, { id }) => ({
|
|
||||||
|
|
||||||
onSave: (x, y) => {
|
|
||||||
dispatch(changeUploadCompose(id, { focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
|
|
||||||
},
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
export default @connect(mapStateToProps, mapDispatchToProps)
|
|
||||||
class FocalPointModal extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
media: ImmutablePropTypes.map.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
focusX: 0,
|
|
||||||
focusY: 0,
|
|
||||||
dragging: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.updatePositionFromMedia(this.props.media);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
const { media } = this.props;
|
|
||||||
if (prevProps.media.get('id') !== media.get('id')) {
|
|
||||||
this.updatePositionFromMedia(media);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
document.removeEventListener('mousemove', this.handleMouseMove);
|
|
||||||
document.removeEventListener('mouseup', this.handleMouseUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMouseDown = e => {
|
|
||||||
document.addEventListener('mousemove', this.handleMouseMove);
|
|
||||||
document.addEventListener('mouseup', this.handleMouseUp);
|
|
||||||
|
|
||||||
this.updatePosition(e);
|
|
||||||
this.setState({ dragging: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMouseMove = e => {
|
|
||||||
this.updatePosition(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMouseUp = () => {
|
|
||||||
document.removeEventListener('mousemove', this.handleMouseMove);
|
|
||||||
document.removeEventListener('mouseup', this.handleMouseUp);
|
|
||||||
|
|
||||||
this.setState({ dragging: false });
|
|
||||||
this.props.onSave(this.state.focusX, this.state.focusY);
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePosition = e => {
|
|
||||||
const { x, y } = getPointerPosition(this.node, e);
|
|
||||||
const focusX = (x - .5) * 2;
|
|
||||||
const focusY = (y - .5) * -2;
|
|
||||||
|
|
||||||
this.setState({ x, y, focusX, focusY });
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePositionFromMedia = media => {
|
|
||||||
const focusX = media.getIn(['meta', 'focus', 'x']);
|
|
||||||
const focusY = media.getIn(['meta', 'focus', 'y']);
|
|
||||||
|
|
||||||
if (focusX && focusY) {
|
|
||||||
const x = (focusX / 2) + .5;
|
|
||||||
const y = (focusY / -2) + .5;
|
|
||||||
|
|
||||||
this.setState({ x, y, focusX, focusY });
|
|
||||||
} else {
|
|
||||||
this.setState({ x: 0.5, y: 0.5, focusX: 0, focusY: 0 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setRef = c => {
|
|
||||||
this.node = c;
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { media } = this.props;
|
|
||||||
const { x, y, dragging } = this.state;
|
|
||||||
|
|
||||||
const width = media.getIn(['meta', 'original', 'width']) || null;
|
|
||||||
const height = media.getIn(['meta', 'original', 'height']) || null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='modal-root__modal video-modal focal-point-modal'>
|
|
||||||
<div className={classNames('focal-point', { dragging })} ref={this.setRef}>
|
|
||||||
<ImageLoader
|
|
||||||
previewSrc={media.get('preview_url')}
|
|
||||||
src={media.get('url')}
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} />
|
|
||||||
<div className='focal-point__overlay' onMouseDown={this.handleMouseDown} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,19 +1,20 @@
|
||||||
import classNames from 'clsx';
|
import classNames from 'clsx';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import ZoomableImage from './zoomable_image';
|
import ZoomableImage from './zoomable-image';
|
||||||
|
|
||||||
export default class ImageLoader extends React.PureComponent {
|
type EventRemover = () => void;
|
||||||
|
|
||||||
static propTypes = {
|
interface IImageLoader {
|
||||||
alt: PropTypes.string,
|
alt?: string,
|
||||||
src: PropTypes.string.isRequired,
|
src: string,
|
||||||
previewSrc: PropTypes.string,
|
previewSrc?: string,
|
||||||
width: PropTypes.number,
|
width?: number,
|
||||||
height: PropTypes.number,
|
height?: number,
|
||||||
onClick: PropTypes.func,
|
onClick?: React.MouseEventHandler,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ImageLoader extends React.PureComponent<IImageLoader> {
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
alt: '',
|
alt: '',
|
||||||
|
@ -27,8 +28,9 @@ export default class ImageLoader extends React.PureComponent {
|
||||||
width: null,
|
width: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
removers = [];
|
removers: EventRemover[] = [];
|
||||||
canvas = null;
|
canvas: HTMLCanvasElement | null = null;
|
||||||
|
_canvasContext: CanvasRenderingContext2D | null = null;
|
||||||
|
|
||||||
get canvasContext() {
|
get canvasContext() {
|
||||||
if (!this.canvas) {
|
if (!this.canvas) {
|
||||||
|
@ -42,7 +44,7 @@ export default class ImageLoader extends React.PureComponent {
|
||||||
this.loadImage(this.props);
|
this.loadImage(this.props);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps: IImageLoader) {
|
||||||
if (prevProps.src !== this.props.src) {
|
if (prevProps.src !== this.props.src) {
|
||||||
this.loadImage(this.props);
|
this.loadImage(this.props);
|
||||||
}
|
}
|
||||||
|
@ -52,7 +54,7 @@ export default class ImageLoader extends React.PureComponent {
|
||||||
this.removeEventListeners();
|
this.removeEventListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
loadImage(props) {
|
loadImage(props: IImageLoader) {
|
||||||
this.removeEventListeners();
|
this.removeEventListeners();
|
||||||
this.setState({ loading: true, error: false });
|
this.setState({ loading: true, error: false });
|
||||||
Promise.all([
|
Promise.all([
|
||||||
|
@ -66,7 +68,7 @@ export default class ImageLoader extends React.PureComponent {
|
||||||
.catch(() => this.setState({ loading: false, error: true }));
|
.catch(() => this.setState({ loading: false, error: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
loadPreviewCanvas = ({ previewSrc, width, height }) => new Promise((resolve, reject) => {
|
loadPreviewCanvas = ({ previewSrc, width, height }: IImageLoader) => new Promise<void>((resolve, reject) => {
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
const removeEventListeners = () => {
|
const removeEventListeners = () => {
|
||||||
image.removeEventListener('error', handleError);
|
image.removeEventListener('error', handleError);
|
||||||
|
@ -78,21 +80,23 @@ export default class ImageLoader extends React.PureComponent {
|
||||||
};
|
};
|
||||||
const handleLoad = () => {
|
const handleLoad = () => {
|
||||||
removeEventListeners();
|
removeEventListeners();
|
||||||
this.canvasContext.drawImage(image, 0, 0, width, height);
|
this.canvasContext?.drawImage(image, 0, 0, width || 0, height || 0);
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
image.addEventListener('error', handleError);
|
image.addEventListener('error', handleError);
|
||||||
image.addEventListener('load', handleLoad);
|
image.addEventListener('load', handleLoad);
|
||||||
image.src = previewSrc;
|
image.src = previewSrc || '';
|
||||||
this.removers.push(removeEventListeners);
|
this.removers.push(removeEventListeners);
|
||||||
})
|
})
|
||||||
|
|
||||||
clearPreviewCanvas() {
|
clearPreviewCanvas() {
|
||||||
|
if (this.canvas && this.canvasContext) {
|
||||||
const { width, height } = this.canvas;
|
const { width, height } = this.canvas;
|
||||||
this.canvasContext.clearRect(0, 0, width, height);
|
this.canvasContext.clearRect(0, 0, width, height);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loadOriginalImage = ({ src }) => new Promise((resolve, reject) => {
|
loadOriginalImage = ({ src }: IImageLoader) => new Promise<void>((resolve, reject) => {
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
const removeEventListeners = () => {
|
const removeEventListeners = () => {
|
||||||
image.removeEventListener('error', handleError);
|
image.removeEventListener('error', handleError);
|
||||||
|
@ -122,7 +126,7 @@ export default class ImageLoader extends React.PureComponent {
|
||||||
return typeof width === 'number' && typeof height === 'number';
|
return typeof width === 'number' && typeof height === 'number';
|
||||||
}
|
}
|
||||||
|
|
||||||
setCanvasRef = c => {
|
setCanvasRef = (c: HTMLCanvasElement) => {
|
||||||
this.canvas = c;
|
this.canvas = c;
|
||||||
if (c) this.setState({ width: c.offsetWidth });
|
if (c) this.setState({ width: c.offsetWidth });
|
||||||
}
|
}
|
||||||
|
@ -157,3 +161,5 @@ export default class ImageLoader extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default ImageLoader;
|
|
@ -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<Attachment>,
|
||||||
|
status: Status,
|
||||||
|
account: Account,
|
||||||
|
index: number,
|
||||||
|
time?: number,
|
||||||
|
onClose: () => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MediaModal: React.FC<IMediaModal> = (props) => {
|
||||||
|
const {
|
||||||
|
media,
|
||||||
|
status,
|
||||||
|
account,
|
||||||
|
onClose,
|
||||||
|
time = 0,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const intl = useIntl();
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const [index, setIndex] = useState<number | null>(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<HTMLButtonElement> = (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 && (
|
||||||
|
<button
|
||||||
|
tabIndex={0}
|
||||||
|
className='media-modal__nav media-modal__nav--left'
|
||||||
|
onClick={handlePrevClick}
|
||||||
|
aria-label={intl.formatMessage(messages.previous)}
|
||||||
|
>
|
||||||
|
<Icon src={require('@tabler/icons/arrow-left.svg')} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const rightNav = media.size > 1 && (
|
||||||
|
<button
|
||||||
|
tabIndex={0}
|
||||||
|
className='media-modal__nav media-modal__nav--right'
|
||||||
|
onClick={handleNextClick}
|
||||||
|
aria-label={intl.formatMessage(messages.next)}
|
||||||
|
>
|
||||||
|
<Icon src={require('@tabler/icons/arrow-right.svg')} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<li className='media-modal__page-dot' key={i}>
|
||||||
|
<button
|
||||||
|
tabIndex={0}
|
||||||
|
className={classes.join(' ')}
|
||||||
|
onClick={handleChangeIndex}
|
||||||
|
data-index={i}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 && (
|
||||||
|
<a href={status.url} onClick={handleStatusClick}>
|
||||||
|
<FormattedMessage id='lightbox.view_context' defaultMessage='View context' />
|
||||||
|
</a>
|
||||||
|
));
|
||||||
|
|
||||||
|
if (attachment.type === 'image') {
|
||||||
|
return (
|
||||||
|
<ImageLoader
|
||||||
|
previewSrc={attachment.preview_url}
|
||||||
|
src={attachment.url}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
alt={attachment.description}
|
||||||
|
key={attachment.url}
|
||||||
|
onClick={toggleNavigation}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (attachment.type === 'video') {
|
||||||
|
return (
|
||||||
|
<Video
|
||||||
|
preview={attachment.preview_url}
|
||||||
|
blurhash={attachment.blurhash}
|
||||||
|
src={attachment.url}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
startTime={time}
|
||||||
|
onCloseVideo={onClose}
|
||||||
|
detailed
|
||||||
|
link={link}
|
||||||
|
alt={attachment.description}
|
||||||
|
key={attachment.url}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (attachment.type === 'audio') {
|
||||||
|
return (
|
||||||
|
<Audio
|
||||||
|
src={attachment.url}
|
||||||
|
alt={attachment.description}
|
||||||
|
poster={attachment.preview_url !== attachment.url ? attachment.preview_url : (status.getIn(['account', 'avatar_static'])) as string | undefined}
|
||||||
|
backgroundColor={attachment.meta.getIn(['colors', 'background']) as string | undefined}
|
||||||
|
foregroundColor={attachment.meta.getIn(['colors', 'foreground']) as string | undefined}
|
||||||
|
accentColor={attachment.meta.getIn(['colors', 'accent']) as string | undefined}
|
||||||
|
duration={attachment.meta.getIn(['original', 'duration'], 0) as number | undefined}
|
||||||
|
key={attachment.url}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (attachment.type === 'gifv') {
|
||||||
|
return (
|
||||||
|
<ExtendedVideoPlayer
|
||||||
|
src={attachment.url}
|
||||||
|
muted
|
||||||
|
controls={false}
|
||||||
|
width={width}
|
||||||
|
link={link}
|
||||||
|
height={height}
|
||||||
|
key={attachment.preview_url}
|
||||||
|
alt={attachment.description}
|
||||||
|
onClick={toggleNavigation}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}).toArray();
|
||||||
|
|
||||||
|
// you can't use 100vh, because the viewport height is taller
|
||||||
|
// than the visible part of the document in some mobile
|
||||||
|
// browsers when it's address bar is visible.
|
||||||
|
// https://developers.google.com/web/updates/2016/12/url-bar-resizing
|
||||||
|
const swipeableViewsStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
};
|
||||||
|
|
||||||
|
const containerStyle: React.CSSProperties = {
|
||||||
|
alignItems: 'center', // center vertically
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigationClassName = classNames('media-modal__navigation', {
|
||||||
|
'media-modal__navigation--hidden': navigationHidden,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='modal-root__modal media-modal'>
|
||||||
|
<div
|
||||||
|
className='media-modal__closer'
|
||||||
|
role='presentation'
|
||||||
|
onClick={handleCloserClick}
|
||||||
|
>
|
||||||
|
<ReactSwipeableViews
|
||||||
|
style={swipeableViewsStyle}
|
||||||
|
containerStyle={containerStyle}
|
||||||
|
onChangeIndex={handleSwipe}
|
||||||
|
index={getIndex()}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactSwipeableViews>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={navigationClassName}>
|
||||||
|
<IconButton
|
||||||
|
className='media-modal__close'
|
||||||
|
title={intl.formatMessage(messages.close)}
|
||||||
|
src={require('@tabler/icons/x.svg')}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{leftNav}
|
||||||
|
{rightNav}
|
||||||
|
|
||||||
|
{(status && !isMultiMedia[getIndex()]) && (
|
||||||
|
<div className={classNames('media-modal__meta', { 'media-modal__meta--shifted': media.size > 1 })}>
|
||||||
|
<a href={status.url} onClick={handleStatusClick}>
|
||||||
|
<FormattedMessage id='lightbox.view_context' defaultMessage='View context' />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className='media-modal__pagination'>
|
||||||
|
{pagination}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MediaModal;
|
|
@ -1,274 +0,0 @@
|
||||||
import classNames from 'clsx';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
|
||||||
import { withRouter } from 'react-router-dom';
|
|
||||||
import ReactSwipeableViews from 'react-swipeable-views';
|
|
||||||
|
|
||||||
import ExtendedVideoPlayer from 'soapbox/components/extended_video_player';
|
|
||||||
import Icon from 'soapbox/components/icon';
|
|
||||||
import IconButton from 'soapbox/components/icon_button';
|
|
||||||
import Audio from 'soapbox/features/audio';
|
|
||||||
import Video from 'soapbox/features/video';
|
|
||||||
|
|
||||||
import ImageLoader from './image_loader';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
|
||||||
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
|
||||||
next: { id: 'lightbox.next', defaultMessage: 'Next' },
|
|
||||||
});
|
|
||||||
|
|
||||||
export default @injectIntl @withRouter
|
|
||||||
class MediaModal extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
media: ImmutablePropTypes.list.isRequired,
|
|
||||||
status: ImmutablePropTypes.record,
|
|
||||||
account: ImmutablePropTypes.record,
|
|
||||||
index: PropTypes.number.isRequired,
|
|
||||||
onClose: PropTypes.func.isRequired,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
history: PropTypes.object,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
|
||||||
index: null,
|
|
||||||
navigationHidden: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
handleSwipe = (index) => {
|
|
||||||
this.setState({ index: index % this.props.media.size });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleNextClick = () => {
|
|
||||||
this.setState({ index: (this.getIndex() + 1) % this.props.media.size });
|
|
||||||
}
|
|
||||||
|
|
||||||
handlePrevClick = () => {
|
|
||||||
this.setState({ index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleChangeIndex = (e) => {
|
|
||||||
const index = Number(e.currentTarget.getAttribute('data-index'));
|
|
||||||
this.setState({ index: index % this.props.media.size });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleKeyDown = (e) => {
|
|
||||||
switch (e.key) {
|
|
||||||
case 'ArrowLeft':
|
|
||||||
this.handlePrevClick();
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
break;
|
|
||||||
case 'ArrowRight':
|
|
||||||
this.handleNextClick();
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
window.addEventListener('keydown', this.handleKeyDown, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
window.removeEventListener('keydown', this.handleKeyDown);
|
|
||||||
}
|
|
||||||
|
|
||||||
getIndex() {
|
|
||||||
return this.state.index !== null ? this.state.index : this.props.index;
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleNavigation = () => {
|
|
||||||
this.setState(prevState => ({
|
|
||||||
navigationHidden: !prevState.navigationHidden,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
handleStatusClick = e => {
|
|
||||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
|
||||||
e.preventDefault();
|
|
||||||
const { status, account } = this.props;
|
|
||||||
const acct = account.get('acct');
|
|
||||||
const statusId = status.get('id');
|
|
||||||
this.props.history.push(`/@${acct}/posts/${statusId}`);
|
|
||||||
this.props.onClose(null, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCloserClick = ({ target }) => {
|
|
||||||
const whitelist = ['zoomable-image'];
|
|
||||||
const activeSlide = document.querySelector('.media-modal .react-swipeable-view-container > div[aria-hidden="false"]');
|
|
||||||
|
|
||||||
const isClickOutside = target === activeSlide || !activeSlide.contains(target);
|
|
||||||
const isWhitelisted = whitelist.some(w => target.classList.contains(w));
|
|
||||||
|
|
||||||
if (isClickOutside || isWhitelisted) {
|
|
||||||
this.props.onClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { media, status, account, intl, onClose } = this.props;
|
|
||||||
const { navigationHidden } = this.state;
|
|
||||||
|
|
||||||
const index = this.getIndex();
|
|
||||||
let pagination = [];
|
|
||||||
|
|
||||||
const leftNav = media.size > 1 && (
|
|
||||||
<button tabIndex='0' className='media-modal__nav media-modal__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}>
|
|
||||||
<Icon src={require('@tabler/icons/arrow-left.svg')} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
const rightNav = media.size > 1 && (
|
|
||||||
<button tabIndex='0' className='media-modal__nav media-modal__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}>
|
|
||||||
<Icon src={require('@tabler/icons/arrow-right.svg')} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (media.size > 1) {
|
|
||||||
pagination = media.map((item, i) => {
|
|
||||||
const classes = ['media-modal__button'];
|
|
||||||
if (i === index) {
|
|
||||||
classes.push('media-modal__button--active');
|
|
||||||
}
|
|
||||||
return (<li className='media-modal__page-dot' key={i}><button tabIndex='0' className={classes.join(' ')} onClick={this.handleChangeIndex} data-index={i}>{i + 1}</button></li>);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMultiMedia = media.map((image) => {
|
|
||||||
if (image.get('type') !== 'image') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}).toArray();
|
|
||||||
|
|
||||||
const content = media.map(attachment => {
|
|
||||||
const width = attachment.getIn(['meta', 'original', 'width']) || null;
|
|
||||||
const height = attachment.getIn(['meta', 'original', 'height']) || null;
|
|
||||||
const link = (status && account && <a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>);
|
|
||||||
|
|
||||||
if (attachment.get('type') === 'image') {
|
|
||||||
return (
|
|
||||||
<ImageLoader
|
|
||||||
previewSrc={attachment.get('preview_url')}
|
|
||||||
src={attachment.get('url')}
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
alt={attachment.get('description')}
|
|
||||||
key={attachment.get('url')}
|
|
||||||
onClick={this.toggleNavigation}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (attachment.get('type') === 'video') {
|
|
||||||
const { time } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Video
|
|
||||||
preview={attachment.get('preview_url')}
|
|
||||||
blurhash={attachment.get('blurhash')}
|
|
||||||
src={attachment.get('url')}
|
|
||||||
width={attachment.get('width')}
|
|
||||||
height={attachment.get('height')}
|
|
||||||
startTime={time || 0}
|
|
||||||
onCloseVideo={onClose}
|
|
||||||
detailed
|
|
||||||
link={link}
|
|
||||||
alt={attachment.get('description')}
|
|
||||||
key={attachment.get('url')}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (attachment.get('type') === 'audio') {
|
|
||||||
return (
|
|
||||||
<Audio
|
|
||||||
src={attachment.get('url')}
|
|
||||||
alt={attachment.get('description')}
|
|
||||||
poster={attachment.get('preview_url') !== attachment.get('url') ? attachment.get('preview_url') : (status && status.getIn(['account', 'avatar_static']))}
|
|
||||||
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
|
|
||||||
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
|
|
||||||
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
|
|
||||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
|
||||||
key={attachment.get('url')}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (attachment.get('type') === 'gifv') {
|
|
||||||
return (
|
|
||||||
<ExtendedVideoPlayer
|
|
||||||
src={attachment.get('url')}
|
|
||||||
muted
|
|
||||||
controls={false}
|
|
||||||
width={width}
|
|
||||||
link={link}
|
|
||||||
height={height}
|
|
||||||
key={attachment.get('preview_url')}
|
|
||||||
alt={attachment.get('description')}
|
|
||||||
onClick={this.toggleNavigation}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}).toArray();
|
|
||||||
|
|
||||||
// you can't use 100vh, because the viewport height is taller
|
|
||||||
// than the visible part of the document in some mobile
|
|
||||||
// browsers when it's address bar is visible.
|
|
||||||
// https://developers.google.com/web/updates/2016/12/url-bar-resizing
|
|
||||||
const swipeableViewsStyle = {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
};
|
|
||||||
|
|
||||||
const containerStyle = {
|
|
||||||
alignItems: 'center', // center vertically
|
|
||||||
};
|
|
||||||
|
|
||||||
const navigationClassName = classNames('media-modal__navigation', {
|
|
||||||
'media-modal__navigation--hidden': navigationHidden,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='modal-root__modal media-modal'>
|
|
||||||
<div
|
|
||||||
className='media-modal__closer'
|
|
||||||
role='presentation'
|
|
||||||
onClick={this.handleCloserClick}
|
|
||||||
>
|
|
||||||
<ReactSwipeableViews
|
|
||||||
style={swipeableViewsStyle}
|
|
||||||
containerStyle={containerStyle}
|
|
||||||
onChangeIndex={this.handleSwipe}
|
|
||||||
onSwitching={this.handleSwitching}
|
|
||||||
index={index}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</ReactSwipeableViews>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={navigationClassName}>
|
|
||||||
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} src={require('@tabler/icons/x.svg')} onClick={onClose} />
|
|
||||||
|
|
||||||
{leftNav}
|
|
||||||
{rightNav}
|
|
||||||
|
|
||||||
{(status && !isMultiMedia[index]) && (
|
|
||||||
<div className={classNames('media-modal__meta', { 'media-modal__meta--shifted': media.size > 1 })}>
|
|
||||||
<a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ul className='media-modal__pagination'>
|
|
||||||
{pagination}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -15,7 +15,6 @@ import {
|
||||||
ListAdder,
|
ListAdder,
|
||||||
MissingDescriptionModal,
|
MissingDescriptionModal,
|
||||||
ActionsModal,
|
ActionsModal,
|
||||||
FocalPointModal,
|
|
||||||
HotkeysModal,
|
HotkeysModal,
|
||||||
ComposeModal,
|
ComposeModal,
|
||||||
ReplyMentionsModal,
|
ReplyMentionsModal,
|
||||||
|
@ -51,7 +50,6 @@ const MODAL_COMPONENTS = {
|
||||||
'ACTIONS': ActionsModal,
|
'ACTIONS': ActionsModal,
|
||||||
'EMBED': EmbedModal,
|
'EMBED': EmbedModal,
|
||||||
'LIST_EDITOR': ListEditor,
|
'LIST_EDITOR': ListEditor,
|
||||||
'FOCAL_POINT': FocalPointModal,
|
|
||||||
'LIST_ADDER': ListAdder,
|
'LIST_ADDER': ListAdder,
|
||||||
'HOTKEYS': HotkeysModal,
|
'HOTKEYS': HotkeysModal,
|
||||||
'COMPOSE': ComposeModal,
|
'COMPOSE': ComposeModal,
|
||||||
|
|
|
@ -22,8 +22,8 @@ const dateFormatOptions: FormatDateOptions = {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
hour12: false,
|
hour12: true,
|
||||||
hour: '2-digit',
|
hour: 'numeric',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,26 +1,57 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { setFilter } from 'soapbox/actions/search';
|
||||||
import Hashtag from 'soapbox/components/hashtag';
|
import Hashtag from 'soapbox/components/hashtag';
|
||||||
import { Widget } from 'soapbox/components/ui';
|
import { Text, Widget } from 'soapbox/components/ui';
|
||||||
|
import PlaceholderSidebarTrends from 'soapbox/features/placeholder/components/placeholder-sidebar-trends';
|
||||||
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
import useTrends from 'soapbox/queries/trends';
|
import useTrends from 'soapbox/queries/trends';
|
||||||
|
|
||||||
interface ITrendsPanel {
|
interface ITrendsPanel {
|
||||||
limit: number
|
limit: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
viewAll: {
|
||||||
|
id: 'trendsPanel.viewAll',
|
||||||
|
defaultMessage: 'View all',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const TrendsPanel = ({ limit }: ITrendsPanel) => {
|
const TrendsPanel = ({ limit }: ITrendsPanel) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
const { data: trends, isFetching } = useTrends();
|
const { data: trends, isFetching } = useTrends();
|
||||||
|
|
||||||
if (trends?.length === 0 || isFetching) {
|
const setHashtagsFilter = () => {
|
||||||
|
dispatch(setFilter('hashtags'));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isFetching && !trends?.length) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Widget title={<FormattedMessage id='trends.title' defaultMessage='Trends' />}>
|
<Widget
|
||||||
{trends?.slice(0, limit).map((hashtag) => (
|
title={<FormattedMessage id='trends.title' defaultMessage='Trends' />}
|
||||||
|
action={
|
||||||
|
<Link to='/search' onClick={setHashtagsFilter}>
|
||||||
|
<Text tag='span' theme='primary' size='sm' className='hover:underline'>
|
||||||
|
{intl.formatMessage(messages.viewAll)}
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isFetching ? (
|
||||||
|
<PlaceholderSidebarTrends limit={limit} />
|
||||||
|
) : (
|
||||||
|
trends?.slice(0, limit).map((hashtag) => (
|
||||||
<Hashtag key={hashtag.name} hashtag={hashtag} />
|
<Hashtag key={hashtag.name} hashtag={hashtag} />
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</Widget>
|
</Widget>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,28 +1,27 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
const MIN_SCALE = 1;
|
const MIN_SCALE = 1;
|
||||||
const MAX_SCALE = 4;
|
const MAX_SCALE = 4;
|
||||||
|
|
||||||
const getMidpoint = (p1, p2) => ({
|
type Point = { x: number, y: number };
|
||||||
|
|
||||||
|
const getMidpoint = (p1: React.Touch, p2: React.Touch): Point => ({
|
||||||
x: (p1.clientX + p2.clientX) / 2,
|
x: (p1.clientX + p2.clientX) / 2,
|
||||||
y: (p1.clientY + p2.clientY) / 2,
|
y: (p1.clientY + p2.clientY) / 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
const getDistance = (p1, p2) =>
|
const getDistance = (p1: React.Touch, p2: React.Touch): number =>
|
||||||
Math.sqrt(Math.pow(p1.clientX - p2.clientX, 2) + Math.pow(p1.clientY - p2.clientY, 2));
|
Math.sqrt(Math.pow(p1.clientX - p2.clientX, 2) + Math.pow(p1.clientY - p2.clientY, 2));
|
||||||
|
|
||||||
const clamp = (min, max, value) => Math.min(max, Math.max(min, value));
|
const clamp = (min: number, max: number, value: number): number => Math.min(max, Math.max(min, value));
|
||||||
|
|
||||||
export default class ZoomableImage extends React.PureComponent {
|
interface IZoomableImage {
|
||||||
|
alt?: string,
|
||||||
|
src: string,
|
||||||
|
onClick?: React.MouseEventHandler,
|
||||||
|
}
|
||||||
|
|
||||||
static propTypes = {
|
class ZoomableImage extends React.PureComponent<IZoomableImage> {
|
||||||
alt: PropTypes.string,
|
|
||||||
src: PropTypes.string.isRequired,
|
|
||||||
width: PropTypes.number,
|
|
||||||
height: PropTypes.number,
|
|
||||||
onClick: PropTypes.func,
|
|
||||||
}
|
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
alt: '',
|
alt: '',
|
||||||
|
@ -34,39 +33,32 @@ export default class ZoomableImage extends React.PureComponent {
|
||||||
scale: MIN_SCALE,
|
scale: MIN_SCALE,
|
||||||
}
|
}
|
||||||
|
|
||||||
removers = [];
|
container: HTMLDivElement | null = null;
|
||||||
container = null;
|
image: HTMLImageElement | null = null;
|
||||||
image = null;
|
|
||||||
lastTouchEndTime = 0;
|
|
||||||
lastDistance = 0;
|
lastDistance = 0;
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
let handler = this.handleTouchStart;
|
this.container?.addEventListener('touchstart', this.handleTouchStart);
|
||||||
this.container.addEventListener('touchstart', handler);
|
|
||||||
this.removers.push(() => this.container.removeEventListener('touchstart', handler));
|
|
||||||
handler = this.handleTouchMove;
|
|
||||||
// on Chrome 56+, touch event listeners will default to passive
|
// on Chrome 56+, touch event listeners will default to passive
|
||||||
// https://www.chromestatus.com/features/5093566007214080
|
// https://www.chromestatus.com/features/5093566007214080
|
||||||
this.container.addEventListener('touchmove', handler, { passive: false });
|
this.container?.addEventListener('touchmove', this.handleTouchMove, { passive: false });
|
||||||
this.removers.push(() => this.container.removeEventListener('touchend', handler));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
this.removeEventListeners();
|
this.container?.removeEventListener('touchstart', this.handleTouchStart);
|
||||||
|
this.container?.removeEventListener('touchend', this.handleTouchMove);
|
||||||
}
|
}
|
||||||
|
|
||||||
removeEventListeners() {
|
handleTouchStart = (e: TouchEvent) => {
|
||||||
this.removers.forEach(listeners => listeners());
|
|
||||||
this.removers = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
handleTouchStart = e => {
|
|
||||||
if (e.touches.length !== 2) return;
|
if (e.touches.length !== 2) return;
|
||||||
|
const [p1, p2] = Array.from(e.touches);
|
||||||
|
|
||||||
this.lastDistance = getDistance(...e.touches);
|
this.lastDistance = getDistance(p1, p2);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleTouchMove = e => {
|
handleTouchMove = (e: TouchEvent) => {
|
||||||
|
if (!this.container) return;
|
||||||
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = this.container;
|
const { scrollTop, scrollHeight, clientHeight } = this.container;
|
||||||
if (e.touches.length === 1 && scrollTop !== scrollHeight - clientHeight) {
|
if (e.touches.length === 1 && scrollTop !== scrollHeight - clientHeight) {
|
||||||
// prevent propagating event to MediaModal
|
// prevent propagating event to MediaModal
|
||||||
|
@ -78,17 +70,19 @@ export default class ZoomableImage extends React.PureComponent {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
const distance = getDistance(...e.touches);
|
const [p1, p2] = Array.from(e.touches);
|
||||||
const midpoint = getMidpoint(...e.touches);
|
const distance = getDistance(p1, p2);
|
||||||
|
const midpoint = getMidpoint(p1, p2);
|
||||||
const scale = clamp(MIN_SCALE, MAX_SCALE, this.state.scale * distance / this.lastDistance);
|
const scale = clamp(MIN_SCALE, MAX_SCALE, this.state.scale * distance / this.lastDistance);
|
||||||
|
|
||||||
this.zoom(scale, midpoint);
|
this.zoom(scale, midpoint);
|
||||||
|
|
||||||
this.lastMidpoint = midpoint;
|
|
||||||
this.lastDistance = distance;
|
this.lastDistance = distance;
|
||||||
}
|
}
|
||||||
|
|
||||||
zoom(nextScale, midpoint) {
|
zoom(nextScale: number, midpoint: Point) {
|
||||||
|
if (!this.container) return;
|
||||||
|
|
||||||
const { scale } = this.state;
|
const { scale } = this.state;
|
||||||
const { scrollLeft, scrollTop } = this.container;
|
const { scrollLeft, scrollTop } = this.container;
|
||||||
|
|
||||||
|
@ -102,23 +96,24 @@ export default class ZoomableImage extends React.PureComponent {
|
||||||
const nextScrollTop = (scrollTop + midpoint.y) * nextScale / scale - midpoint.y;
|
const nextScrollTop = (scrollTop + midpoint.y) * nextScale / scale - midpoint.y;
|
||||||
|
|
||||||
this.setState({ scale: nextScale }, () => {
|
this.setState({ scale: nextScale }, () => {
|
||||||
|
if (!this.container) return;
|
||||||
this.container.scrollLeft = nextScrollLeft;
|
this.container.scrollLeft = nextScrollLeft;
|
||||||
this.container.scrollTop = nextScrollTop;
|
this.container.scrollTop = nextScrollTop;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleClick = e => {
|
handleClick: React.MouseEventHandler = e => {
|
||||||
// don't propagate event to MediaModal
|
// don't propagate event to MediaModal
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const handler = this.props.onClick;
|
const handler = this.props.onClick;
|
||||||
if (handler) handler();
|
if (handler) handler(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
setContainerRef = c => {
|
setContainerRef = (c: HTMLDivElement) => {
|
||||||
this.container = c;
|
this.container = c;
|
||||||
}
|
}
|
||||||
|
|
||||||
setImageRef = c => {
|
setImageRef = (c: HTMLImageElement) => {
|
||||||
this.image = c;
|
this.image = c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -150,3 +145,5 @@ export default class ZoomableImage extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default ZoomableImage;
|
|
@ -23,7 +23,7 @@ export function CommunityTimeline() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HashtagTimeline() {
|
export function HashtagTimeline() {
|
||||||
return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline');
|
return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag-timeline');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DirectTimeline() {
|
export function DirectTimeline() {
|
||||||
|
@ -123,7 +123,7 @@ export function Audio() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MediaModal() {
|
export function MediaModal() {
|
||||||
return import(/* webpackChunkName: "features/ui" */'../components/media_modal');
|
return import(/* webpackChunkName: "features/ui" */'../components/media-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VideoModal() {
|
export function VideoModal() {
|
||||||
|
@ -146,10 +146,6 @@ export function ActionsModal() {
|
||||||
return import(/* webpackChunkName: "features/ui" */'../components/actions_modal');
|
return import(/* webpackChunkName: "features/ui" */'../components/actions_modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FocalPointModal() {
|
|
||||||
return import(/* webpackChunkName: "features/ui" */'../components/focal_point_modal');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HotkeysModal() {
|
export function HotkeysModal() {
|
||||||
return import(/* webpackChunkName: "features/ui" */'../components/hotkeys_modal');
|
return import(/* webpackChunkName: "features/ui" */'../components/hotkeys_modal');
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,6 +20,10 @@ const messages = defineMessages({
|
||||||
id: 'registrations.success',
|
id: 'registrations.success',
|
||||||
defaultMessage: 'Welcome to {siteTitle}!',
|
defaultMessage: 'Welcome to {siteTitle}!',
|
||||||
},
|
},
|
||||||
|
usernameHint: {
|
||||||
|
id: 'registrations.username.hint',
|
||||||
|
defaultMessage: 'May only contain A-Z, 0-9, and underscores',
|
||||||
|
},
|
||||||
usernameTaken: {
|
usernameTaken: {
|
||||||
id: 'registrations.unprocessable_entity',
|
id: 'registrations.unprocessable_entity',
|
||||||
defaultMessage: 'This username has already been taken.',
|
defaultMessage: 'This username has already been taken.',
|
||||||
|
@ -104,7 +108,7 @@ const Registration = () => {
|
||||||
|
|
||||||
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto space-y-4'>
|
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto space-y-4'>
|
||||||
<Form onSubmit={handleSubmit}>
|
<Form onSubmit={handleSubmit}>
|
||||||
<FormGroup labelText='Your username'>
|
<FormGroup labelText='Your username' hintText={intl.formatMessage(messages.usernameHint)}>
|
||||||
<Input
|
<Input
|
||||||
name='username'
|
name='username'
|
||||||
type='text'
|
type='text'
|
||||||
|
@ -112,6 +116,7 @@ const Registration = () => {
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
required
|
required
|
||||||
icon={require('@tabler/icons/at.svg')}
|
icon={require('@tabler/icons/at.svg')}
|
||||||
|
placeholder='LibertyForAll'
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
||||||
|
|
|
@ -49,6 +49,7 @@
|
||||||
"account.requested": "Oczekująca prośba, kliknij aby anulować",
|
"account.requested": "Oczekująca prośba, kliknij aby anulować",
|
||||||
"account.requested_small": "Oczekująca prośba",
|
"account.requested_small": "Oczekująca prośba",
|
||||||
"account.search": "Szukaj wpisów @{name}",
|
"account.search": "Szukaj wpisów @{name}",
|
||||||
|
"account.search_self": "Szukaj własnych wpisów",
|
||||||
"account.share": "Udostępnij profil @{name}",
|
"account.share": "Udostępnij profil @{name}",
|
||||||
"account.show_reblogs": "Pokazuj podbicia od @{name}",
|
"account.show_reblogs": "Pokazuj podbicia od @{name}",
|
||||||
"account.subscribe": "Subskrybuj wpisy @{name}",
|
"account.subscribe": "Subskrybuj wpisy @{name}",
|
||||||
|
@ -67,6 +68,18 @@
|
||||||
"account.verified": "Zweryfikowane konto",
|
"account.verified": "Zweryfikowane konto",
|
||||||
"account.welcome": "Welcome",
|
"account.welcome": "Welcome",
|
||||||
"account_gallery.none": "Brak zawartości multimedialnej do wyświetlenia.",
|
"account_gallery.none": "Brak zawartości multimedialnej do wyświetlenia.",
|
||||||
|
"account_moderation_modal.admin_fe": "Otwórz w AdminFE",
|
||||||
|
"account_moderation_modal.fields.account_role": "Poziom uprawnień",
|
||||||
|
"account_moderation_modal.fields.badges": "Niestandaradowe odznaki",
|
||||||
|
"account_moderation_modal.fields.deactivate": "Dezaktywuj konto",
|
||||||
|
"account_moderation_modal.fields.delete": "Usuń konto",
|
||||||
|
"account_moderation_modal.fields.suggested": "Proponuj obserwację tego konta",
|
||||||
|
"account_moderation_modal.fields.verified": "Zweryfikowane konto",
|
||||||
|
"account_moderation_modal.info.id": "ID: {id}",
|
||||||
|
"account_moderation_modal.roles.admin": "Administrator",
|
||||||
|
"account_moderation_modal.roles.moderator": "Moderator",
|
||||||
|
"account_moderation_modal.roles.user": "Użytkownik",
|
||||||
|
"account_moderation_modal.title": "Moderuj @{acct}",
|
||||||
"account_note.hint": "Możesz pozostawić dla siebie notatkę o tym użytkowniku (tylko ty ją zobaczysz):",
|
"account_note.hint": "Możesz pozostawić dla siebie notatkę o tym użytkowniku (tylko ty ją zobaczysz):",
|
||||||
"account_note.placeholder": "Nie wprowadzono opisu",
|
"account_note.placeholder": "Nie wprowadzono opisu",
|
||||||
"account_note.save": "Zapisz",
|
"account_note.save": "Zapisz",
|
||||||
|
@ -125,6 +138,7 @@
|
||||||
"admin.users.actions.unsuggest_user": "Przestań polecać @{name}",
|
"admin.users.actions.unsuggest_user": "Przestań polecać @{name}",
|
||||||
"admin.users.actions.unverify_user": "Cofnij weryfikację @{name}",
|
"admin.users.actions.unverify_user": "Cofnij weryfikację @{name}",
|
||||||
"admin.users.actions.verify_user": "Weryfikuj @{name}",
|
"admin.users.actions.verify_user": "Weryfikuj @{name}",
|
||||||
|
"admin.users.badges_saved_message": "Zaktualizowano niestandardowe odznaki.",
|
||||||
"admin.users.remove_donor_message": "Usunięto @{acct} ze wspierających",
|
"admin.users.remove_donor_message": "Usunięto @{acct} ze wspierających",
|
||||||
"admin.users.set_donor_message": "Ustawiono @{acct} jako wspierającego",
|
"admin.users.set_donor_message": "Ustawiono @{acct} jako wspierającego",
|
||||||
"admin.users.user_deactivated_message": "Zdezaktywowano @{acct}",
|
"admin.users.user_deactivated_message": "Zdezaktywowano @{acct}",
|
||||||
|
@ -173,6 +187,7 @@
|
||||||
"backups.empty_message": "Nie znaleziono kopii zapasowych. {action}",
|
"backups.empty_message": "Nie znaleziono kopii zapasowych. {action}",
|
||||||
"backups.empty_message.action": "Chcesz utworzyć?",
|
"backups.empty_message.action": "Chcesz utworzyć?",
|
||||||
"backups.pending": "Oczekująca",
|
"backups.pending": "Oczekująca",
|
||||||
|
"badge_input.placeholder": "Wprowadź odznakę…",
|
||||||
"beta.also_available": "Dostępne w językach:",
|
"beta.also_available": "Dostępne w językach:",
|
||||||
"birthday_panel.title": "Urodziny",
|
"birthday_panel.title": "Urodziny",
|
||||||
"birthdays_modal.empty": "Żaden z Twoich znajomych nie ma dziś urodzin.",
|
"birthdays_modal.empty": "Żaden z Twoich znajomych nie ma dziś urodzin.",
|
||||||
|
@ -736,8 +751,10 @@
|
||||||
"migration.fields.acct.placeholder": "konto@domena",
|
"migration.fields.acct.placeholder": "konto@domena",
|
||||||
"migration.fields.confirm_password.label": "Obecne hasło",
|
"migration.fields.confirm_password.label": "Obecne hasło",
|
||||||
"migration.hint": "Ta opcja przeniesie Twoich obserwujących na nowe konto. Żadne inne dane nie zostaną przeniesione. Aby dokonać migracji, musisz najpierw {link} na swoim nowym koncie.",
|
"migration.hint": "Ta opcja przeniesie Twoich obserwujących na nowe konto. Żadne inne dane nie zostaną przeniesione. Aby dokonać migracji, musisz najpierw {link} na swoim nowym koncie.",
|
||||||
|
"migration.hint.cooldown_period": "Jeżeli przemigrujesz swoje konto, nie będziesz móc wykonać kolejnej migracji przez {cooldownPeriod, plural, one {jeden dzień} other {kolejne # dni}}.",
|
||||||
"migration.hint.link": "utworzyć alias konta",
|
"migration.hint.link": "utworzyć alias konta",
|
||||||
"migration.move_account.fail": "Przenoszenie konta nie powiodło się.",
|
"migration.move_account.fail": "Przenoszenie konta nie powiodło się.",
|
||||||
|
"migration.move_account.fail.cooldown_period": "Niedawno migrowałeś(-aś) swoje konto. Spróbuj ponownie później.",
|
||||||
"migration.move_account.success": "Pomyślnie przeniesiono konto.",
|
"migration.move_account.success": "Pomyślnie przeniesiono konto.",
|
||||||
"migration.submit": "Przenieś obserwujących",
|
"migration.submit": "Przenieś obserwujących",
|
||||||
"missing_description_modal.cancel": "Anuluj",
|
"missing_description_modal.cancel": "Anuluj",
|
||||||
|
@ -747,6 +764,9 @@
|
||||||
"missing_indicator.label": "Nie znaleziono",
|
"missing_indicator.label": "Nie znaleziono",
|
||||||
"missing_indicator.sublabel": "Nie można odnaleźć tego zasobu",
|
"missing_indicator.sublabel": "Nie można odnaleźć tego zasobu",
|
||||||
"mobile.also_available": "Dostępne w językach:",
|
"mobile.also_available": "Dostępne w językach:",
|
||||||
|
"moderation_overlay.contact": "Kontakt",
|
||||||
|
"moderation_overlay.hide": "Ukryj",
|
||||||
|
"moderation_overlay.show": "Wyświetl",
|
||||||
"morefollows.followers_label": "…i {count} więcej {count, plural, one {obserwujący(-a)} few {obserwujących} many {obserwujących} other {obserwujących}} na zdalnych stronach.",
|
"morefollows.followers_label": "…i {count} więcej {count, plural, one {obserwujący(-a)} few {obserwujących} many {obserwujących} other {obserwujących}} na zdalnych stronach.",
|
||||||
"morefollows.following_label": "…i {count} więcej {count, plural, one {obserwowany(-a)} few {obserwowanych} many {obserwowanych} other {obserwowanych}} na zdalnych stronach.",
|
"morefollows.following_label": "…i {count} więcej {count, plural, one {obserwowany(-a)} few {obserwowanych} many {obserwowanych} other {obserwowanych}} na zdalnych stronach.",
|
||||||
"mute_modal.hide_notifications": "Chcesz ukryć powiadomienia od tego użytkownika?",
|
"mute_modal.hide_notifications": "Chcesz ukryć powiadomienia od tego użytkownika?",
|
||||||
|
@ -843,6 +863,10 @@
|
||||||
"onboarding.display_name.subtitle": "Możesz ją zawsze zmienić później.",
|
"onboarding.display_name.subtitle": "Możesz ją zawsze zmienić później.",
|
||||||
"onboarding.display_name.title": "Wybierz wyświetlaną nazwę",
|
"onboarding.display_name.title": "Wybierz wyświetlaną nazwę",
|
||||||
"onboarding.done": "Gotowe",
|
"onboarding.done": "Gotowe",
|
||||||
|
"onboarding.fediverse.its_you": "Oto Twoje konto! Inni ludzie mogą Cię obserwować z innych serwerów używając pełnej @nazwy.",
|
||||||
|
"onboarding.fediverse.next": "Dalej",
|
||||||
|
"onboarding.fediverse.title": "{siteTitle} to tylko jedna z części Fediwersum",
|
||||||
|
"onboarding.fediverse.other_instances": "Kiedy przeglądasz oś czasum, zwróć uwagę na pełną nazwę użytkownika po znaku @, aby wiedzieć z którego serwera pochodzi wpis.",
|
||||||
"onboarding.finished.message": "Cieszymy się, że możemy powitać Cię w naszej społeczności! Naciśnij poniższy przycisk, aby rozpocząć.",
|
"onboarding.finished.message": "Cieszymy się, że możemy powitać Cię w naszej społeczności! Naciśnij poniższy przycisk, aby rozpocząć.",
|
||||||
"onboarding.finished.title": "Wprowadzenie ukończone",
|
"onboarding.finished.title": "Wprowadzenie ukończone",
|
||||||
"onboarding.header.subtitle": "Będzie widoczny w górnej części Twojego profilu",
|
"onboarding.header.subtitle": "Będzie widoczny w górnej części Twojego profilu",
|
||||||
|
@ -1008,6 +1032,7 @@
|
||||||
"report.target": "Zgłaszanie {target}",
|
"report.target": "Zgłaszanie {target}",
|
||||||
"reset_password.fail": "Token wygasł, spróbuj ponownie.",
|
"reset_password.fail": "Token wygasł, spróbuj ponownie.",
|
||||||
"reset_password.header": "Ustaw nowe hasło",
|
"reset_password.header": "Ustaw nowe hasło",
|
||||||
|
"save": "Zapisz",
|
||||||
"schedule.post_time": "Data/godzina publikacji",
|
"schedule.post_time": "Data/godzina publikacji",
|
||||||
"schedule.remove": "Usuń zaplanowany wpis",
|
"schedule.remove": "Usuń zaplanowany wpis",
|
||||||
"schedule_button.add_schedule": "Zaplanuj wpis na później",
|
"schedule_button.add_schedule": "Zaplanuj wpis na później",
|
||||||
|
@ -1128,7 +1153,7 @@
|
||||||
"sponsored.info.title": "Dlaczego widzę tę reklamę?",
|
"sponsored.info.title": "Dlaczego widzę tę reklamę?",
|
||||||
"sponsored.subtitle": "Wpis sponsorowany",
|
"sponsored.subtitle": "Wpis sponsorowany",
|
||||||
"status.actions.more": "Więcej",
|
"status.actions.more": "Więcej",
|
||||||
"status.admin_account": "Otwórz interfejs moderacyjny dla @{name}",
|
"status.admin_account": "Moderuj @{name}",
|
||||||
"status.admin_status": "Otwórz ten wpis w interfejsie moderacyjnym",
|
"status.admin_status": "Otwórz ten wpis w interfejsie moderacyjnym",
|
||||||
"status.block": "Zablokuj @{name}",
|
"status.block": "Zablokuj @{name}",
|
||||||
"status.bookmark": "Dodaj do zakładek",
|
"status.bookmark": "Dodaj do zakładek",
|
||||||
|
|
|
@ -5,6 +5,8 @@ import {
|
||||||
fromJS,
|
fromJS,
|
||||||
} from 'immutable';
|
} from 'immutable';
|
||||||
|
|
||||||
|
import { normalizeAttachment } from 'soapbox/normalizers/attachment';
|
||||||
|
|
||||||
import type { Attachment, Card, Emoji } from 'soapbox/types/entities';
|
import type { Attachment, Card, Emoji } from 'soapbox/types/entities';
|
||||||
|
|
||||||
export const ChatMessageRecord = ImmutableRecord({
|
export const ChatMessageRecord = ImmutableRecord({
|
||||||
|
@ -22,8 +24,14 @@ export const ChatMessageRecord = ImmutableRecord({
|
||||||
pending: false,
|
pending: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const normalizeMedia = (status: ImmutableMap<string, any>) => {
|
||||||
|
return status.update('attachment', null, normalizeAttachment);
|
||||||
|
};
|
||||||
|
|
||||||
export const normalizeChatMessage = (chatMessage: Record<string, any>) => {
|
export const normalizeChatMessage = (chatMessage: Record<string, any>) => {
|
||||||
return ChatMessageRecord(
|
return ChatMessageRecord(
|
||||||
ImmutableMap(fromJS(chatMessage)),
|
ImmutableMap(fromJS(chatMessage)).withMutations(chatMessage => {
|
||||||
|
normalizeMedia(chatMessage);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -106,12 +106,12 @@ export const SoapboxConfigRecord = ImmutableRecord({
|
||||||
limit: 1,
|
limit: 1,
|
||||||
}),
|
}),
|
||||||
aboutPages: ImmutableMap<string, ImmutableMap<string, unknown>>(),
|
aboutPages: ImmutableMap<string, ImmutableMap<string, unknown>>(),
|
||||||
mobilePages: ImmutableMap<string, ImmutableMap<string, unknown>>(),
|
|
||||||
authenticatedProfile: true,
|
authenticatedProfile: true,
|
||||||
singleUserMode: false,
|
singleUserMode: false,
|
||||||
singleUserModeProfile: '',
|
singleUserModeProfile: '',
|
||||||
linkFooterMessage: '',
|
linkFooterMessage: '',
|
||||||
links: ImmutableMap<string, string>(),
|
links: ImmutableMap<string, string>(),
|
||||||
|
displayCta: true,
|
||||||
}, 'SoapboxConfig');
|
}, 'SoapboxConfig');
|
||||||
|
|
||||||
type SoapboxConfigMap = ImmutableMap<string, any>;
|
type SoapboxConfigMap = ImmutableMap<string, any>;
|
||||||
|
|
|
@ -36,10 +36,10 @@ const DefaultPage: React.FC = ({ children }) => {
|
||||||
)}
|
)}
|
||||||
{features.trends && (
|
{features.trends && (
|
||||||
<BundleContainer fetchComponent={TrendsPanel}>
|
<BundleContainer fetchComponent={TrendsPanel}>
|
||||||
{Component => <Component limit={3} key='trends-panel' />}
|
{Component => <Component limit={5} key='trends-panel' />}
|
||||||
</BundleContainer>
|
</BundleContainer>
|
||||||
)}
|
)}
|
||||||
{features.suggestions && (
|
{me && features.suggestions && (
|
||||||
<BundleContainer fetchComponent={WhoToFollowPanel}>
|
<BundleContainer fetchComponent={WhoToFollowPanel}>
|
||||||
{Component => <Component limit={3} key='wtf-panel' />}
|
{Component => <Component limit={3} key='wtf-panel' />}
|
||||||
</BundleContainer>
|
</BundleContainer>
|
||||||
|
|
|
@ -82,7 +82,7 @@ const HomePage: React.FC = ({ children }) => {
|
||||||
)}
|
)}
|
||||||
{features.trends && (
|
{features.trends && (
|
||||||
<BundleContainer fetchComponent={TrendsPanel}>
|
<BundleContainer fetchComponent={TrendsPanel}>
|
||||||
{Component => <Component limit={3} />}
|
{Component => <Component limit={5} />}
|
||||||
</BundleContainer>
|
</BundleContainer>
|
||||||
)}
|
)}
|
||||||
{hasPatron && (
|
{hasPatron && (
|
||||||
|
@ -103,7 +103,7 @@ const HomePage: React.FC = ({ children }) => {
|
||||||
{Component => <Component limit={10} />}
|
{Component => <Component limit={10} />}
|
||||||
</BundleContainer>
|
</BundleContainer>
|
||||||
)}
|
)}
|
||||||
{features.suggestions && (
|
{me && features.suggestions && (
|
||||||
<BundleContainer fetchComponent={WhoToFollowPanel}>
|
<BundleContainer fetchComponent={WhoToFollowPanel}>
|
||||||
{Component => <Component limit={3} />}
|
{Component => <Component limit={3} />}
|
||||||
</BundleContainer>
|
</BundleContainer>
|
||||||
|
|
|
@ -137,7 +137,7 @@ const ProfilePage: React.FC<IProfilePage> = ({ params, children }) => {
|
||||||
<BundleContainer fetchComponent={PinnedAccountsPanel}>
|
<BundleContainer fetchComponent={PinnedAccountsPanel}>
|
||||||
{Component => <Component account={account} limit={5} key='pinned-accounts-panel' />}
|
{Component => <Component account={account} limit={5} key='pinned-accounts-panel' />}
|
||||||
</BundleContainer>
|
</BundleContainer>
|
||||||
) : features.suggestions && (
|
) : me && features.suggestions && (
|
||||||
<BundleContainer fetchComponent={WhoToFollowPanel}>
|
<BundleContainer fetchComponent={WhoToFollowPanel}>
|
||||||
{Component => <Component limit={3} key='wtf-panel' />}
|
{Component => <Component limit={3} key='wtf-panel' />}
|
||||||
</BundleContainer>
|
</BundleContainer>
|
||||||
|
|
|
@ -40,10 +40,10 @@ const StatusPage: React.FC<IStatusPage> = ({ children }) => {
|
||||||
)}
|
)}
|
||||||
{features.trends && (
|
{features.trends && (
|
||||||
<BundleContainer fetchComponent={TrendsPanel}>
|
<BundleContainer fetchComponent={TrendsPanel}>
|
||||||
{Component => <Component limit={3} key='trends-panel' />}
|
{Component => <Component limit={5} key='trends-panel' />}
|
||||||
</BundleContainer>
|
</BundleContainer>
|
||||||
)}
|
)}
|
||||||
{features.suggestions && (
|
{me && features.suggestions && (
|
||||||
<BundleContainer fetchComponent={WhoToFollowPanel}>
|
<BundleContainer fetchComponent={WhoToFollowPanel}>
|
||||||
{Component => <Component limit={3} key='wtf-panel' />}
|
{Component => <Component limit={3} key='wtf-panel' />}
|
||||||
</BundleContainer>
|
</BundleContainer>
|
||||||
|
|
|
@ -242,39 +242,3 @@
|
||||||
@apply block shadow-md;
|
@apply block shadow-md;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.focal-point {
|
|
||||||
position: relative;
|
|
||||||
cursor: pointer;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&.dragging {
|
|
||||||
cursor: move;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
max-width: 80vw;
|
|
||||||
max-height: 80vh;
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__reticle {
|
|
||||||
position: absolute;
|
|
||||||
width: 100px;
|
|
||||||
height: 100px;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
background: url('../images/reticle.png') no-repeat 0 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__overlay {
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -387,12 +387,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.focal-point-modal {
|
|
||||||
max-width: 80vw;
|
|
||||||
max-height: 80vh;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.column-inline-form {
|
.column-inline-form {
|
||||||
padding: 7px 15px;
|
padding: 7px 15px;
|
||||||
padding-right: 5px;
|
padding-right: 5px;
|
||||||
|
|
Loading…
Reference in New Issue