Merge remote-tracking branch 'origin/develop' into chats

revert-fa4bd20d
Chewbacca 2022-10-14 14:27:53 -04:00
commit 65a8bf9aa1
55 zmienionych plików z 1310 dodań i 1447 usunięć

Wyświetl plik

@ -148,6 +148,8 @@ docker:
image: docker:20.10.17
services:
- docker:20.10.17-dind
tags:
- dind
# https://medium.com/devops-with-valentine/how-to-build-a-docker-image-and-push-it-to-the-gitlab-container-registry-from-a-gitlab-ci-pipeline-acac0d1f26df
script:
- echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER $CI_REGISTRY --password-stdin

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 1.4 KiB

Wyświetl plik

@ -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,
};

Wyświetl plik

@ -34,22 +34,23 @@ const Announcement: React.FC<IAnnouncement> = ({ announcement, addReaction, remo
<Text theme='muted'>
<FormattedDate
value={startsAt}
hour12={false}
hour12
year={(skipYear || startsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'}
month='short'
day='2-digit'
hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'}
hour={skipTime ? undefined : 'numeric'}
minute={skipTime ? undefined : '2-digit'}
/>
{' '}
-
{' '}
<FormattedDate
value={endsAt}
hour12={false}
hour12
year={(skipYear || endsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'}
month={skipEndDate ? undefined : 'short'}
day={skipEndDate ? undefined : '2-digit'}
hour={skipTime ? undefined : '2-digit'}
hour={skipTime ? undefined : 'numeric'}
minute={skipTime ? undefined : '2-digit'}
/>
</Text>

Wyświetl plik

@ -6,9 +6,10 @@ import Bundle from 'soapbox/features/ui/components/bundle';
import { MediaGallery } from 'soapbox/features/ui/util/async-components';
import type { List as ImmutableList } from 'immutable';
import type { Attachment } from 'soapbox/types/entities';
interface IAttachmentThumbs {
media: ImmutableList<Immutable.Record<any>>
media: ImmutableList<Attachment>
onClick?(): void
sensitive?: boolean
}
@ -18,7 +19,7 @@ const AttachmentThumbs = (props: IAttachmentThumbs) => {
const dispatch = useDispatch();
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 (
<div className='attachment-thumbs'>
@ -30,6 +31,7 @@ const AttachmentThumbs = (props: IAttachmentThumbs) => {
height={50}
compact
sensitive={sensitive}
visible
/>
)}
</Bundle>

Wyświetl plik

@ -263,14 +263,13 @@ const Item: React.FC<IItem> = ({
interface IMediaGallery {
sensitive?: boolean,
media: ImmutableList<Attachment>,
size: number,
height: number,
onOpenMedia: (media: ImmutableList<Attachment>, index: number) => void,
defaultWidth: number,
cacheWidth: (width: number) => void,
defaultWidth?: number,
cacheWidth?: (width: number) => void,
visible?: boolean,
onToggleVisibility?: () => void,
displayMedia: string,
displayMedia?: string,
compact: boolean,
}
@ -278,7 +277,7 @@ const MediaGallery: React.FC<IMediaGallery> = (props) => {
const {
media,
sensitive = false,
defaultWidth,
defaultWidth = 0,
onToggleVisibility,
onOpenMedia,
cacheWidth,

Wyświetl plik

@ -1,6 +1,6 @@
import classNames from 'clsx';
import React, { useEffect, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { useIntl, FormattedMessage } from 'react-intl';
import { usePopper } from 'react-popper';
import { useHistory } from 'react-router-dom';
@ -15,9 +15,10 @@ import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
import { UserPanel } from 'soapbox/features/ui/util/async-components';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors';
import { isLocal } from 'soapbox/utils/accounts';
import { showProfileHoverCard } from './hover_ref_wrapper';
import { Card, CardBody, Stack, Text } from './ui';
import { Card, CardBody, HStack, Icon, Stack, Text } from './ui';
import type { AppDispatch } from 'soapbox/store';
import type { Account } from 'soapbox/types/entities';
@ -60,6 +61,7 @@ interface IProfileHoverCard {
export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }) => {
const dispatch = useAppDispatch();
const history = useHistory();
const intl = useIntl();
const [popperElement, setPopperElement] = useState<HTMLElement | null>(null);
@ -88,6 +90,7 @@ export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }
if (!account) return null;
const accountBio = { __html: account.note_emojified };
const memberSinceDate = intl.formatDate(account.created_at, { month: 'long', year: 'numeric' });
const followedBy = me !== account.id && account.relationship?.followed_by === true;
return (
@ -116,6 +119,23 @@ export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }
)}
</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 && (
<Text size='sm' dangerouslySetInnerHTML={accountBio} />
)}

Wyświetl plik

@ -17,11 +17,11 @@ const messages = defineMessages({
});
const dateFormatOptions: FormatDateOptions = {
hour12: false,
hour12: true,
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
hour: 'numeric',
minute: '2-digit',
};
@ -32,8 +32,8 @@ const shortDateFormatOptions: FormatDateOptions = {
const SECOND = 1000;
const MINUTE = 1000 * 60;
const HOUR = 1000 * 60 * 60;
const DAY = 1000 * 60 * 60 * 24;
const HOUR = 1000 * 60 * 60;
const DAY = 1000 * 60 * 60 * 24;
const MAX_DELAY = 2147483647;
@ -170,12 +170,12 @@ class RelativeTimestamp extends React.Component<RelativeTimestampProps, Relative
clearTimeout(this._timer);
}
const { timestamp } = this.props;
const delta = (new Date(timestamp)).getTime() - this.state.now;
const unitDelay = getUnitDelay(selectUnits(delta));
const unitRemainder = Math.abs(delta % unitDelay);
const { timestamp } = this.props;
const delta = (new Date(timestamp)).getTime() - this.state.now;
const unitDelay = getUnitDelay(selectUnits(delta));
const unitRemainder = Math.abs(delta % unitDelay);
const updateInterval = 1000 * 10;
const delay = delta < 0 ? Math.max(updateInterval, unitDelay - unitRemainder) : Math.max(updateInterval, unitRemainder);
const delay = delta < 0 ? Math.max(updateInterval, unitDelay - unitRemainder) : Math.max(updateInterval, unitRemainder);
this._timer = setTimeout(() => {
this.setState({ now: Date.now() });

Wyświetl plik

@ -374,7 +374,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
menu.push({
text: intl.formatMessage(status.pinned ? messages.unpin : messages.pin),
action: handlePinClick,
icon: mutingConversation ? require('@tabler/icons/pinned-off.svg') : require('@tabler/icons/pin.svg'),
icon: status.pinned ? require('@tabler/icons/pinned-off.svg') : require('@tabler/icons/pin.svg'),
});
} else {
if (status.visibility === 'private') {

Wyświetl plik

@ -1,12 +1,32 @@
import classNames from 'clsx';
type ButtonThemes = 'primary' | 'secondary' | 'tertiary' | 'accent' | 'danger' | 'transparent' | 'outline'
type ButtonSizes = 'sm' | 'md' | 'lg'
const themes = {
primary:
'bg-primary-500 hover:bg-primary-400 dark:hover:bg-primary-600 border-transparent focus:bg-primary-500 text-gray-100 focus:ring-primary-300',
secondary:
'border-transparent bg-primary-100 dark:bg-primary-800 hover:bg-primary-50 dark:hover:bg-primary-700 focus:bg-primary-100 dark:focus:bg-primary-800 text-primary-500 dark:text-primary-200',
tertiary:
'bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500',
accent: 'border-transparent bg-secondary-500 hover:bg-secondary-400 focus:bg-secondary-500 text-gray-100 focus:ring-secondary-300',
danger: 'border-transparent bg-danger-100 dark:bg-danger-900 text-danger-600 dark:text-danger-200 hover:bg-danger-600 hover:text-gray-100 dark:hover:text-gray-100 dark:hover:bg-danger-500 focus:bg-danger-800 dark:focus:bg-danger-600',
transparent: 'border-transparent text-gray-800 backdrop-blur-sm bg-white/75 hover:bg-white/80',
outline: 'border-gray-100 border-2 bg-transparent text-gray-100 hover:bg-white/10',
};
const sizes = {
xs: 'px-3 py-1 text-xs',
sm: 'px-3 py-1.5 text-xs leading-4',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
};
type ButtonSizes = keyof typeof sizes
type ButtonThemes = keyof typeof themes
type IButtonStyles = {
theme: ButtonThemes,
block: boolean,
disabled: boolean,
theme: ButtonThemes
block: boolean
disabled: boolean
size: ButtonSizes
}
@ -17,26 +37,6 @@ const useButtonStyles = ({
disabled,
size,
}: IButtonStyles) => {
const themes = {
primary:
'bg-primary-500 hover:bg-primary-400 dark:hover:bg-primary-600 border-transparent focus:bg-primary-500 text-gray-100 focus:ring-primary-300',
secondary:
'border-transparent bg-primary-100 dark:bg-primary-800 hover:bg-primary-50 dark:hover:bg-primary-700 focus:bg-primary-100 dark:focus:bg-primary-800 text-primary-500 dark:text-primary-200',
tertiary:
'bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500',
accent: 'border-transparent bg-secondary-500 hover:bg-secondary-400 focus:bg-secondary-500 text-gray-100 focus:ring-secondary-300',
danger: 'border-transparent bg-danger-100 dark:bg-danger-900 text-danger-600 dark:text-danger-200 hover:bg-danger-600 hover:text-gray-100 dark:hover:text-gray-100 dark:hover:bg-danger-500 focus:bg-danger-800 dark:focus:bg-danger-600',
transparent: 'border-transparent text-gray-800 backdrop-blur-sm bg-white/75 hover:bg-white/80',
outline: 'border-gray-100 border-2 bg-transparent text-gray-100 hover:bg-white/10',
};
const sizes = {
xs: 'px-3 py-1 text-xs',
sm: 'px-3 py-1.5 text-xs leading-4',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
};
const buttonStyle = classNames({
'inline-flex items-center border font-medium rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 appearance-none transition-all': true,
'select-none disabled:opacity-75 disabled:cursor-default': disabled,

Wyświetl plik

@ -18,13 +18,13 @@ const messages = defineMessages({
interface ICard {
/** The type of card. */
variant?: 'default' | 'rounded',
variant?: 'default' | 'rounded'
/** Card size preset. */
size?: 'md' | 'lg' | 'xl',
size?: keyof typeof sizes
/** Extra classnames for the <div> element. */
className?: string,
className?: string
/** Elements inside the card. */
children: React.ReactNode,
children: React.ReactNode
}
/** An opaque backdrop to hold a collection of related elements. */

Wyświetl plik

@ -17,7 +17,7 @@ const alignItemsOptions = {
};
const spaces = {
'0.5': 'space-x-0.5',
[0.5]: 'space-x-0.5',
1: 'space-x-1',
1.5: 'space-x-1.5',
2: 'space-x-2',
@ -29,21 +29,21 @@ const spaces = {
interface IHStack {
/** Vertical alignment of children. */
alignItems?: 'top' | 'bottom' | 'center' | 'start',
alignItems?: keyof typeof alignItemsOptions
/** Extra class names on the <div> element. */
className?: string,
className?: string
/** Children */
children?: React.ReactNode,
children?: React.ReactNode
/** Horizontal alignment of children. */
justifyContent?: 'between' | 'center' | 'start' | 'end' | 'around',
justifyContent?: keyof typeof justifyContentOptions
/** Size of the gap between elements. */
space?: 0.5 | 1 | 1.5 | 2 | 3 | 4 | 6 | 8,
space?: keyof typeof spaces
/** Whether to let the flexbox grow. */
grow?: boolean,
grow?: boolean
/** Extra CSS styles for the <div> */
style?: React.CSSProperties
/** Whether to let the flexbox wrap onto multiple lines. */
wrap?: boolean,
wrap?: boolean
}
/** Horizontal row of child elements. */

Wyświetl plik

@ -11,8 +11,6 @@ const messages = defineMessages({
confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
});
type Widths = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl'
const widths = {
xs: 'max-w-xs',
sm: 'max-w-sm',
@ -52,7 +50,7 @@ interface IModal {
skipFocus?: boolean,
/** Title text for the modal. */
title?: React.ReactNode,
width?: Widths,
width?: keyof typeof widths,
}
/** Displays a modal dialog box. */

Wyświetl plik

@ -1,13 +1,11 @@
import classNames from 'clsx';
import React from 'react';
type SIZES = 0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 10
const spaces = {
0: 'space-y-0',
'0.5': 'space-y-0.5',
[0.5]: 'space-y-0.5',
1: 'space-y-1',
'1.5': 'space-y-1.5',
[1.5]: 'space-y-1.5',
2: 'space-y-2',
3: 'space-y-3',
4: 'space-y-4',
@ -27,15 +25,15 @@ const alignItemsOptions = {
interface IStack extends React.HTMLAttributes<HTMLDivElement> {
/** Size of the gap between elements. */
space?: SIZES,
space?: keyof typeof spaces
/** Horizontal alignment of children. */
alignItems?: 'center' | 'start',
/** Vertical alignment of children. */
justifyContent?: 'center',
justifyContent?: 'center'
/** Extra class names on the <div> element. */
className?: string,
className?: string
/** Whether to let the flexbox grow. */
grow?: boolean,
grow?: boolean
}
/** Vertical stack of child elements. */

Wyświetl plik

@ -13,8 +13,7 @@
[data-reach-tab] {
@apply flex-1 flex justify-center items-center
py-4 px-1 text-center font-medium text-sm text-gray-700
dark:text-gray-600 hover:text-gray-800 dark:hover:text-gray-500
focus:ring-primary-300 focus:ring-2;
dark:text-gray-600 hover:text-gray-800 dark:hover:text-gray-500;
}
[data-reach-tab][data-selected] {

Wyświetl plik

@ -1,16 +1,6 @@
import classNames from 'clsx';
import React from 'react';
type Themes = 'default' | 'danger' | 'primary' | 'muted' | 'subtle' | 'success' | 'inherit' | 'white'
type Weights = 'normal' | 'medium' | 'semibold' | 'bold'
export type Sizes = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'
type Alignments = 'left' | 'center' | 'right'
type TrackingSizes = 'normal' | 'wide'
type TransformProperties = 'uppercase' | 'normal'
type Families = 'sans' | 'mono'
type Tags = 'abbr' | 'p' | 'span' | 'pre' | 'time' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'label'
type Directions = 'ltr' | 'rtl'
const themes = {
default: 'text-gray-900 dark:text-gray-100',
danger: 'text-danger-600',
@ -60,15 +50,19 @@ const families = {
mono: 'font-mono',
};
export type Sizes = keyof typeof sizes
type Tags = 'abbr' | 'p' | 'span' | 'pre' | 'time' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'label'
type Directions = 'ltr' | 'rtl'
interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'dangerouslySetInnerHTML'> {
/** How to align the text. */
align?: Alignments,
align?: keyof typeof alignments,
/** Extra class names for the outer element. */
className?: string,
/** Text direction. */
direction?: Directions,
/** Typeface of the text. */
family?: Families,
family?: keyof typeof families,
/** The "for" attribute specifies which form element a label is bound to. */
htmlFor?: string,
/** Font size of the text. */
@ -76,15 +70,15 @@ interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'danger
/** HTML element name of the outer element. */
tag?: Tags,
/** Theme for the text. */
theme?: Themes,
theme?: keyof typeof themes,
/** Letter-spacing of the text. */
tracking?: TrackingSizes,
tracking?: keyof typeof trackingSizes,
/** Transform (eg uppercase) for the text. */
transform?: TransformProperties,
transform?: keyof typeof transformProperties,
/** Whether to truncate the text if its container is too small. */
truncate?: boolean,
/** Font weight of the text. */
weight?: Weights,
weight?: keyof typeof weights,
/** Tooltip title. */
title?: string,
}

Wyświetl plik

@ -139,7 +139,6 @@ const SoapboxMount = () => {
)}
<Route exact path='/about/:slug?' component={PublicLayout} />
<Route exact path='/mobile/:slug?' component={PublicLayout} />
<Route path='/login' component={AuthLayout} />
{(features.accountCreation && instance.registrations) && (

Wyświetl plik

@ -1,6 +1,6 @@
'use strict';
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import { List as ImmutableList } from 'immutable';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Link, useHistory } from 'react-router-dom';
@ -23,6 +23,7 @@ import MovedNote from 'soapbox/features/account_timeline/components/moved_note';
import ActionButton from 'soapbox/features/ui/components/action-button';
import SubscriptionButton from 'soapbox/features/ui/components/subscription-button';
import { useAppDispatch, useFeatures, useOwnAccount } from 'soapbox/hooks';
import { normalizeAttachment } from 'soapbox/normalizers';
import { Account } from 'soapbox/types/entities';
import { isRemote } from 'soapbox/utils/accounts';
@ -207,12 +208,9 @@ const Header: React.FC<IHeader> = ({ account }) => {
};
const onAvatarClick = () => {
const avatar_url = account.avatar;
const avatar = ImmutableMap({
const avatar = normalizeAttachment({
type: 'image',
preview_url: avatar_url,
url: avatar_url,
description: '',
url: account.avatar,
});
dispatch(openModal('MEDIA', { media: ImmutableList.of(avatar), index: 0 }));
};
@ -225,12 +223,9 @@ const Header: React.FC<IHeader> = ({ account }) => {
};
const onHeaderClick = () => {
const header_url = account.header;
const header = ImmutableMap({
const header = normalizeAttachment({
type: 'image',
preview_url: header_url,
url: header_url,
description: '',
url: account.header,
});
dispatch(openModal('MEDIA', { media: ImmutableList.of(header), index: 0 }));
};

Wyświetl plik

@ -32,7 +32,7 @@ const ModerationLog = () => {
setIsLoading(false);
setLastPage(1);
})
.catch(() => {});
.catch(() => { });
}, []);
const handleLoadMore = () => {
@ -43,7 +43,7 @@ const ModerationLog = () => {
.then(() => {
setIsLoading(false);
setLastPage(page);
}).catch(() => {});
}).catch(() => { });
};
return (
@ -62,11 +62,11 @@ const ModerationLog = () => {
<div className='logentry__timestamp'>
<FormattedDate
value={new Date(item.time * 1000)}
hour12={false}
hour12
year='numeric'
month='short'
day='2-digit'
hour='2-digit'
hour='numeric'
minute='2-digit'
/>
</div>

Wyświetl plik

@ -23,7 +23,7 @@ interface IAccount {
const Account: React.FC<IAccount> = ({ accountId, aliases }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const getAccount = useCallback(makeGetAccount(), []);
const account = useAppSelector((state) => getAccount(state, accountId));

Wyświetl plik

@ -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>
);
}
}

Wyświetl plik

@ -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;

Wyświetl plik

@ -31,11 +31,11 @@ const AuthToken: React.FC<IAuthToken> = ({ token }) => {
<Text size='sm' theme='muted'>
<FormattedDate
value={new Date(token.valid_until)}
hour12={false}
hour12
year='numeric'
month='short'
day='2-digit'
hour='2-digit'
hour='numeric'
minute='2-digit'
/>
</Text>
@ -51,7 +51,7 @@ const AuthToken: React.FC<IAuthToken> = ({ token }) => {
);
};
const AuthTokenList: React.FC = () =>{
const AuthTokenList: React.FC = () => {
const dispatch = useAppDispatch();
const intl = useIntl();
const tokens = useAppSelector(state => state.security.get('tokens').reverse());

Wyświetl plik

@ -185,6 +185,7 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat, autosize }) => {
media={ImmutableList([attachment])}
height={120}
onOpenMedia={onOpenMedia}
visible
/>
)}
</Bundle>

Wyświetl plik

@ -13,7 +13,7 @@ interface IQuotedStatusContainer {
const QuotedStatusContainer: React.FC<IQuotedStatusContainer> = ({ composeId }) => {
const dispatch = useAppDispatch();
const getStatus = useCallback(makeGetStatus(), []);
const status = useAppSelector(state => getStatus(state, { id: state.compose.get(composeId)?.quote! }));
const onCancel = () => {

Wyświetl plik

@ -23,7 +23,7 @@ interface IAccountAuthorize {
const AccountAuthorize: React.FC<IAccountAuthorize> = ({ id }) => {
const intl = useIntl();
const dispatch = useDispatch();
const getAccount = useCallback(makeGetAccount(), []);
const account = useAppSelector((state) => getAccount(state, id));

Wyświetl plik

@ -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;

Wyświetl plik

@ -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>
);
}
}

Wyświetl plik

@ -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;

Wyświetl plik

@ -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>
))}
</>
);
};

Wyświetl plik

@ -7,7 +7,6 @@ import { isStandalone } from 'soapbox/utils/state';
import AboutPage from '../about';
import LandingPage from '../landing_page';
import MobilePage from '../mobile';
import Footer from './components/footer';
import Header from './components/header';
@ -31,7 +30,6 @@ const PublicLayout = () => {
<Switch>
<Route exact path='/' component={LandingPage} />
<Route exact path='/about/:slug?' component={AboutPage} />
<Route exact path='/mobile/:slug?' component={MobilePage} />
</Switch>
</div>
</div>

Wyświetl plik

@ -48,6 +48,7 @@ const messages = defineMessages({
promoPanelIconsLink: { id: 'soapbox_config.hints.promo_panel_icons.link', defaultMessage: 'Soapbox Icons List' },
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.' },
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' },
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' },
@ -261,6 +262,13 @@ const SoapboxConfig: React.FC = () => {
/>
</ListItem>
<ListItem label={intl.formatMessage(messages.displayCtaLabel)}>
<Toggle
checked={soapbox.displayCta === true}
onChange={handleChange(['displayCta'], (e) => e.target.checked)}
/>
</ListItem>
<ListItem
label={intl.formatMessage(messages.authenticatedProfileLabel)}
hint={intl.formatMessage(messages.authenticatedProfileHint)}

Wyświetl plik

@ -108,7 +108,7 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
<span>
<a href={actualStatus.url} target='_blank' rel='noopener' className='hover:underline'>
<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>
</a>
@ -122,7 +122,7 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
tabIndex={0}
>
<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>
</div>
</>

Wyświetl plik

@ -2,12 +2,15 @@ import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Card, CardTitle, Text, Stack, Button } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
import { useAppSelector, useSoapboxConfig } from 'soapbox/hooks';
/** Prompts logged-out users to log in when viewing a thread. */
const ThreadLoginCta: React.FC = () => {
const { displayCta } = useSoapboxConfig();
const siteTitle = useAppSelector(state => state.instance.title);
if (!displayCta) return null;
return (
<Card className='px-6 py-12 space-y-6 text-center' variant='rounded'>
<Stack>

Wyświetl plik

@ -79,7 +79,7 @@ const CompareHistoryModal: React.FC<ICompareHistoryModal> = ({ onClose, statusId
)}
<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>
</div>
);

Wyświetl plik

@ -5,11 +5,11 @@ import { Banner, Button, HStack, Stack, Text } from 'soapbox/components/ui';
import { useAppSelector, useSoapboxConfig } from 'soapbox/hooks';
const CtaBanner = () => {
const { singleUserMode } = useSoapboxConfig();
const { displayCta, singleUserMode } = useSoapboxConfig();
const siteTitle = useAppSelector((state) => state.instance.title);
const me = useAppSelector((state) => state.me);
if (me || singleUserMode) return null;
if (me || !displayCta || singleUserMode) return null;
return (
<div data-testid='cta-banner' className='hidden lg:block'>

Wyświetl plik

@ -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>
);
}
}

Wyświetl plik

@ -1,19 +1,20 @@
import classNames from 'clsx';
import PropTypes from 'prop-types';
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 = {
alt: PropTypes.string,
src: PropTypes.string.isRequired,
previewSrc: PropTypes.string,
width: PropTypes.number,
height: PropTypes.number,
onClick: PropTypes.func,
}
interface IImageLoader {
alt?: string,
src: string,
previewSrc?: string,
width?: number,
height?: number,
onClick?: React.MouseEventHandler,
}
class ImageLoader extends React.PureComponent<IImageLoader> {
static defaultProps = {
alt: '',
@ -27,8 +28,9 @@ export default class ImageLoader extends React.PureComponent {
width: null,
}
removers = [];
canvas = null;
removers: EventRemover[] = [];
canvas: HTMLCanvasElement | null = null;
_canvasContext: CanvasRenderingContext2D | null = null;
get canvasContext() {
if (!this.canvas) {
@ -42,7 +44,7 @@ export default class ImageLoader extends React.PureComponent {
this.loadImage(this.props);
}
componentDidUpdate(prevProps) {
componentDidUpdate(prevProps: IImageLoader) {
if (prevProps.src !== this.props.src) {
this.loadImage(this.props);
}
@ -52,7 +54,7 @@ export default class ImageLoader extends React.PureComponent {
this.removeEventListeners();
}
loadImage(props) {
loadImage(props: IImageLoader) {
this.removeEventListeners();
this.setState({ loading: true, error: false });
Promise.all([
@ -66,7 +68,7 @@ export default class ImageLoader extends React.PureComponent {
.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 removeEventListeners = () => {
image.removeEventListener('error', handleError);
@ -78,21 +80,23 @@ export default class ImageLoader extends React.PureComponent {
};
const handleLoad = () => {
removeEventListeners();
this.canvasContext.drawImage(image, 0, 0, width, height);
this.canvasContext?.drawImage(image, 0, 0, width || 0, height || 0);
resolve();
};
image.addEventListener('error', handleError);
image.addEventListener('load', handleLoad);
image.src = previewSrc;
image.src = previewSrc || '';
this.removers.push(removeEventListeners);
})
clearPreviewCanvas() {
const { width, height } = this.canvas;
this.canvasContext.clearRect(0, 0, width, height);
if (this.canvas && this.canvasContext) {
const { width, height } = this.canvas;
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 removeEventListeners = () => {
image.removeEventListener('error', handleError);
@ -122,7 +126,7 @@ export default class ImageLoader extends React.PureComponent {
return typeof width === 'number' && typeof height === 'number';
}
setCanvasRef = c => {
setCanvasRef = (c: HTMLCanvasElement) => {
this.canvas = c;
if (c) this.setState({ width: c.offsetWidth });
}
@ -157,3 +161,5 @@ export default class ImageLoader extends React.PureComponent {
}
}
export default ImageLoader;

Wyświetl plik

@ -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;

Wyświetl plik

@ -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>
);
}
}

Wyświetl plik

@ -15,7 +15,6 @@ import {
ListAdder,
MissingDescriptionModal,
ActionsModal,
FocalPointModal,
HotkeysModal,
ComposeModal,
ReplyMentionsModal,
@ -51,7 +50,6 @@ const MODAL_COMPONENTS = {
'ACTIONS': ActionsModal,
'EMBED': EmbedModal,
'LIST_EDITOR': ListEditor,
'FOCAL_POINT': FocalPointModal,
'LIST_ADDER': ListAdder,
'HOTKEYS': HotkeysModal,
'COMPOSE': ComposeModal,

Wyświetl plik

@ -22,8 +22,8 @@ const dateFormatOptions: FormatDateOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
hour12: false,
hour: '2-digit',
hour12: true,
hour: 'numeric',
minute: '2-digit',
};

Wyświetl plik

@ -1,26 +1,57 @@
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 { 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';
interface ITrendsPanel {
limit: number
}
const messages = defineMessages({
viewAll: {
id: 'trendsPanel.viewAll',
defaultMessage: 'View all',
},
});
const TrendsPanel = ({ limit }: ITrendsPanel) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const { data: trends, isFetching } = useTrends();
if (trends?.length === 0 || isFetching) {
const setHashtagsFilter = () => {
dispatch(setFilter('hashtags'));
};
if (!isFetching && !trends?.length) {
return null;
}
return (
<Widget title={<FormattedMessage id='trends.title' defaultMessage='Trends' />}>
{trends?.slice(0, limit).map((hashtag) => (
<Hashtag key={hashtag.name} hashtag={hashtag} />
))}
<Widget
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} />
))
)}
</Widget>
);
};

Wyświetl plik

@ -1,28 +1,27 @@
import PropTypes from 'prop-types';
import React from 'react';
const MIN_SCALE = 1;
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,
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));
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 = {
alt: PropTypes.string,
src: PropTypes.string.isRequired,
width: PropTypes.number,
height: PropTypes.number,
onClick: PropTypes.func,
}
class ZoomableImage extends React.PureComponent<IZoomableImage> {
static defaultProps = {
alt: '',
@ -34,39 +33,32 @@ export default class ZoomableImage extends React.PureComponent {
scale: MIN_SCALE,
}
removers = [];
container = null;
image = null;
lastTouchEndTime = 0;
container: HTMLDivElement | null = null;
image: HTMLImageElement | null = null;
lastDistance = 0;
componentDidMount() {
let handler = this.handleTouchStart;
this.container.addEventListener('touchstart', handler);
this.removers.push(() => this.container.removeEventListener('touchstart', handler));
handler = this.handleTouchMove;
this.container?.addEventListener('touchstart', this.handleTouchStart);
// on Chrome 56+, touch event listeners will default to passive
// https://www.chromestatus.com/features/5093566007214080
this.container.addEventListener('touchmove', handler, { passive: false });
this.removers.push(() => this.container.removeEventListener('touchend', handler));
this.container?.addEventListener('touchmove', this.handleTouchMove, { passive: false });
}
componentWillUnmount() {
this.removeEventListeners();
this.container?.removeEventListener('touchstart', this.handleTouchStart);
this.container?.removeEventListener('touchend', this.handleTouchMove);
}
removeEventListeners() {
this.removers.forEach(listeners => listeners());
this.removers = [];
}
handleTouchStart = e => {
handleTouchStart = (e: TouchEvent) => {
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;
if (e.touches.length === 1 && scrollTop !== scrollHeight - clientHeight) {
// prevent propagating event to MediaModal
@ -78,17 +70,19 @@ export default class ZoomableImage extends React.PureComponent {
e.preventDefault();
e.stopPropagation();
const distance = getDistance(...e.touches);
const midpoint = getMidpoint(...e.touches);
const [p1, p2] = Array.from(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);
this.zoom(scale, midpoint);
this.lastMidpoint = midpoint;
this.lastDistance = distance;
}
zoom(nextScale, midpoint) {
zoom(nextScale: number, midpoint: Point) {
if (!this.container) return;
const { scale } = this.state;
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;
this.setState({ scale: nextScale }, () => {
if (!this.container) return;
this.container.scrollLeft = nextScrollLeft;
this.container.scrollTop = nextScrollTop;
});
}
handleClick = e => {
handleClick: React.MouseEventHandler = e => {
// don't propagate event to MediaModal
e.stopPropagation();
const handler = this.props.onClick;
if (handler) handler();
if (handler) handler(e);
}
setContainerRef = c => {
setContainerRef = (c: HTMLDivElement) => {
this.container = c;
}
setImageRef = c => {
setImageRef = (c: HTMLImageElement) => {
this.image = c;
}
@ -150,3 +145,5 @@ export default class ZoomableImage extends React.PureComponent {
}
}
export default ZoomableImage;

Wyświetl plik

@ -23,7 +23,7 @@ export function CommunityTimeline() {
}
export function HashtagTimeline() {
return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline');
return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag-timeline');
}
export function DirectTimeline() {
@ -123,7 +123,7 @@ export function Audio() {
}
export function MediaModal() {
return import(/* webpackChunkName: "features/ui" */'../components/media_modal');
return import(/* webpackChunkName: "features/ui" */'../components/media-modal');
}
export function VideoModal() {
@ -146,10 +146,6 @@ export function ActionsModal() {
return import(/* webpackChunkName: "features/ui" */'../components/actions_modal');
}
export function FocalPointModal() {
return import(/* webpackChunkName: "features/ui" */'../components/focal_point_modal');
}
export function HotkeysModal() {
return import(/* webpackChunkName: "features/ui" */'../components/hotkeys_modal');
}

Wyświetl plik

@ -20,6 +20,10 @@ const messages = defineMessages({
id: 'registrations.success',
defaultMessage: 'Welcome to {siteTitle}!',
},
usernameHint: {
id: 'registrations.username.hint',
defaultMessage: 'May only contain A-Z, 0-9, and underscores',
},
usernameTaken: {
id: 'registrations.unprocessable_entity',
defaultMessage: 'This username has already been taken.',
@ -104,7 +108,7 @@ const Registration = () => {
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto space-y-4'>
<Form onSubmit={handleSubmit}>
<FormGroup labelText='Your username'>
<FormGroup labelText='Your username' hintText={intl.formatMessage(messages.usernameHint)}>
<Input
name='username'
type='text'
@ -112,6 +116,7 @@ const Registration = () => {
onChange={handleInputChange}
required
icon={require('@tabler/icons/at.svg')}
placeholder='LibertyForAll'
/>
</FormGroup>

Wyświetl plik

@ -49,6 +49,7 @@
"account.requested": "Oczekująca prośba, kliknij aby anulować",
"account.requested_small": "Oczekująca prośba",
"account.search": "Szukaj wpisów @{name}",
"account.search_self": "Szukaj własnych wpisów",
"account.share": "Udostępnij profil @{name}",
"account.show_reblogs": "Pokazuj podbicia od @{name}",
"account.subscribe": "Subskrybuj wpisy @{name}",
@ -67,6 +68,18 @@
"account.verified": "Zweryfikowane konto",
"account.welcome": "Welcome",
"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.placeholder": "Nie wprowadzono opisu",
"account_note.save": "Zapisz",
@ -125,6 +138,7 @@
"admin.users.actions.unsuggest_user": "Przestań polecać @{name}",
"admin.users.actions.unverify_user": "Cofnij weryfikację @{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.set_donor_message": "Ustawiono @{acct} jako wspierającego",
"admin.users.user_deactivated_message": "Zdezaktywowano @{acct}",
@ -173,6 +187,7 @@
"backups.empty_message": "Nie znaleziono kopii zapasowych. {action}",
"backups.empty_message.action": "Chcesz utworzyć?",
"backups.pending": "Oczekująca",
"badge_input.placeholder": "Wprowadź odznakę…",
"beta.also_available": "Dostępne w językach:",
"birthday_panel.title": "Urodziny",
"birthdays_modal.empty": "Żaden z Twoich znajomych nie ma dziś urodzin.",
@ -736,8 +751,10 @@
"migration.fields.acct.placeholder": "konto@domena",
"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.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.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.submit": "Przenieś obserwujących",
"missing_description_modal.cancel": "Anuluj",
@ -747,6 +764,9 @@
"missing_indicator.label": "Nie znaleziono",
"missing_indicator.sublabel": "Nie można odnaleźć tego zasobu",
"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.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?",
@ -843,6 +863,10 @@
"onboarding.display_name.subtitle": "Możesz ją zawsze zmienić później.",
"onboarding.display_name.title": "Wybierz wyświetlaną nazwę",
"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.title": "Wprowadzenie ukończone",
"onboarding.header.subtitle": "Będzie widoczny w górnej części Twojego profilu",
@ -1008,6 +1032,7 @@
"report.target": "Zgłaszanie {target}",
"reset_password.fail": "Token wygasł, spróbuj ponownie.",
"reset_password.header": "Ustaw nowe hasło",
"save": "Zapisz",
"schedule.post_time": "Data/godzina publikacji",
"schedule.remove": "Usuń zaplanowany wpis",
"schedule_button.add_schedule": "Zaplanuj wpis na później",
@ -1128,7 +1153,7 @@
"sponsored.info.title": "Dlaczego widzę tę reklamę?",
"sponsored.subtitle": "Wpis sponsorowany",
"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.block": "Zablokuj @{name}",
"status.bookmark": "Dodaj do zakładek",

Wyświetl plik

@ -5,6 +5,8 @@ import {
fromJS,
} from 'immutable';
import { normalizeAttachment } from 'soapbox/normalizers/attachment';
import type { Attachment, Card, Emoji } from 'soapbox/types/entities';
export const ChatMessageRecord = ImmutableRecord({
@ -22,8 +24,14 @@ export const ChatMessageRecord = ImmutableRecord({
pending: false,
});
const normalizeMedia = (status: ImmutableMap<string, any>) => {
return status.update('attachment', null, normalizeAttachment);
};
export const normalizeChatMessage = (chatMessage: Record<string, any>) => {
return ChatMessageRecord(
ImmutableMap(fromJS(chatMessage)),
ImmutableMap(fromJS(chatMessage)).withMutations(chatMessage => {
normalizeMedia(chatMessage);
}),
);
};

Wyświetl plik

@ -106,12 +106,12 @@ export const SoapboxConfigRecord = ImmutableRecord({
limit: 1,
}),
aboutPages: ImmutableMap<string, ImmutableMap<string, unknown>>(),
mobilePages: ImmutableMap<string, ImmutableMap<string, unknown>>(),
authenticatedProfile: true,
singleUserMode: false,
singleUserModeProfile: '',
linkFooterMessage: '',
links: ImmutableMap<string, string>(),
displayCta: true,
}, 'SoapboxConfig');
type SoapboxConfigMap = ImmutableMap<string, any>;

Wyświetl plik

@ -36,10 +36,10 @@ const DefaultPage: React.FC = ({ children }) => {
)}
{features.trends && (
<BundleContainer fetchComponent={TrendsPanel}>
{Component => <Component limit={3} key='trends-panel' />}
{Component => <Component limit={5} key='trends-panel' />}
</BundleContainer>
)}
{features.suggestions && (
{me && features.suggestions && (
<BundleContainer fetchComponent={WhoToFollowPanel}>
{Component => <Component limit={3} key='wtf-panel' />}
</BundleContainer>

Wyświetl plik

@ -82,7 +82,7 @@ const HomePage: React.FC = ({ children }) => {
)}
{features.trends && (
<BundleContainer fetchComponent={TrendsPanel}>
{Component => <Component limit={3} />}
{Component => <Component limit={5} />}
</BundleContainer>
)}
{hasPatron && (
@ -103,7 +103,7 @@ const HomePage: React.FC = ({ children }) => {
{Component => <Component limit={10} />}
</BundleContainer>
)}
{features.suggestions && (
{me && features.suggestions && (
<BundleContainer fetchComponent={WhoToFollowPanel}>
{Component => <Component limit={3} />}
</BundleContainer>

Wyświetl plik

@ -137,7 +137,7 @@ const ProfilePage: React.FC<IProfilePage> = ({ params, children }) => {
<BundleContainer fetchComponent={PinnedAccountsPanel}>
{Component => <Component account={account} limit={5} key='pinned-accounts-panel' />}
</BundleContainer>
) : features.suggestions && (
) : me && features.suggestions && (
<BundleContainer fetchComponent={WhoToFollowPanel}>
{Component => <Component limit={3} key='wtf-panel' />}
</BundleContainer>

Wyświetl plik

@ -40,10 +40,10 @@ const StatusPage: React.FC<IStatusPage> = ({ children }) => {
)}
{features.trends && (
<BundleContainer fetchComponent={TrendsPanel}>
{Component => <Component limit={3} key='trends-panel' />}
{Component => <Component limit={5} key='trends-panel' />}
</BundleContainer>
)}
{features.suggestions && (
{me && features.suggestions && (
<BundleContainer fetchComponent={WhoToFollowPanel}>
{Component => <Component limit={3} key='wtf-panel' />}
</BundleContainer>

Wyświetl plik

@ -242,39 +242,3 @@
@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;
}
}

Wyświetl plik

@ -387,12 +387,6 @@
}
}
.focal-point-modal {
max-width: 80vw;
max-height: 80vh;
position: relative;
}
.column-inline-form {
padding: 7px 15px;
padding-right: 5px;