Merge remote-tracking branch 'soapbox/develop' into events-

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
environments/review-events-5jp5it/deployments/1372
marcin mikołajczak 2 years ago
commit 3e0b6c38cd

@ -1,27 +0,0 @@
import { staticClient } from '../api';
import type { AppDispatch } from 'soapbox/store';
const FETCH_MOBILE_PAGE_REQUEST = 'FETCH_MOBILE_PAGE_REQUEST';
const FETCH_MOBILE_PAGE_SUCCESS = 'FETCH_MOBILE_PAGE_SUCCESS';
const FETCH_MOBILE_PAGE_FAIL = 'FETCH_MOBILE_PAGE_FAIL';
const fetchMobilePage = (slug = 'index', locale?: string) =>
(dispatch: AppDispatch) => {
dispatch({ type: FETCH_MOBILE_PAGE_REQUEST, slug, locale });
const filename = `${slug}${locale ? `.${locale}` : ''}.html`;
return staticClient.get(`/instance/mobile/${filename}`).then(({ data: html }) => {
dispatch({ type: FETCH_MOBILE_PAGE_SUCCESS, slug, locale, html });
return html;
}).catch(error => {
dispatch({ type: FETCH_MOBILE_PAGE_FAIL, slug, locale, error });
throw error;
});
};
export {
FETCH_MOBILE_PAGE_REQUEST,
FETCH_MOBILE_PAGE_SUCCESS,
FETCH_MOBILE_PAGE_FAIL,
fetchMobilePage,
};

@ -34,11 +34,11 @@ 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'}
hour={skipTime ? undefined : 'numeric'}
minute={skipTime ? undefined : '2-digit'}
/>
{' '}
@ -46,11 +46,11 @@ const Announcement: React.FC<IAnnouncement> = ({ announcement, addReaction, remo
{' '}
<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>

@ -7,6 +7,7 @@ import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest
import { useAppDispatch } from 'soapbox/hooks';
import type { Menu } from 'soapbox/components/dropdown_menu';
import type { InputThemes } from 'soapbox/components/ui/input/input';
const noOp = () => {};
@ -19,6 +20,7 @@ interface IAutosuggestAccountInput {
autoSelect?: boolean,
menu?: Menu,
onKeyDown?: React.KeyboardEventHandler,
theme?: InputThemes,
}
const AutosuggestAccountInput: React.FC<IAutosuggestAccountInput> = ({

@ -6,10 +6,12 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import AutosuggestEmoji, { Emoji } from 'soapbox/components/autosuggest_emoji';
import Icon from 'soapbox/components/icon';
import { Input } from 'soapbox/components/ui';
import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest_account';
import { isRtl } from 'soapbox/rtl';
import type { Menu, MenuItem } from 'soapbox/components/dropdown_menu';
import type { InputThemes } from 'soapbox/components/ui/input/input';
type CursorMatch = [
tokenStart: number | null,
@ -60,6 +62,7 @@ interface IAutosuggestInput extends Pick<React.HTMLAttributes<HTMLInputElement>,
menu?: Menu,
resultsPosition: string,
renderSuggestion?: React.FC<{ id: string }>,
theme?: InputThemes,
}
export default class AutosuggestInput extends ImmutablePureComponent<IAutosuggestInput> {
@ -289,7 +292,7 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
}
render() {
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength, menu } = this.props;
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength, menu, theme } = this.props;
const { suggestionsHidden } = this.state;
const style: React.CSSProperties = { direction: 'ltr' };
@ -303,11 +306,10 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
<div key='input' className='relative w-full'>
<label className='sr-only'>{placeholder}</label>
<input
<Input
type='text'
className={classNames({
'block w-full sm:text-sm border-gray-200 dark:border-gray-800 bg-gray-200 dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-600 dark:placeholder:text-gray-600 focus:border-gray-200 dark:focus-border-gray-800 focus:ring-primary-500 focus:ring-2': true,
}, className)}
className={className}
outerClassName='mt-0'
ref={this.setInput}
disabled={disabled}
placeholder={placeholder}
@ -323,6 +325,7 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
id={id}
maxLength={maxLength}
data-testid='autosuggest-input'
theme={theme}
/>
</div>,
<Portal key='portal'>

@ -83,10 +83,7 @@ const EmojiButtonWrapper: React.FC<IEmojiButtonWrapper> = ({ statusId, children
if (ownAccount) {
dispatch(simpleEmojiReact(status, emoji));
} else {
dispatch(openModal('UNAUTHORIZED', {
action: 'FAVOURITE',
ap_id: status.url,
}));
handleUnauthorized();
}
setVisible(false);
@ -96,10 +93,14 @@ const EmojiButtonWrapper: React.FC<IEmojiButtonWrapper> = ({ statusId, children
const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji) || '👍';
if (isUserTouching()) {
if (visible) {
handleReact(meEmojiReact);
if (ownAccount) {
if (visible) {
handleReact(meEmojiReact);
} else {
setVisible(true);
}
} else {
setVisible(true);
handleUnauthorized();
}
} else {
handleReact(meEmojiReact);
@ -109,6 +110,13 @@ const EmojiButtonWrapper: React.FC<IEmojiButtonWrapper> = ({ statusId, children
e.stopPropagation();
};
const handleUnauthorized = () => {
dispatch(openModal('UNAUTHORIZED', {
action: 'FAVOURITE',
ap_id: status.url,
}));
};
// const handleUnfocus: React.EventHandler<React.KeyboardEvent> = () => {
// setFocused(false);
// };

@ -1,6 +1,5 @@
import classNames from 'clsx';
import React, { useState, useRef, useEffect } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import Blurhash from 'soapbox/components/blurhash';
import Icon from 'soapbox/components/icon';
@ -13,8 +12,6 @@ import { truncateFilename } from 'soapbox/utils/media';
import { isIOS } from '../is_mobile';
import { isPanoramic, isPortrait, isNonConformingRatio, minimumAspectRatio, maximumAspectRatio } from '../utils/media_aspect_ratio';
import { Button, Text } from './ui';
import type { Property } from 'csstype';
import type { List as ImmutableList } from 'immutable';
@ -39,10 +36,6 @@ interface SizeData {
width: number,
}
const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Hide' },
});
const withinLimits = (aspectRatio: number) => {
return aspectRatio >= minimumAspectRatio && aspectRatio <= maximumAspectRatio;
};
@ -276,35 +269,16 @@ interface IMediaGallery {
const MediaGallery: React.FC<IMediaGallery> = (props) => {
const {
media,
sensitive = false,
defaultWidth = 0,
onToggleVisibility,
onOpenMedia,
cacheWidth,
compact,
height,
} = props;
const intl = useIntl();
const settings = useSettings();
const displayMedia = settings.get('displayMedia') as string | undefined;
const [visible, setVisible] = useState<boolean>(props.visible !== undefined ? props.visible : (displayMedia !== 'hide_all' && !sensitive || displayMedia === 'show_all'));
const [width, setWidth] = useState<number>(defaultWidth);
const node = useRef<HTMLDivElement>(null);
const handleOpen: React.MouseEventHandler = (e) => {
e.stopPropagation();
if (onToggleVisibility) {
onToggleVisibility();
} else {
setVisible(!visible);
}
};
const handleClick = (index: number) => {
onOpenMedia(media, index);
};
@ -545,20 +519,13 @@ const MediaGallery: React.FC<IMediaGallery> = (props) => {
index={i}
size={sizeData.size}
displayWidth={sizeData.width}
visible={visible}
visible={!!props.visible}
dimensions={sizeData.itemsDimensions[i]}
last={i === ATTACHMENT_LIMIT - 1}
total={media.size}
/>
));
let warning;
if (sensitive) {
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
} else {
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
}
useEffect(() => {
if (node.current) {
@ -572,60 +539,8 @@ const MediaGallery: React.FC<IMediaGallery> = (props) => {
}
}, [node.current]);
useEffect(() => {
setVisible(!!props.visible);
}, [props.visible]);
return (
<div className={classNames('media-gallery', { 'media-gallery--compact': compact })} style={sizeData.style} ref={node}>
<div
className={classNames({
'absolute z-40': true,
'inset-0': !visible && !compact,
'left-1 top-1': visible || compact,
})}
>
{sensitive && (
(visible || compact) ? (
<Button
text={intl.formatMessage(messages.toggle_visible)}
icon={visible ? require('@tabler/icons/eye-off.svg') : require('@tabler/icons/eye.svg')}
onClick={handleOpen}
theme='transparent'
size='sm'
/>
) : (
<div
onClick={(e) => e.stopPropagation()}
className={
classNames({
'bg-gray-800/75 cursor-default backdrop-blur-sm rounded-lg w-full h-full border-0 flex items-center justify-center': true,
})
}
>
<div className='text-center w-3/4 mx-auto space-y-4'>
<div className='space-y-1'>
<Text theme='white' weight='semibold'>{warning}</Text>
<Text size='sm'>
<FormattedMessage id='status.sensitive_warning.subtitle' defaultMessage='This content may not be suitable for all audiences.' />
</Text>
</div>
<Button
type='button'
theme='outline'
size='sm'
icon={require('@tabler/icons/eye.svg')}
onClick={handleOpen}
>
<FormattedMessage id='status.sensitive_warning.action' defaultMessage='Show content' />
</Button>
</div>
</div>
)
)}
</div>
{children}
</div>
);

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

@ -32,7 +32,7 @@ const StatusMedia: React.FC<IStatusMedia> = ({
muted = false,
onClick,
showMedia = true,
onToggleVisibility = () => {},
onToggleVisibility = () => { },
excludeBanner = false,
}) => {
const dispatch = useAppDispatch();
@ -64,7 +64,7 @@ const StatusMedia: React.FC<IStatusMedia> = ({
};
const openMedia = (media: ImmutableList<Attachment>, index: number) => {
dispatch(openModal('MEDIA', { media, index }));
dispatch(openModal('MEDIA', { media, status, index }));
};
const openVideo = (media: Attachment, time: number): void => {

@ -19,8 +19,8 @@ import StatusActionBar from './status-action-bar';
import StatusMedia from './status-media';
import StatusReplyMentions from './status-reply-mentions';
import StatusContent from './status_content';
import ModerationOverlay from './statuses/moderation-overlay';
import { Card, HStack, Text } from './ui';
import SensitiveContentOverlay from './statuses/sensitive-content-overlay';
import { Card, HStack, Stack, Text } from './ui';
import type { Map as ImmutableMap } from 'immutable';
import type {
@ -118,9 +118,9 @@ const Status: React.FC<IStatus> = (props) => {
if (firstAttachment) {
if (firstAttachment.type === 'video') {
dispatch(openModal('VIDEO', { media: firstAttachment, time: 0 }));
dispatch(openModal('VIDEO', { status, media: firstAttachment, time: 0 }));
} else {
dispatch(openModal('MEDIA', { media: status.media_attachments, index: 0 }));
dispatch(openModal('MEDIA', { status, media: status.media_attachments, index: 0 }));
}
}
};
@ -302,6 +302,7 @@ const Status: React.FC<IStatus> = (props) => {
const accountAction = props.accountAction || reblogElement;
const inReview = status.visibility === 'self';
const isSensitive = status.sensitive;
return (
<HotKeys handlers={handlers} data-testid='status'>
@ -352,47 +353,55 @@ const Status: React.FC<IStatus> = (props) => {
/>
</div>
<div
className={classNames('status__content-wrapper relative', {
'min-h-[220px]': inReview,
})}
>
{inReview ? (
<ModerationOverlay />
) : null}
{!group && actualStatus.group && (
<div className='status__meta'>
Posted in <NavLink to={`/groups/${actualStatus.getIn(['group', 'id'])}`}>{String(actualStatus.getIn(['group', 'title']))}</NavLink>
</div>
)}
<StatusReplyMentions
status={actualStatus}
hoverable={hoverable}
/>
{actualStatus.event ? <EventPreview className='shadow-xl' status={actualStatus} /> : (
<>
<StatusContent
status={actualStatus}
onClick={handleClick}
expanded={!status.hidden}
onExpandedToggle={handleExpandedToggle}
collapsable
/>
<StatusMedia
status={actualStatus}
muted={muted}
onClick={handleClick}
showMedia={showMedia}
<div className='status__content-wrapper'>
<Stack
className={
classNames('relative', {
'min-h-[220px]': inReview || isSensitive,
})
}
>
{(inReview || isSensitive) ? (
<SensitiveContentOverlay
status={status}
visible={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
/>
{quote}
</>
)}
) : null}
{!group && actualStatus.group && (
<div className='status__meta'>
Posted in <NavLink to={`/groups/${actualStatus.getIn(['group', 'id'])}`}>{String(actualStatus.getIn(['group', 'title']))}</NavLink>
</div>
)}
<StatusReplyMentions
status={actualStatus}
hoverable={hoverable}
/>
{actualStatus.event ? <EventPreview className='shadow-xl' status={actualStatus} /> : (
<>
<StatusContent
status={actualStatus}
onClick={handleClick}
expanded={!status.hidden}
onExpandedToggle={handleExpandedToggle}
collapsable
/>
<StatusMedia
status={actualStatus}
muted={muted}
onClick={handleClick}
showMedia={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
/>
{quote}
</>
)}
</Stack>
{!hideActionBar && (
<div className='pt-4'>

@ -19,7 +19,7 @@ import useAds from 'soapbox/queries/ads';
import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
import type { VirtuosoHandle } from 'react-virtuoso';
import type { IScrollableList } from 'soapbox/components/scrollable_list';
import type { Ad as AdEntity } from 'soapbox/features/ads/providers';
import type { Ad as AdEntity } from 'soapbox/types/soapbox';
interface IStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'> {
/** Unique key to preserve the scroll position when navigating back. */
@ -141,12 +141,7 @@ const StatusList: React.FC<IStatusList> = ({
const renderAd = (ad: AdEntity, index: number) => {
return (
<Ad
key={`ad-${index}`}
card={ad.card}
impression={ad.impression}
expires={ad.expires}
/>
<Ad key={`ad-${index}`} ad={ad} />
);
};

@ -1,19 +0,0 @@
import React from 'react';
import { fireEvent, render, screen } from '../../../jest/test-helpers';
import ModerationOverlay from '../moderation-overlay';
describe('<ModerationOverlay />', () => {
it('defaults to enabled', () => {
render(<ModerationOverlay />);
expect(screen.getByTestId('moderation-overlay')).toHaveTextContent('Content Under Review');
});
it('can be toggled', () => {
render(<ModerationOverlay />);
fireEvent.click(screen.getByTestId('button'));
expect(screen.getByTestId('moderation-overlay')).not.toHaveTextContent('Content Under Review');
expect(screen.getByTestId('moderation-overlay')).toHaveTextContent('Hide');
});
});

@ -0,0 +1,111 @@
import { Map as ImmutableMap } from 'immutable';
import React from 'react';
import { normalizeStatus } from 'soapbox/normalizers';
import { ReducerStatus } from 'soapbox/reducers/statuses';
import { fireEvent, render, rootState, screen } from '../../../jest/test-helpers';
import SensitiveContentOverlay from '../sensitive-content-overlay';
describe('<SensitiveContentOverlay />', () => {
let status: ReducerStatus;
describe('when the Status is marked as sensitive', () => {
beforeEach(() => {
status = normalizeStatus({ sensitive: true }) as ReducerStatus;
});
it('displays the "Sensitive content" warning', () => {
render(<SensitiveContentOverlay status={status} />);
expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Sensitive content');
});
it('can be toggled', () => {
render(<SensitiveContentOverlay status={status} />);
fireEvent.click(screen.getByTestId('button'));
expect(screen.getByTestId('sensitive-overlay')).not.toHaveTextContent('Sensitive content');
expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Hide');
fireEvent.click(screen.getByTestId('button'));
expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Sensitive content');
expect(screen.getByTestId('sensitive-overlay')).not.toHaveTextContent('Hide');
});
});
describe('when the Status is marked as in review', () => {
beforeEach(() => {
status = normalizeStatus({ visibility: 'self', sensitive: false }) as ReducerStatus;
});
it('displays the "Under review" warning', () => {
render(<SensitiveContentOverlay status={status} />);
expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Content Under Review');
});
it('can be toggled', () => {
render(<SensitiveContentOverlay status={status} />);
fireEvent.click(screen.getByTestId('button'));
expect(screen.getByTestId('sensitive-overlay')).not.toHaveTextContent('Content Under Review');
expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Hide');
fireEvent.click(screen.getByTestId('button'));
expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Content Under Review');
expect(screen.getByTestId('sensitive-overlay')).not.toHaveTextContent('Hide');
});
});
describe('when the Status is marked as in review and sensitive', () => {
beforeEach(() => {
status = normalizeStatus({ visibility: 'self', sensitive: true }) as ReducerStatus;
});
it('displays the "Under review" warning', () => {
render(<SensitiveContentOverlay status={status} />);
expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Content Under Review');
});
it('can be toggled', () => {
render(<SensitiveContentOverlay status={status} />);
fireEvent.click(screen.getByTestId('button'));
expect(screen.getByTestId('sensitive-overlay')).not.toHaveTextContent('Content Under Review');
expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Hide');
fireEvent.click(screen.getByTestId('button'));
expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Content Under Review');
expect(screen.getByTestId('sensitive-overlay')).not.toHaveTextContent('Hide');
});
});
describe('when the Status is marked as sensitive and displayMedia set to "show_all"', () => {
let store: any;
beforeEach(() => {
status = normalizeStatus({ sensitive: true }) as ReducerStatus;
store = rootState
.set('settings', ImmutableMap({
displayMedia: 'show_all',
}));
});
it('displays the "Under review" warning', () => {
render(<SensitiveContentOverlay status={status} />, undefined, store);
expect(screen.getByTestId('sensitive-overlay')).not.toHaveTextContent('Sensitive content');
expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Hide');
});
it('can be toggled', () => {
render(<SensitiveContentOverlay status={status} />, undefined, store);
fireEvent.click(screen.getByTestId('button'));
expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Sensitive content');
expect(screen.getByTestId('sensitive-overlay')).not.toHaveTextContent('Hide');
fireEvent.click(screen.getByTestId('button'));
expect(screen.getByTestId('sensitive-overlay')).not.toHaveTextContent('Sensitive content');
expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Hide');
});
});
});

@ -1,93 +0,0 @@
import classNames from 'clsx';
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useSoapboxConfig } from 'soapbox/hooks';
import { Button, HStack, Text } from '../ui';
const messages = defineMessages({
hide: { id: 'moderation_overlay.hide', defaultMessage: 'Hide' },
title: { id: 'moderation_overlay.title', defaultMessage: 'Content Under Review' },
subtitle: { id: 'moderation_overlay.subtitle', defaultMessage: 'This Post has been sent to Moderation for review and is only visible to you. If you believe this is an error please contact Support.' },
contact: { id: 'moderation_overlay.contact', defaultMessage: 'Contact' },
show: { id: 'moderation_overlay.show', defaultMessage: 'Show Content' },
});
const ModerationOverlay = () => {
const intl = useIntl();
const { links } = useSoapboxConfig();
const [visible, setVisible] = useState<boolean>(false);
const toggleVisibility = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
setVisible((prevValue) => !prevValue);
};
return (
<div
className={classNames('absolute z-40', {
'cursor-default backdrop-blur-lg rounded-lg w-full h-full border-0 flex justify-center items-center': !visible,
'bg-gray-800/75 inset-0': !visible,
'top-1 left-1': visible,
})}
data-testid='moderation-overlay'
>
{visible ? (
<Button
text={intl.formatMessage(messages.hide)}
icon={require('@tabler/icons/eye-off.svg')}
onClick={toggleVisibility}
theme='transparent'
size='sm'
/>
) : (
<div className='text-center w-3/4 mx-auto space-y-4'>
<div className='space-y-1'>
<Text theme='white' weight='semibold'>
{intl.formatMessage(messages.title)}
</Text>
<Text theme='white' size='sm' weight='medium'>
{intl.formatMessage(messages.subtitle)}
</Text>
</div>
<HStack alignItems='center' justifyContent='center' space={2}>
{links.get('support') && (
<a
href={links.get('support')}
target='_blank'
onClick={(event) => event.stopPropagation()}
>
<Button
type='button'
theme='outline'
size='sm'
icon={require('@tabler/icons/headset.svg')}
>
{intl.formatMessage(messages.contact)}
</Button>
</a>
)}
<Button
type='button'
theme='outline'
size='sm'
icon={require('@tabler/icons/eye.svg')}
onClick={toggleVisibility}
>
{intl.formatMessage(messages.show)}
</Button>
</HStack>
</div>
)}
</div>
);
};
export default ModerationOverlay;

@ -0,0 +1,124 @@
import classNames from 'clsx';
import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useSettings, useSoapboxConfig } from 'soapbox/hooks';
import { defaultMediaVisibility } from 'soapbox/utils/status';
import { Button, HStack, Text } from '../ui';
import type { Status as StatusEntity } from 'soapbox/types/entities';
const messages = defineMessages({
hide: { id: 'moderation_overlay.hide', defaultMessage: 'Hide content' },
sensitiveTitle: { id: 'status.sensitive_warning', defaultMessage: 'Sensitive content' },
underReviewTitle: { id: 'moderation_overlay.title', defaultMessage: 'Content Under Review' },
underReviewSubtitle: { id: 'moderation_overlay.subtitle', defaultMessage: 'This Post has been sent to Moderation for review and is only visible to you. If you believe this is an error please contact Support.' },
sensitiveSubtitle: { id: 'status.sensitive_warning.subtitle', defaultMessage: 'This content may not be suitable for all audiences.' },
contact: { id: 'moderation_overlay.contact', defaultMessage: 'Contact' },
show: { id: 'moderation_overlay.show', defaultMessage: 'Show Content' },
});
interface ISensitiveContentOverlay {
status: StatusEntity
onToggleVisibility?(): void
visible?: boolean
}
const SensitiveContentOverlay = (props: ISensitiveContentOverlay) => {
const { onToggleVisibility, status } = props;
const isUnderReview = status.visibility === 'self';
const settings = useSettings();
const displayMedia = settings.get('displayMedia') as string;
const intl = useIntl();
const { links } = useSoapboxConfig();
const [visible, setVisible] = useState<boolean>(defaultMediaVisibility(status, displayMedia));
const toggleVisibility = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (onToggleVisibility) {
onToggleVisibility();
} else {
setVisible((prevValue) => !prevValue);
}
};
useEffect(() => {
if (typeof props.visible !== 'undefined') {
setVisible(!!props.visible);
}
}, [props.visible]);
return (
<div
className={classNames('absolute z-40', {
'cursor-default backdrop-blur-lg rounded-lg w-full h-full border-0 flex justify-center items-center': !visible,
'bg-gray-800/75 inset-0': !visible,
'bottom-1 right-1': visible,
})}
data-testid='sensitive-overlay'
>
{visible ? (
<Button
text={intl.formatMessage(messages.hide)}
icon={require('@tabler/icons/eye-off.svg')}
onClick={toggleVisibility}
theme='primary'
size='sm'
/>
) : (
<div className='text-center w-3/4 mx-auto space-y-4'>
<div className='space-y-1'>
<Text theme='white' weight='semibold'>
{intl.formatMessage(isUnderReview ? messages.underReviewTitle : messages.sensitiveTitle)}
</Text>
<Text theme='white' size='sm' weight='medium'>
{intl.formatMessage(isUnderReview ? messages.underReviewSubtitle : messages.sensitiveSubtitle)}
</Text>
</div>
<HStack alignItems='center' justifyContent='center' space={2}>
{isUnderReview ? (
<>
{links.get('support') && (
<a
href={links.get('support')}
target='_blank'
onClick={(event) => event.stopPropagation()}
>
<Button
type='button'
theme='outline'
size='sm'
icon={require('@tabler/icons/headset.svg')}
>
{intl.formatMessage(messages.contact)}
</Button>
</a>
)}
</>
) : null}
<Button
type='button'
theme='outline'
size='sm'
icon={require('@tabler/icons/eye.svg')}
onClick={toggleVisibility}
>
{intl.formatMessage(messages.show)}
</Button>
</HStack>
</div>
)}
</div>
);
};
export default SensitiveContentOverlay;

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

@ -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. */

@ -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. */

@ -11,7 +11,10 @@ const messages = defineMessages({
hidePassword: { id: 'input.password.hide_password', defaultMessage: 'Hide password' },
});
interface IInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'maxLength' | 'onChange' | 'onBlur' | 'type' | 'autoComplete' | 'autoCorrect' | 'autoCapitalize' | 'required' | 'disabled' | 'onClick' | 'readOnly' | 'min' | 'pattern'> {
/** Possible theme names for an Input. */
type InputThemes = 'normal' | 'search' | 'transparent';
interface IInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'maxLength' | 'onChange' | 'onBlur' | 'type' | 'autoComplete' | 'autoCorrect' | 'autoCapitalize' | 'required' | 'disabled' | 'onClick' | 'readOnly' | 'min' | 'pattern' | 'onKeyDown' | 'onKeyUp' | 'onFocus' | 'style' | 'id'> {
/** Put the cursor into the input on mount. */
autoFocus?: boolean,
/** The initial text in the input. */
@ -36,8 +39,8 @@ interface IInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'maxL
prepend?: React.ReactElement,
/** An element to display as suffix to input. Cannot be used with password type. */
append?: React.ReactElement,
/** Adds specific styling to denote a searchabe input. */
isSearch?: boolean,
/** Theme to style the input with. */
theme?: InputThemes,
}
/** Form input element. */
@ -45,7 +48,7 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
(props, ref) => {
const intl = useIntl();
const { type = 'text', icon, className, outerClassName, hasError, append, prepend, isSearch, ...filteredProps } = props;
const { type = 'text', icon, className, outerClassName, hasError, append, prepend, theme = 'normal', ...filteredProps } = props;
const [revealed, setRevealed] = React.useState(false);
@ -59,8 +62,8 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
<div
className={
classNames('mt-1 relative shadow-sm', outerClassName, {
'rounded-md': !isSearch,
'rounded-full': isSearch,
'rounded-md': theme !== 'search',
'rounded-full': theme === 'search',
})
}
>
@ -82,9 +85,10 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
ref={ref}
className={classNames({
'text-gray-900 dark:text-gray-100 placeholder:text-gray-600 dark:placeholder:text-gray-600 block w-full sm:text-sm dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500':
true,
'rounded-md bg-white dark:bg-gray-900 border-gray-400 dark:border-gray-800': !isSearch,
'rounded-full bg-gray-200 border-gray-200 dark:bg-gray-800 dark:border-gray-800 focus:bg-white': isSearch,
['normal', 'search'].includes(theme),
'rounded-md bg-white dark:bg-gray-900 border-gray-400 dark:border-gray-800': theme === 'normal',
'rounded-full bg-gray-200 border-gray-200 dark:bg-gray-800 dark:border-gray-800 focus:bg-white': theme === 'search',
'bg-transparent border-none': theme === 'transparent',
'pr-7': isPassword || append,
'text-red-600 border-red-600': hasError,
'pl-8': typeof icon !== 'undefined',
@ -127,4 +131,7 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
},
);
export default Input;
export {
Input as default,
InputThemes,
};

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

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

@ -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] {

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

@ -138,7 +138,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) && (

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

@ -27,7 +27,7 @@ const ReportStatus: React.FC<IReportStatus> = ({ status }) => {
const dispatch = useAppDispatch();
const handleOpenMedia = (media: Attachment, index: number) => {
dispatch(openModal('MEDIA', { media, index }));
dispatch(openModal('MEDIA', { media, status, index }));
};
const handleDeleteStatus = () => {

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

@ -1,4 +1,4 @@
import { useQueryClient } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import React, { useState, useEffect, useRef } from 'react';
import { FormattedMessage } from 'react-intl';
@ -7,19 +7,14 @@ import IconButton from 'soapbox/components/ui/icon-button/icon-button';
import StatusCard from 'soapbox/features/status/components/card';
import { useAppSelector } from 'soapbox/hooks';
import type { Card as CardEntity } from 'soapbox/types/entities';
import type { Ad as AdEntity } from 'soapbox/types/soapbox';
interface IAd {
/** Embedded ad data in Card format (almost like OEmbed). */
card: CardEntity,
/** Impression URL to fetch upon display. */
impression?: string,
/** Time when the ad expires and should no longer be displayed. */
expires?: Date,
ad: AdEntity,
}
/** Displays an ad in sponsored post format. */
const Ad: React.FC<IAd> = ({ card, impression, expires }) => {
const Ad: React.FC<IAd> = ({ ad }) => {
const queryClient = useQueryClient();
const instance = useAppSelector(state => state.instance);
@ -27,6 +22,14 @@ const Ad: React.FC<IAd> = ({ card, impression, expires }) => {
const infobox = useRef<HTMLDivElement>(null);
const [showInfo, setShowInfo] = useState(false);
// Fetch the impression URL (if any) upon displaying the ad.
// Don't fetch it more than once.
useQuery(['ads', 'impression', ad.impression], () => {
if (ad.impression) {
return fetch(ad.impression);
}
}, { cacheTime: Infinity, staleTime: Infinity });
/** Invalidate query cache for ads. */
const bustCache = (): void => {
queryClient.invalidateQueries(['ads']);
@ -53,18 +56,10 @@ const Ad: React.FC<IAd> = ({ card, impression, expires }) => {
};
}, [infobox]);
// Fetch the impression URL (if any) upon displaying the ad.
// It's common for ad providers to provide this.
useEffect(() => {
if (impression) {
fetch(impression);
}
}, [impression]);
// Wait until the ad expires, then invalidate cache.
useEffect(() => {
if (expires) {
const delta = expires.getTime() - (new Date()).getTime();
if (ad.expires_at) {
const delta = new Date(ad.expires_at).getTime() - (new Date()).getTime();
timer.current = setTimeout(bustCache, delta);
}
@ -73,7 +68,7 @@ const Ad: React.FC<IAd> = ({ card, impression, expires }) => {
clearTimeout(timer.current);
}
};
}, [expires]);
}, [ad.expires_at]);
return (
<div className='relative'>
@ -112,7 +107,7 @@ const Ad: React.FC<IAd> = ({ card, impression, expires }) => {
</Stack>
</HStack>
<StatusCard card={card} onOpenMedia={() => {}} horizontal />
<StatusCard card={ad.card} onOpenMedia={() => {}} horizontal />
</Stack>
</Card>
@ -125,11 +120,15 @@ const Ad: React.FC<IAd> = ({ card, impression, expires }) => {
</Text>
<Text size='sm' theme='muted'>
<FormattedMessage
id='sponsored.info.message'
defaultMessage='{siteTitle} displays ads to help fund our service.'
values={{ siteTitle: instance.title }}
/>
{ad.reason ? (
ad.reason
) : (
<FormattedMessage
id='sponsored.info.message'
defaultMessage='{siteTitle} displays ads to help fund our service.'
values={{ siteTitle: instance.title }}
/>
)}
</Text>
</Stack>
</Card>

@ -7,6 +7,7 @@ import type { Card } from 'soapbox/types/entities';
const PROVIDERS: Record<string, () => Promise<AdProvider>> = {
soapbox: async() => (await import(/* webpackChunkName: "features/ads/soapbox" */'./soapbox-config')).default,
rumble: async() => (await import(/* webpackChunkName: "features/ads/rumble" */'./rumble')).default,
truth: async() => (await import(/* webpackChunkName: "features/ads/truth" */'./truth')).default,
};
/** Ad server implementation. */
@ -21,7 +22,9 @@ interface Ad {
/** Impression URL to fetch when displaying the ad. */
impression?: string,
/** Time when the ad expires and should no longer be displayed. */
expires?: Date,
expires_at?: string,
/** Reason the ad is displayed. */
reason?: string,
}
/** Gets the current provider based on config. */

@ -1,6 +1,6 @@
import { getSettings } from 'soapbox/actions/settings';
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import { normalizeCard } from 'soapbox/normalizers';
import { normalizeAd, normalizeCard } from 'soapbox/normalizers';
import type { AdProvider } from '.';
@ -36,14 +36,14 @@ const RumbleAdProvider: AdProvider = {
if (response.ok) {
const data = await response.json() as RumbleApiResponse;
return data.ads.map(item => ({
return data.ads.map(item => normalizeAd({
impression: item.impression,
card: normalizeCard({
type: item.type === 1 ? 'link' : 'rich',
image: item.asset,
url: item.click,
}),
expires: new Date(item.expires * 1000),
expires_at: new Date(item.expires * 1000),
}));
}
}

@ -0,0 +1,39 @@
import { getSettings } from 'soapbox/actions/settings';
import { normalizeCard } from 'soapbox/normalizers';
import type { AdProvider } from '.';
import type { Card } from 'soapbox/types/entities';
/** TruthSocial ad API entity. */
interface TruthAd {
impression: string,
card: Card,
expires_at: string,
reason: string,
}
/** Provides ads from the TruthSocial API. */
const TruthAdProvider: AdProvider = {
getAds: async(getState) => {
const state = getState();
const settings = getSettings(state);
const response = await fetch('/api/v2/truth/ads?device=desktop', {
headers: {
'Accept-Language': settings.get('locale', '*') as string,
},
});
if (response.ok) {
const data = await response.json() as TruthAd[];
return data.map(item => ({
...item,
card: normalizeCard(item.card),
}));
}
return [];
},
};
export default TruthAdProvider;

@ -1,535 +0,0 @@
import classNames from 'clsx';
import debounce from 'lodash/debounce';
import throttle from 'lodash/throttle';
import PropTypes from 'prop-types';
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import Icon from 'soapbox/components/icon';
import { formatTime, getPointerPosition, fileNameFromURL } from 'soapbox/features/video';
import Visualizer from './visualizer';
const messages = defineMessages({
play: { id: 'video.play', defaultMessage: 'Play' },
pause: { id: 'video.pause', defaultMessage: 'Pause' },
mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
download: { id: 'video.download', defaultMessage: 'Download file' },
});
const TICK_SIZE = 10;
const PADDING = 180;
export default @injectIntl
class Audio extends React.PureComponent {
static propTypes = {
src: PropTypes.string.isRequired,
alt: PropTypes.string,
poster: PropTypes.string,
duration: PropTypes.number,
width: PropTypes.number,
height: PropTypes.number,
editable: PropTypes.bool,
fullscreen: PropTypes.bool,
intl: PropTypes.object.isRequired,
cacheWidth: PropTypes.func,
backgroundColor: PropTypes.string,
foregroundColor: PropTypes.string,
accentColor: PropTypes.string,
currentTime: PropTypes.number,
autoPlay: PropTypes.bool,
volume: PropTypes.number,
muted: PropTypes.bool,
deployPictureInPicture: PropTypes.func,
};
state = {
width: this.props.width,
currentTime: 0,
buffer: 0,
duration: null,
paused: true,
muted: false,
volume: 0.5,
dragging: false,
};
constructor(props) {
super(props);
this.visualizer = new Visualizer(TICK_SIZE);
}
setPlayerRef = c => {
this.player = c;
if (this.player) {
this._setDimensions();
}
}
_pack() {
return {
src: this.props.src,
volume: this.audio.volume,
muted: this.audio.muted,
currentTime: this.audio.currentTime,
poster: this.props.poster,
backgroundColor: this.props.backgroundColor,
foregroundColor: this.props.foregroundColor,
accentColor: this.props.accentColor,
};
}
_setDimensions() {
const width = this.player.offsetWidth;
const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16 / 9));
if (this.props.cacheWidth) {
this.props.cacheWidth(width);
}
this.setState({ width, height });
}
setSeekRef = c => {
this.seek = c;
}
setVolumeRef = c => {
this.volume = c;
}
setAudioRef = c => {
this.audio = c;
if (this.audio) {
this.setState({ volume: this.audio.volume, muted: this.audio.muted });
}
}
setCanvasRef = c => {
this.canvas = c;
this.visualizer.setCanvas(c);
}
componentDidMount() {
window.addEventListener('scroll', this.handleScroll);
window.addEventListener('resize', this.handleResize, { passive: true });
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.src !== this.props.src || this.state.width !== prevState.width || this.state.height !== prevState.height || prevProps.accentColor !== this.props.accentColor) {
this._clear();
this._draw();
}
}
componentWillUnmount() {
window.removeEventListener('scroll', this.handleScroll);
window.removeEventListener('resize', this.handleResize);
if (!this.state.paused && this.audio && this.props.deployPictureInPicture) {
this.props.deployPictureInPicture('audio', this._pack());
}
}
togglePlay = () => {
if (!this.audioContext) {
this._initAudioContext();
}
if (this.state.paused) {
this.setState({ paused: false }, () => this.audio.play());
} else {
this.setState({ paused: true }, () => this.audio.pause());
}
}
handleResize = debounce(() => {
if (this.player) {
this._setDimensions();
}
}, 250, {
trailing: true,
});
handlePlay = () => {
this.setState({ paused: false });
if (this.audioContext && this.audioContext.state === 'suspended') {
this.audioContext.resume();
}
this._renderCanvas();
}
handlePause = () => {
this.setState({ paused: true });
if (this.audioContext) {
this.audioContext.suspend();
}
}
handleProgress = () => {
const lastTimeRange = this.audio.buffered.length - 1;
if (lastTimeRange > -1) {
this.setState({ buffer: Math.ceil(this.audio.buffered.end(lastTimeRange) / this.audio.duration * 100) });
}
}
toggleMute = () => {
const muted = !this.state.muted;
this.setState({ muted }, () => {
this.audio.muted = muted;
});
}
handleVolumeMouseDown = e => {
document.addEventListener('mousemove', this.handleMouseVolSlide, true);
document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
document.addEventListener('touchmove', this.handleMouseVolSlide, true);
document.addEventListener('touchend', this.handleVolumeMouseUp, true);
this.handleMouseVolSlide(e);
e.preventDefault();
e.stopPropagation();
}
handleVolumeMouseUp = () => {
document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
}
handleMouseDown = e => {
document.addEventListener('mousemove', this.handleMouseMove, true);
document.addEventListener('mouseup', this.handleMouseUp, true);
document.addEventListener('touchmove', this.handleMouseMove, true);
document.addEventListener('touchend', this.handleMouseUp, true);
this.setState({ dragging: true });
this.audio.pause();
this.handleMouseMove(e);
e.preventDefault();
e.stopPropagation();
}
handleMouseUp = () => {
document.removeEventListener('mousemove', this.handleMouseMove, true);
document.removeEventListener('mouseup', this.handleMouseUp, true);
document.removeEventListener('touchmove', this.handleMouseMove, true);
document.removeEventListener('touchend', this.handleMouseUp, true);
this.setState({ dragging: false });
this.audio.play();
}
handleMouseMove = throttle(e => {
const { x } = getPointerPosition(this.seek, e);
const currentTime = this.audio.duration * x;
if (!isNaN(currentTime)) {
this.setState({ currentTime }, () => {
this.audio.currentTime = currentTime;
});
}
}, 15);
handleTimeUpdate = () => {
this.setState({
currentTime: this.audio.currentTime,
duration: this.audio.duration,
});
}
handleMouseVolSlide = throttle(e => {
const { x } = getPointerPosition(this.volume, e);
if (!isNaN(x)) {
this.setState({ volume: x }, () => {
this.audio.volume = x;
});
}
}, 15);
handleScroll = throttle(() => {
if (!this.canvas || !this.audio) {
return;
}
const { top, height } = this.canvas.getBoundingClientRect();
const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
if (!this.state.paused && !inView) {
this.audio.pause();
if (this.props.deployPictureInPicture) {
this.props.deployPictureInPicture('audio', this._pack());
}
this.setState({ paused: true });
}
}, 150, { trailing: true });
handleMouseEnter = () => {
this.setState({ hovered: true });
}
handleMouseLeave = () => {
this.setState({ hovered: false });
}
handleLoadedData = () => {
const { autoPlay, currentTime, volume, muted } = this.props;
this.setState({ duration: this.audio.duration });
if (currentTime) {
this.audio.currentTime = currentTime;
}
if (volume !== undefined) {
this.audio.volume = volume;
}
if (muted !== undefined) {
this.audio.muted = muted;
}
if (autoPlay) {
this.togglePlay();
}
}
_initAudioContext() {
// eslint-disable-next-line compat/compat
const AudioContext = window.AudioContext || window.webkitAudioContext;
const context = new AudioContext();
const source = context.createMediaElementSource(this.audio);
this.visualizer.setAudioContext(context, source);
source.connect(context.destination);
this.audioContext = context;
}
handleDownload = () => {
fetch(this.props.src).then(res => res.blob()).then(blob => {
const element = document.createElement('a');
const objectURL = URL.createObjectURL(blob);
element.setAttribute('href', objectURL);
element.setAttribute('download', fileNameFromURL(this.props.src));
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
URL.revokeObjectURL(objectURL);
}).catch(err => {
console.error(err);
});
}
_renderCanvas() {
requestAnimationFrame(() => {
if (!this.audio) return;
this.handleTimeUpdate();
this._clear();
this._draw();
if (!this.state.paused) {
this._renderCanvas();
}
});
}
_clear() {
this.visualizer.clear(this.state.width, this.state.height);
}
_draw() {
this.visualizer.draw(this._getCX(), this._getCY(), this._getAccentColor(), this._getRadius(), this._getScaleCoefficient());
}
_getRadius() {
return parseInt(((this.state.height || this.props.height) - (PADDING * this._getScaleCoefficient()) * 2) / 2);
}
_getScaleCoefficient() {
return (this.state.height || this.props.height) / 982;
}
_getCX() {
return Math.floor(this.state.width / 2) || null;
}
_getCY() {
return Math.floor(this._getRadius() + (PADDING * this._getScaleCoefficient())) || null;
}
_getAccentColor() {
return this.props.accentColor || '#ffffff';
}
_getBackgroundColor() {
return this.props.backgroundColor || '#000000';
}
_getForegroundColor() {
return this.props.foregroundColor || '#ffffff';
}
seekBy(time) {
const currentTime = this.audio.currentTime + time;
if (!isNaN(currentTime)) {
this.setState({ currentTime }, () => {
this.audio.currentTime = currentTime;
});
}
}
handleAudioKeyDown = e => {
// On the audio element or the seek bar, we can safely use the space bar
// for playback control because there are no buttons to press
if (e.key === ' ') {
e.preventDefault();
e.stopPropagation();
this.togglePlay();
}
}
handleKeyDown = e => {
switch (e.key) {
case 'k':
e.preventDefault();
e.stopPropagation();
this.togglePlay();
break;
case 'm':
e.preventDefault();
e.stopPropagation();
this.toggleMute();
break;
case 'j':
e.preventDefault();
e.stopPropagation();
this.seekBy(-10);
break;
case 'l':
e.preventDefault();
e.stopPropagation();
this.seekBy(10);
break;
}
}
render() {
const { src, intl, alt, editable } = this.props;
const { paused, muted, volume, currentTime, buffer, dragging } = this.state;
const duration = this.state.duration || this.props.duration;
const progress = Math.min((currentTime / duration) * 100, 100);
return (
<div className={classNames('audio-player', { editable })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex='0' onKeyDown={this.handleKeyDown}>
<audio
src={src}
ref={this.setAudioRef}
preload='auto'
onPlay={this.handlePlay}
onPause={this.handlePause}
onProgress={this.handleProgress}
onLoadedData={this.handleLoadedData}
crossOrigin='anonymous'
/>
<canvas
role='button'
tabIndex='0'
className='audio-player__canvas'
width={this.state.width}
height={this.state.height}
style={{ width: '100%', position: 'absolute', top: 0, left: 0 }}
ref={this.setCanvasRef}
onClick={this.togglePlay}
onKeyDown={this.handleAudioKeyDown}
title={alt}
aria-label={alt}
/>
{this.props.poster && <img
src={this.props.poster}
alt=''
width={(this._getRadius() - TICK_SIZE) * 2 || null}
height={(this._getRadius() - TICK_SIZE) * 2 || null}
style={{ position: 'absolute', left: this._getCX(), top: this._getCY(), transform: 'translate(-50%, -50%)', borderRadius: '50%', pointerEvents: 'none' }}
/>}
<div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
<div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
<div className='video-player__seek__progress' style={{ width: `${progress}%`, backgroundColor: this._getAccentColor() }} />
<span
className={classNames('video-player__seek__handle', { active: dragging })}
tabIndex='0'
style={{ left: `${progress}%`, backgroundColor: this._getAccentColor() }}
onKeyDown={this.handleAudioKeyDown}
/>
</div>
<div className='video-player__controls active'>
<div className='video-player__buttons-bar'>
<div className='video-player__buttons left'>
<button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} className='player-button' onClick={this.togglePlay}><Icon src={paused ? require('@tabler/icons/player-play.svg') : require('@tabler/icons/player-pause.svg')} /></button>
<button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} className='player-button' onClick={this.toggleMute}><Icon src={muted ? require('@tabler/icons/volume-3.svg') : require('@tabler/icons/volume.svg')} /></button>
<div className={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}>
<div className='video-player__volume__current' style={{ width: `${volume * 100}%`, backgroundColor: this._getAccentColor() }} />
<span
className='video-player__volume__handle'
tabIndex='0'
style={{ left: `${volume * 100}%`, backgroundColor: this._getAccentColor() }}
/>
</div>
<span className='video-player__time'>
<span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
{duration && (<>
<span className='video-player__time-sep'>/</span>
<span className='video-player__time-total'>{formatTime(Math.floor(duration))}</span>
</>)}
</span>
</div>
<div className='video-player__buttons right'>
<a
title={intl.formatMessage(messages.download)}
aria-label={intl.formatMessage(messages.download)}
className='video-player__download__icon player-button'
href={this.props.src}
download
target='_blank'
>
<Icon src={require('@tabler/icons/download.svg')} />
</a>
</div>
</div>
</div>
</div>
);
}
}

@ -0,0 +1,583 @@
import classNames from 'clsx';
import debounce from 'lodash/debounce';
import throttle from 'lodash/throttle';
import React, { useEffect, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Icon from 'soapbox/components/icon';
import { formatTime, getPointerPosition } from 'soapbox/features/video';
import Visualizer from './visualizer';
const messages = defineMessages({
play: { id: 'video.play', defaultMessage: 'Play' },
pause: { id: 'video.pause', defaultMessage: 'Pause' },
mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
download: { id: 'video.download', defaultMessage: 'Download file' },
});
const TICK_SIZE = 10;
const PADDING = 180;
interface IAudio {
src: string,
alt?: string,
poster?: string,
duration?: number,
width?: number,
height?: number,
editable?: boolean,
fullscreen?: boolean,
cacheWidth?: (width: number) => void,
backgroundColor?: string,
foregroundColor?: string,
accentColor?: string,
currentTime?: number,
autoPlay?: boolean,
volume?: number,
muted?: boolean,
deployPictureInPicture?: (type: string, opts: Record<string, any>) => void,
}
const Audio: React.FC<IAudio> = (props) => {
const {
src,
alt = '',
poster,
accentColor,
backgroundColor,
foregroundColor,
cacheWidth,
fullscreen,
autoPlay,
editable,
deployPictureInPicture = false,
} = props;
const intl = useIntl();
const [width, setWidth] = useState<number | undefined>(props.width);
const [height, setHeight] = useState<number | undefined>(props.height);
const [currentTime, setCurrentTime] = useState(0);
const [buffer, setBuffer] = useState(0);
const [duration, setDuration] = useState<number | undefined>(undefined);
const [paused, setPaused] = useState(true);
const [muted, setMuted] = useState(false);
const [volume, setVolume] = useState(0.5);
const [dragging, setDragging] = useState(false);
const [hovered, setHovered] = useState(false);
const visualizer = useRef<Visualizer>(new Visualizer(TICK_SIZE));
const audioContext = useRef<AudioContext | null>(null);
const player = useRef<HTMLDivElement>(null);
const audio = useRef<HTMLAudioElement>(null);
const seek = useRef<HTMLDivElement>(null);
const slider = useRef<HTMLDivElement>(null);
const canvas = useRef<HTMLCanvasElement>(null);
const _pack = () => ({
src: props.src,
volume: audio.current?.volume,
muted: audio.current?.muted,
currentTime: audio.current?.currentTime,
poster: props.poster,
backgroundColor: props.backgroundColor,
foregroundColor: props.foregroundColor,
accentColor: props.accentColor,
});
const _setDimensions = () => {
if (player.current) {
const width = player.current.offsetWidth;
const height = fullscreen ? player.current.offsetHeight : (width / (16 / 9));
if (cacheWidth) {
cacheWidth(width);
}
setWidth(width);
setHeight(height);
}
};
const togglePlay = () => {
if (!audioContext.current) {
_initAudioContext();
}
if (paused) {
audio.current?.play();
} else {
audio.current?.pause();
}
setPaused(!paused);
};
const handleResize = debounce(() => {
if (player.current) {
_setDimensions();
}
}, 250, {
trailing: true,
});
const handlePlay = () => {
setPaused(false);
if (audioContext.current?.state === 'suspended') {
audioContext.current?.resume();
}
_renderCanvas();
};
const handlePause = () => {
setPaused(true);
audioContext.current?.suspend();
};
const handleProgress = () => {
if (audio.current) {
const lastTimeRange = audio.current.buffered.length - 1;
if (lastTimeRange > -1) {
setBuffer(Math.ceil(audio.current.buffered.end(lastTimeRange) / audio.current.duration * 100));
}
}
};
const toggleMute = () => {
const nextMuted = !muted;
setMuted(nextMuted);
if (audio.current) {
audio.current.muted = nextMuted;
}
};
const handleVolumeMouseDown: React.MouseEventHandler = e => {
document.addEventListener('mousemove', handleMouseVolSlide, true);
document.addEventListener('mouseup', handleVolumeMouseUp, true);
document.addEventListener('touchmove', handleMouseVolSlide, true);
document.addEventListener('touchend', handleVolumeMouseUp, true);
handleMouseVolSlide(e);
e.preventDefault();
e.stopPropagation();
};
const handleVolumeMouseUp = () => {
document.removeEventListener('mousemove', handleMouseVolSlide, true);
document.removeEventListener('mouseup', handleVolumeMouseUp, true);
document.removeEventListener('touchmove', handleMouseVolSlide, true);
document.removeEventListener('touchend', handleVolumeMouseUp, true);
};
const handleMouseDown: React.MouseEventHandler = e => {
document.addEventListener('mousemove', handleMouseMove, true);
document.addEventListener('mouseup', handleMouseUp, true);
document.addEventListener('touchmove', handleMouseMove, true);
document.addEventListener('touchend', handleMouseUp, true);
setDragging(true);
audio.current?.pause();
handleMouseMove(e);
e.preventDefault();
e.stopPropagation();
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove, true);
document.removeEventListener('mouseup', handleMouseUp, true);
document.removeEventListener('touchmove', handleMouseMove, true);
document.removeEventListener('touchend', handleMouseUp, true);
setDragging(false);
audio.current?.play();
};
const handleMouseMove = throttle((e) => {
if (audio.current && seek.current) {
const { x } = getPointerPosition(seek.current, e);
const currentTime = audio.current.duration * x;
if (!isNaN(currentTime)) {
setCurrentTime(currentTime);
audio.current.currentTime = currentTime;
}
}
}, 15);
const handleTimeUpdate = () => {
if (audio.current) {
setCurrentTime(audio.current.currentTime);
setDuration(audio.current.duration);
}
};
const handleMouseVolSlide = throttle(e => {
if (audio.current && slider.current) {
const { x } = getPointerPosition(slider.current, e);
if (!isNaN(x)) {
setVolume(x);
audio.current.volume = x;
}
}
}, 15);
const handleScroll = throttle(() => {
if (!canvas.current || !audio.current) {
return;
}
const { top, height } = canvas.current.getBoundingClientRect();
const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
if (!paused && !inView) {
audio.current.pause();
if (deployPictureInPicture) {
deployPictureInPicture('audio', _pack());
}
setPaused(true);
}
}, 150, { trailing: true });
const handleMouseEnter = () => {
setHovered(true);
};
const handleMouseLeave = () => {
setHovered(false);
};
const handleLoadedData = () => {
if (audio.current) {
setDuration(audio.current.duration);
if (currentTime) {
audio.current.currentTime = currentTime;
}
if (volume !== undefined) {
audio.current.volume = volume;
}
if (muted !== undefined) {
audio.current.muted = muted;
}
if (autoPlay) {
togglePlay();
}
}
};
const _initAudioContext = () => {
if (audio.current) {
// @ts-ignore
// eslint-disable-next-line compat/compat
const AudioContext = window.AudioContext || window.webkitAudioContext;
const context = new AudioContext();
const source = context.createMediaElementSource(audio.current);
visualizer.current.setAudioContext(context, source);
source.connect(context.destination);
audioContext.current = context;
}
};
const _renderCanvas = () => {
requestAnimationFrame(() => {
if (!audio.current) return;
handleTimeUpdate();
_clear();
_draw();
if (!paused) {
_renderCanvas();
}
});
};
const _clear = () => {
visualizer.current?.clear(width || 0, height || 0);
};
const _draw = () => {
visualizer.current?.draw(_getCX(), _getCY(), _getAccentColor(), _getRadius(), _getScaleCoefficient());
};
const _getRadius = (): number => {
return ((height || props.height || 0) - (PADDING * _getScaleCoefficient()) * 2) / 2;
};
const _getScaleCoefficient = (): number => {
return (height || props.height || 0) / 982;
};
const _getCX = (): number => {
return Math.floor((width || 0) / 2);
};
const _getCY = (): number => {
return Math.floor(_getRadius() + (PADDING * _getScaleCoefficient()));
};
const _getAccentColor = (): string => {
return accentColor || '#ffffff';
};
const _getBackgroundColor = (): string => {
return backgroundColor || '#000000';
};
const _getForegroundColor = (): string => {
return foregroundColor || '#ffffff';
};
const seekBy = (time: number) => {
if (audio.current) {
const currentTime = audio.current.currentTime + time;
if (!isNaN(currentTime)) {
setCurrentTime(currentTime);
audio.current.currentTime = currentTime;
}
}
};
const handleAudioKeyDown: React.KeyboardEventHandler = e => {
// On the audio element or the seek bar, we can safely use the space bar
// for playback control because there are no buttons to press
if (e.key === ' ') {
e.preventDefault();
e.stopPropagation();
togglePlay();
}
};
const handleKeyDown: React.KeyboardEventHandler = e => {
switch (e.key) {
case 'k':
e.preventDefault();
e.stopPropagation();
togglePlay();
break;
case 'm':
e.preventDefault();
e.stopPropagation();
toggleMute();
break;
case 'j':
e.preventDefault();
e.stopPropagation();
seekBy(-10);
break;
case 'l':
e.preventDefault();
e.stopPropagation();
seekBy(10);
break;
}
};
const getDuration = () => duration || props.duration || 0;
const progress = Math.min((currentTime / getDuration()) * 100, 100);
useEffect(() => {
if (player.current) {
_setDimensions();
}
}, [player.current]);
useEffect(() => {
if (audio.current) {
setVolume(audio.current.volume);
setMuted(audio.current.muted);
}
}, [audio.current]);
useEffect(() => {
if (canvas.current && visualizer.current) {
visualizer.current.setCanvas(canvas.current);
}
}, [canvas.current, visualizer.current]);
useEffect(() => {
window.addEventListener('scroll', handleScroll);
window.addEventListener('resize', handleResize, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleResize);
if (!paused && audio.current && deployPictureInPicture) {
deployPictureInPicture('audio', _pack());
}
};
}, []);
useEffect(() => {
_clear();
_draw();
}, [src, width, height, accentColor]);
return (
<div
className={classNames('audio-player', { editable })}
ref={player}
style={{
backgroundColor: _getBackgroundColor(),
color: _getForegroundColor(),
width: '100%',
height: fullscreen ? '100%' : (height || props.height),
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
tabIndex={0}
onKeyDown={handleKeyDown}
>
<audio
src={src}
ref={audio}
preload='auto'
onPlay={handlePlay}
onPause={handlePause}
onProgress={handleProgress}
onLoadedData={handleLoadedData}
crossOrigin='anonymous'
/>
<canvas
role='button'
tabIndex={0}
className='audio-player__canvas'
width={width}
height={height}
style={{ width: '100%', position: 'absolute', top: 0, left: 0 }}
ref={canvas}
onClick={togglePlay}
onKeyDown={handleAudioKeyDown}
title={alt}
aria-label={alt}
/>
{poster && (
<img
src={poster}
alt=''
width={(_getRadius() - TICK_SIZE) * 2}
height={(_getRadius() - TICK_SIZE) * 2}
style={{
position: 'absolute',
left: _getCX(),
top: _getCY(),
transform: 'translate(-50%, -50%)',
borderRadius: '50%',
pointerEvents: 'none',
}}
/>
)}
<div className='video-player__seek' onMouseDown={handleMouseDown} ref={seek}>
<div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
<div
className='video-player__seek__progress'
style={{ width: `${progress}%`, backgroundColor: _getAccentColor() }}
/>
<span
className={classNames('video-player__seek__handle', { active: dragging })}
tabIndex={0}
style={{ left: `${progress}%`, backgroundColor: _getAccentColor() }}
onKeyDown={handleAudioKeyDown}
/>
</div>
<div className='video-player__controls active'>
<div className='video-player__buttons-bar'>
<div className='video-player__buttons left'>
<button
type='button'
title={intl.formatMessage(paused ? messages.play : messages.pause)}
aria-label={intl.formatMessage(paused ? messages.play : messages.pause)}
className='player-button'
onClick={togglePlay}
>
<Icon src={paused ? require('@tabler/icons/player-play.svg') : require('@tabler/icons/player-pause.svg')} />
</button>
<button
type='button'
title={intl.formatMessage(muted ? messages.unmute : messages.mute)}
aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)}
className='player-button'
onClick={toggleMute}
>
<Icon src={muted ? require('@tabler/icons/volume-3.svg') : require('@tabler/icons/volume.svg')} />
</button>
<div
className={classNames('video-player__volume', { active: hovered })}
ref={slider}
onMouseDown={handleVolumeMouseDown}
>
<div
className='video-player__volume__current'
style={{
width: `${volume * 100}%`,
backgroundColor: _getAccentColor(),
}}
/>
<span
className='video-player__volume__handle'
tabIndex={0}
style={{ left: `${volume * 100}%`, backgroundColor: _getAccentColor() }}
/>
</div>
<span className='video-player__time'>
<span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
{getDuration() && (<>
<span className='video-player__time-sep'>/</span>
<span className='video-player__time-total'>{formatTime(Math.floor(getDuration()))}</span>
</>)}
</span>
</div>
<div className='video-player__buttons right'>
<a
title={intl.formatMessage(messages.download)}
aria-label={intl.formatMessage(messages.download)}
className='video-player__download__icon player-button'
href={src}
download
target='_blank'
>
<Icon src={require('@tabler/icons/download.svg')} />
</a>
</div>
</div>
</div>
</div>
);
};
export default Audio;

@ -31,11 +31,11 @@ const AuthToken: React.FC<IAuthToken> = ({ token }) => {
<Text size='sm' theme='muted'>
<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());

@ -15,6 +15,7 @@ import {
submitSearch,
} from 'soapbox/actions/search';
import AutosuggestAccountInput from 'soapbox/components/autosuggest_account_input';
import { Input } from 'soapbox/components/ui';
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
import { useAppSelector } from 'soapbox/hooks';
@ -117,7 +118,6 @@ const Search = (props: ISearch) => {
const hasValue = value.length > 0 || submitted;
const componentProps: any = {
className: 'block w-full pl-3 pr-10 py-2 border border-gray-200 dark:border-gray-800 rounded-full leading-5 bg-gray-200 dark:bg-gray-800 dark:text-white placeholder:text-gray-600 dark:placeholder:text-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500 sm:text-sm',
type: 'text',
id: 'search',
placeholder: intl.formatMessage(messages.placeholder),
@ -126,6 +126,7 @@ const Search = (props: ISearch) => {
onKeyDown: handleKeyDown,
onFocus: handleFocus,
autoFocus: autoFocus,
theme: 'search',
};
if (autosuggest) {
@ -142,7 +143,7 @@ const Search = (props: ISearch) => {
{autosuggest ? (
<AutosuggestAccountInput {...componentProps} />
) : (
<input {...componentProps} />
<Input {...componentProps} />
)}
<div

@ -0,0 +1,115 @@
import React, { useEffect, useRef } from 'react';
import { FormattedMessage } from 'react-intl';
import { connectHashtagStream } from 'soapbox/actions/streaming';
import { expandHashtagTimeline, clearTimeline } from 'soapbox/actions/timelines';
import ColumnHeader from 'soapbox/components/column_header';
import { Column } from 'soapbox/components/ui';
import Timeline from 'soapbox/features/ui/components/timeline';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import type { Tag as TagEntity } from 'soapbox/types/entities';
type Mode = 'any' | 'all' | 'none';
type Tag = { value: string };
type Tags = { [k in Mode]: Tag[] };
interface IHashtagTimeline {
params?: {
id?: string,
tags?: Tags,
},
}
export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
const id = params?.id || '';
const tags = params?.tags || { any: [], all: [], none: [] };
const dispatch = useAppDispatch();
const hasUnread = useAppSelector<boolean>(state => (state.timelines.getIn([`hashtag:${id}`, 'unread']) as number) > 0);
const disconnects = useRef<(() => void)[]>([]);
// Mastodon supports displaying results from multiple hashtags.
// https://github.com/mastodon/mastodon/issues/6359
const title = () => {
const title: React.ReactNode[] = [`#${id}`];
if (additionalFor('any')) {
title.push(' ', <FormattedMessage key='any' id='hashtag.column_header.tag_mode.any' values={{ additional: additionalFor('any') }} defaultMessage='or {additional}' />);
}
if (additionalFor('all')) {
title.push(' ', <FormattedMessage key='all' id='hashtag.column_header.tag_mode.all' values={{ additional: additionalFor('all') }} defaultMessage='and {additional}' />);
}
if (additionalFor('none')) {
title.push(' ', <FormattedMessage key='none' id='hashtag.column_header.tag_mode.none' values={{ additional: additionalFor('none') }} defaultMessage='without {additional}' />);
}
return title;
};
const additionalFor = (mode: Mode) => {
if (tags && (tags[mode] || []).length > 0) {
return tags[mode].map(tag => tag.value).join('/');
} else {
return '';
}
};
const subscribe = () => {
const any = tags.any.map(tag => tag.value);
const all = tags.all.map(tag => tag.value);
const none = tags.none.map(tag => tag.value);
[id, ...any].map(tag => {
disconnects.current.push(dispatch(connectHashtagStream(id, tag, status => {
const tags = status.tags.map((tag: TagEntity) => tag.name);
return all.filter(tag => tags.includes(tag)).length === all.length &&
none.filter(tag => tags.includes(tag)).length === 0;
})));
});
};
const unsubscribe = () => {
disconnects.current.map(disconnect => disconnect());
disconnects.current = [];
};
const handleLoadMore = (maxId: string) => {
dispatch(expandHashtagTimeline(id, { maxId, tags }));
};
useEffect(() => {
subscribe();
dispatch(expandHashtagTimeline(id, { tags }));
return () => {
unsubscribe();
};
}, []);
useEffect(() => {
unsubscribe();
subscribe();
dispatch(clearTimeline(`hashtag:${id}`));
dispatch(expandHashtagTimeline(id, { tags }));
}, [id, tags]);
return (
<Column label={`#${id}`} transparent withHeader={false}>
<ColumnHeader active={hasUnread} title={title()} />
<Timeline
scrollKey='hashtag_timeline'
timelineId={`hashtag:${id}`}
onLoadMore={handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
divideType='space'
/>
</Column>
);
};
export default HashtagTimeline;

@ -1,128 +0,0 @@
import isEqual from 'lodash/isEqual';
import PropTypes from 'prop-types';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { connectHashtagStream } from '../../actions/streaming';
import { expandHashtagTimeline, clearTimeline } from '../../actions/timelines';
import ColumnHeader from '../../components/column_header';
import { Column } from '../../components/ui';
import Timeline from '../ui/components/timeline';
const mapStateToProps = (state, props) => ({
hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0,
});
export default @connect(mapStateToProps)
class HashtagTimeline extends React.PureComponent {
disconnects = [];
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
hasUnread: PropTypes.bool,
};
title = () => {
const title = [`#${this.props.params.id}`];
// TODO: wtf is all this?
// It exists in Mastodon's codebase, but undocumented
if (this.additionalFor('any')) {
title.push(' ', <FormattedMessage key='any' id='hashtag.column_header.tag_mode.any' values={{ additional: this.additionalFor('any') }} defaultMessage='or {additional}' />);
}
if (this.additionalFor('all')) {
title.push(' ', <FormattedMessage key='all' id='hashtag.column_header.tag_mode.all' values={{ additional: this.additionalFor('all') }} defaultMessage='and {additional}' />);
}
if (this.additionalFor('none')) {
title.push(' ', <FormattedMessage key='none' id='hashtag.column_header.tag_mode.none' values={{ additional: this.additionalFor('none') }} defaultMessage='without {additional}' />);
}
return title;
}
// TODO: wtf is this?
// It exists in Mastodon's codebase, but undocumented
additionalFor = (mode) => {
const { tags } = this.props.params;
if (tags && (tags[mode] || []).length > 0) {
return tags[mode].map(tag => tag.value).join('/');
} else {
return '';
}
}
_subscribe(dispatch, id, tags = {}) {
const any = (tags.any || []).map(tag => tag.value);
const all = (tags.all || []).map(tag => tag.value);
const none = (tags.none || []).map(tag => tag.value);
[id, ...any].map(tag => {
this.disconnects.push(dispatch(connectHashtagStream(id, tag, status => {
const tags = status.tags.map(tag => tag.name);
return all.filter(tag => tags.includes(tag)).length === all.length &&
none.filter(tag => tags.includes(tag)).length === 0;
})));
});
}
_unsubscribe() {
this.disconnects.map(disconnect => disconnect());
this.disconnects = [];
}
componentDidMount() {
const { dispatch } = this.props;
const { id, tags } = this.props.params;
this._subscribe(dispatch, id, tags);
dispatch(expandHashtagTimeline(id, { tags }));
}
componentDidUpdate(prevProps) {
const { dispatch } = this.props;
const { id, tags } = this.props.params;
const { id: prevId, tags: prevTags } = prevProps.params;
if (id !== prevId || !isEqual(tags, prevTags)) {
this._unsubscribe();
this._subscribe(dispatch, id, tags);
dispatch(clearTimeline(`hashtag:${id}`));
dispatch(expandHashtagTimeline(id, { tags }));
}
}
componentWillUnmount() {
this._unsubscribe();
}
handleLoadMore = maxId => {
const { id, tags } = this.props.params;
this.props.dispatch(expandHashtagTimeline(id, { maxId, tags }));
}
render() {
const { hasUnread } = this.props;
const { id } = this.props.params;
return (
<Column label={`#${id}`} transparent withHeader={false}>
<ColumnHeader active={hasUnread} title={this.title()} />
<Timeline
scrollKey='hashtag_timeline'
timelineId={`hashtag:${id}`}
onLoadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
divideType='space'
/>
</Column>
);
}
}

@ -1,108 +0,0 @@
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { fetchMobilePage } from 'soapbox/actions/mobile';
import { getSettings } from 'soapbox/actions/settings';
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import { languages } from '../preferences';
const mapStateToProps = state => ({
locale: getSettings(state).get('locale'),
mobilePages: getSoapboxConfig(state).get('mobilePages'),
});
@connect(mapStateToProps)
@injectIntl
class MobilePage extends ImmutablePureComponent {
state = {
pageHtml: '',
locale: this.props.locale,
}
loadPageHtml = () => {
const { dispatch, match, mobilePages } = this.props;
const { locale } = this.state;
const { slug } = match.params;
const page = mobilePages.get(slug || 'mobile');
const fetchLocale = page && locale !== page.get('default') && page.get('locales').includes(locale);
dispatch(fetchMobilePage(slug, fetchLocale && locale)).then(html => {
this.setState({ pageHtml: html });
}).catch(error => {
// TODO: Better error handling. 404 page?
this.setState({ pageHtml: '<h1>Page not found</h1>' });
});
}
setLocale = (locale) => () => {
this.setState({ locale });
};
componentDidMount() {
this.loadPageHtml();
}
componentDidUpdate(prevProps, prevState) {
const { locale, match, mobilePages } = this.props;
const { locale: prevLocale, mobilePages: prevMobilePages } = prevProps;
const { locale: stateLocale } = this.state;
const { locale: prevStateLocale } = prevState;
const { slug } = match.params;
const { slug: prevSlug } = prevProps.match.params;
if (locale !== prevLocale) this.setState({ locale });
if (
slug !== prevSlug ||
stateLocale !== prevStateLocale ||
(!prevMobilePages.get(slug || 'mobile') && mobilePages.get(slug || 'mobile'))
)
this.loadPageHtml();
}
render() {
const { match, mobilePages } = this.props;
const { slug } = match.params;
const page = mobilePages.get(slug || 'mobile');
const defaultLocale = page && page.get('default');
const alsoAvailable = page && (
<div className='rich-formatting also-available'>
<FormattedMessage id='mobile.also_available' defaultMessage='Available in:' />
{' '}
<ul>
<li>
<a href='#' onClick={this.setLocale(defaultLocale)}>
{languages[defaultLocale] || defaultLocale}
</a>
</li>
{
page.get('locales').map(locale => (
<li key={locale}>
<a href='#' onClick={this.setLocale(locale)}>
{languages[locale] || locale}
</a>
</li>
))
}
</ul>
</div>
);
return (
<div>
<div
dangerouslySetInnerHTML={{ __html: this.state.pageHtml }}
/>
{alsoAvailable}
</div>
);
}
}
export default MobilePage;

@ -0,0 +1,21 @@
import React from 'react';
import { Stack } from 'soapbox/components/ui';
import { randomIntFromInterval, generateText } from '../utils';
export default ({ limit }: { limit: number }) => {
const trend = randomIntFromInterval(6, 3);
const stat = randomIntFromInterval(10, 3);
return (
<>
{new Array(limit).fill(undefined).map((_, idx) => (
<Stack key={idx} className='animate-pulse text-primary-200 dark:text-primary-700'>
<p>{generateText(trend)}</p>
<p>{generateText(stat)}</p>
</Stack>
))}
</>
);
};

@ -7,7 +7,6 @@ import { isStandalone } from 'soapbox/utils/state';
import AboutPage from '../about';
import 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>

@ -1,3 +1,4 @@
import classNames from 'clsx';
import React, { useRef } from 'react';
import { FormattedDate, FormattedMessage, useIntl } from 'react-intl';
@ -5,7 +6,8 @@ import Icon from 'soapbox/components/icon';
import StatusMedia from 'soapbox/components/status-media';
import StatusReplyMentions from 'soapbox/components/status-reply-mentions';
import StatusContent from 'soapbox/components/status_content';
import { HStack, Text } from 'soapbox/components/ui';
import SensitiveContentOverlay from 'soapbox/components/statuses/sensitive-content-overlay';
import { HStack, Stack, Text } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container';
import { getActualStatus } from 'soapbox/utils/status';
@ -48,6 +50,9 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
const { account } = actualStatus;
if (!account || typeof account !== 'object') return null;
const isUnderReview = actualStatus.visibility === 'self';
const isSensitive = actualStatus.sensitive;
let statusTypeIcon = null;
let quote;
@ -85,19 +90,35 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
<StatusReplyMentions status={actualStatus} />
<StatusContent
status={actualStatus}
expanded={!actualStatus.hidden}
onExpandedToggle={handleExpandedToggle}
/>
<Stack
className={
classNames('relative', {
'min-h-[220px]': isUnderReview || isSensitive,
})
}
>
{(isUnderReview || isSensitive) ? (
<SensitiveContentOverlay
status={status}
visible={showMedia}
onToggleVisibility={onToggleMediaVisibility}
/>
) : null}
<StatusContent
status={actualStatus}
expanded={!actualStatus.hidden}
onExpandedToggle={handleExpandedToggle}
/>
<StatusMedia
status={actualStatus}
showMedia={showMedia}
onToggleVisibility={onToggleMediaVisibility}
/>
<StatusMedia
status={actualStatus}
showMedia={showMedia}
onToggleVisibility={onToggleMediaVisibility}
/>
{quote}
{quote}
</Stack>
<HStack justifyContent='between' alignItems='center' className='py-2' wrap>
<StatusInteractionBar status={actualStatus} />
@ -108,7 +129,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 +143,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>
</>

@ -29,7 +29,6 @@ import MissingIndicator from 'soapbox/components/missing_indicator';
import PullToRefresh from 'soapbox/components/pull-to-refresh';
import ScrollableList from 'soapbox/components/scrollable_list';
import StatusActionBar from 'soapbox/components/status-action-bar';
import ModerationOverlay from 'soapbox/components/statuses/moderation-overlay';
import SubNavigation from 'soapbox/components/sub_navigation';
import Tombstone from 'soapbox/components/tombstone';
import { Column, Stack } from 'soapbox/components/ui';
@ -135,7 +134,6 @@ const Thread: React.FC<IThread> = (props) => {
const me = useAppSelector(state => state.me);
const status = useAppSelector(state => getStatus(state, { id: props.params.statusId }));
const displayMedia = settings.get('displayMedia') as DisplayMedia;
const inReview = status?.visibility === 'self';
const { ancestorsIds, descendantsIds } = useAppSelector(state => {
let ancestorsIds = ImmutableOrderedSet<string>();
@ -156,7 +154,7 @@ const Thread: React.FC<IThread> = (props) => {
};
});
const [showMedia, setShowMedia] = useState<boolean>(defaultMediaVisibility(status, displayMedia));
const [showMedia, setShowMedia] = useState<boolean>(status?.visibility === 'self' ? false : defaultMediaVisibility(status, displayMedia));
const [isLoaded, setIsLoaded] = useState<boolean>(!!status);
const [next, setNext] = useState<string>();
@ -165,7 +163,7 @@ const Thread: React.FC<IThread> = (props) => {
const scroller = useRef<VirtuosoHandle>(null);
/** Fetch the status (and context) from the API. */
const fetchData = async() => {
const fetchData = async () => {
const { params } = props;
const { statusId } = params;
const { next } = await dispatch(fetchStatusWithContext(statusId));
@ -228,7 +226,7 @@ const Thread: React.FC<IThread> = (props) => {
};
const handleOpenMedia = (media: ImmutableList<AttachmentEntity>, index: number) => {
dispatch(openModal('MEDIA', { media, index }));
dispatch(openModal('MEDIA', { media, status, index }));
};
const handleOpenVideo = (media: ImmutableList<AttachmentEntity>, time: number) => {
@ -393,7 +391,7 @@ const Thread: React.FC<IThread> = (props) => {
// Reset media visibility if status changes.
useEffect(() => {
setShowMedia(defaultMediaVisibility(status, displayMedia));
setShowMedia(status?.visibility === 'self' ? false : defaultMediaVisibility(status, displayMedia));
}, [status?.id]);
// Scroll focused status into view when thread updates.
@ -467,18 +465,11 @@ const Thread: React.FC<IThread> = (props) => {
<HotKeys handlers={handlers}>
<div
ref={statusRef}
className={
classNames('detailed-status__wrapper focusable relative', {
'min-h-[220px]': inReview,
})
}
className='detailed-status__wrapper focusable relative'
tabIndex={0}
// FIXME: no "reblogged by" text is added for the screen reader
aria-label={textForScreenReader(intl, status)}
>
{inReview ? (
<ModerationOverlay />
) : null}
<DetailedStatus
status={status}

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

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

@ -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 = ({ 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 as Element);
const isWhitelisted = whitelist.some(w => (target as Element).classList.contains(w));
if (isClickOutside || isWhitelisted) {
onClose();
}
};
let pagination: React.ReactNode[] = [];
const leftNav = media.size > 1 && (
<button
tabIndex={0}
className='media-modal__nav media-modal__nav--left'
onClick={handlePrevClick}
aria-label={intl.formatMessage(messages.previous)}
>
<Icon src={require('@tabler/icons/arrow-left.svg')} />
</button>
);
const rightNav = media.size > 1 && (
<button
tabIndex={0}
className='media-modal__nav media-modal__nav--right'
onClick={handleNextClick}
aria-label={intl.formatMessage(messages.next)}
>
<Icon src={require('@tabler/icons/arrow-right.svg')} />
</button>
);
if (media.size > 1) {
pagination = media.toArray().map((item, i) => {
const classes = ['media-modal__button'];
if (i === getIndex()) {
classes.push('media-modal__button--active');
}
return (
<li className='media-modal__page-dot' key={i}>
<button
tabIndex={0}
className={classes.join(' ')}
onClick={handleChangeIndex}
data-index={i}
>
{i + 1}
</button>
</li>
);
});
}
const isMultiMedia = media.map((image) => {
if (image.type !== 'image') {
return true;
}
return false;
}).toArray();
const content = media.map(attachment => {
const width = (attachment.meta.getIn(['original', 'width']) || undefined) as number | undefined;
const height = (attachment.meta.getIn(['original', 'height']) || undefined) as number | undefined;
const link = (status && account && (
<a href={status.url} onClick={handleStatusClick}>
<FormattedMessage id='lightbox.view_context' defaultMessage='View context' />
</a>
));
if (attachment.type === 'image') {
return (
<ImageLoader
previewSrc={attachment.preview_url}
src={attachment.url}
width={width}
height={height}
alt={attachment.description}
key={attachment.url}
onClick={toggleNavigation}
/>
);
} else if (attachment.type === 'video') {
return (
<Video
preview={attachment.preview_url}
blurhash={attachment.blurhash}
src={attachment.url}
width={width}
height={height}
startTime={time}
onCloseVideo={onClose}
detailed
link={link}
alt={attachment.description}
key={attachment.url}
/>
);
} else if (attachment.type === 'audio') {
return (
<Audio
src={attachment.url}
alt={attachment.description}
poster={attachment.preview_url !== attachment.url ? attachment.preview_url : (status?.getIn(['account', 'avatar_static'])) as string | undefined}
backgroundColor={attachment.meta.getIn(['colors', 'background']) as string | undefined}
foregroundColor={attachment.meta.getIn(['colors', 'foreground']) as string | undefined}
accentColor={attachment.meta.getIn(['colors', 'accent']) as string | undefined}
duration={attachment.meta.getIn(['original', 'duration'], 0) as number | undefined}
key={attachment.url}
/>
);
} else if (attachment.type === 'gifv') {
return (
<ExtendedVideoPlayer
src={attachment.url}
muted
controls={false}
width={width}
link={link}
height={height}
key={attachment.preview_url}
alt={attachment.description}
onClick={toggleNavigation}
/>
);
}
return null;
}).toArray();
// you can't use 100vh, because the viewport height is taller
// than the visible part of the document in some mobile
// browsers when it's address bar is visible.
// https://developers.google.com/web/updates/2016/12/url-bar-resizing
const swipeableViewsStyle: React.CSSProperties = {
width: '100%',
height: '100%',
};
const containerStyle: React.CSSProperties = {
alignItems: 'center', // center vertically
};
const navigationClassName = classNames('media-modal__navigation', {
'media-modal__navigation--hidden': navigationHidden,
});
return (
<div className='modal-root__modal media-modal'>
<div
className='media-modal__closer'
role='presentation'
onClick={handleCloserClick}
>
<ReactSwipeableViews
style={swipeableViewsStyle}
containerStyle={containerStyle}
onChangeIndex={handleSwipe}
index={getIndex()}
>
{content}
</ReactSwipeableViews>
</div>
<div className={navigationClassName}>
<IconButton
className='media-modal__close'
title={intl.formatMessage(messages.close)}
src={require('@tabler/icons/x.svg')}
onClick={onClose}
/>
{leftNav}
{rightNav}
{(status && !isMultiMedia[getIndex()]) && (
<div className={classNames('media-modal__meta', { 'media-modal__meta--shifted': media.size > 1 })}>
<a href={status.url} onClick={handleStatusClick}>
<FormattedMessage id='lightbox.view_context' defaultMessage='View context' />
</a>
</div>
)}
<ul className='media-modal__pagination'>
{pagination}
</ul>
</div>
</div>
);
};
export default MediaModal;

@ -1,274 +0,0 @@
import classNames from 'clsx';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { withRouter } from 'react-router-dom';
import ReactSwipeableViews from 'react-swipeable-views';
import ExtendedVideoPlayer from 'soapbox/components/extended_video_player';
import Icon from 'soapbox/components/icon';
import IconButton from 'soapbox/components/icon_button';
import Audio from 'soapbox/features/audio';
import Video from 'soapbox/features/video';
import ImageLoader from './image_loader';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
next: { id: 'lightbox.next', defaultMessage: 'Next' },
});
export default @injectIntl @withRouter
class MediaModal extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.list.isRequired,
status: ImmutablePropTypes.record,
account: ImmutablePropTypes.record,
index: PropTypes.number.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
history: PropTypes.object,
};
state = {
index: null,
navigationHidden: false,
};
handleSwipe = (index) => {
this.setState({ index: index % this.props.media.size });
}
handleNextClick = () => {
this.setState({ index: (this.getIndex() + 1) % this.props.media.size });
}
handlePrevClick = () => {
this.setState({ index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size });
}
handleChangeIndex = (e) => {
const index = Number(e.currentTarget.getAttribute('data-index'));
this.setState({ index: index % this.props.media.size });
}
handleKeyDown = (e) => {
switch (e.key) {
case 'ArrowLeft':
this.handlePrevClick();
e.preventDefault();
e.stopPropagation();
break;
case 'ArrowRight':
this.handleNextClick();
e.preventDefault();
e.stopPropagation();
break;
}
}
componentDidMount() {
window.addEventListener('keydown', this.handleKeyDown, false);
}
componentWillUnmount() {
window.removeEventListener('keydown', this.handleKeyDown);
}
getIndex() {
return this.state.index !== null ? this.state.index : this.props.index;
}
toggleNavigation = () => {
this.setState(prevState => ({
navigationHidden: !prevState.navigationHidden,
}));
};
handleStatusClick = e => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
const { status, account } = this.props;
const acct = account.get('acct');
const statusId = status.get('id');
this.props.history.push(`/@${acct}/posts/${statusId}`);
this.props.onClose(null, true);
}
}
handleCloserClick = ({ target }) => {
const whitelist = ['zoomable-image'];
const activeSlide = document.querySelector('.media-modal .react-swipeable-view-container > div[aria-hidden="false"]');
const isClickOutside = target === activeSlide || !activeSlide.contains(target);
const isWhitelisted = whitelist.some(w => target.classList.contains(w));
if (isClickOutside || isWhitelisted) {
this.props.onClose();
}
}
render() {
const { media, status, account, intl, onClose } = this.props;
const { navigationHidden } = this.state;
const index = this.getIndex();
let pagination = [];
const leftNav = media.size > 1 && (
<button tabIndex='0' className='media-modal__nav media-modal__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}>
<Icon src={require('@tabler/icons/arrow-left.svg')} />
</button>
);
const rightNav = media.size > 1 && (
<button tabIndex='0' className='media-modal__nav media-modal__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}>
<Icon src={require('@tabler/icons/arrow-right.svg')} />
</button>
);
if (media.size > 1) {
pagination = media.map((item, i) => {
const classes = ['media-modal__button'];
if (i === index) {
classes.push('media-modal__button--active');
}
return (<li className='media-modal__page-dot' key={i}><button tabIndex='0' className={classes.join(' ')} onClick={this.handleChangeIndex} data-index={i}>{i + 1}</button></li>);
});
}
const isMultiMedia = media.map((image) => {
if (image.get('type') !== 'image') {
return true;
}
return false;
}).toArray();
const content = media.map(attachment => {
const width = attachment.getIn(['meta', 'original', 'width']) || null;
const height = attachment.getIn(['meta', 'original', 'height']) || null;
const link = (status && account && <a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>);
if (attachment.get('type') === 'image') {
return (
<ImageLoader
previewSrc={attachment.get('preview_url')}
src={attachment.get('url')}
width={width}
height={height}
alt={attachment.get('description')}
key={attachment.get('url')}
onClick={this.toggleNavigation}
/>
);
} else if (attachment.get('type') === 'video') {
const { time } = this.props;
return (
<Video
preview={attachment.get('preview_url')}
blurhash={attachment.get('blurhash')}
src={attachment.get('url')}
width={attachment.get('width')}
height={attachment.get('height')}
startTime={time || 0}
onCloseVideo={onClose}
detailed
link={link}
alt={attachment.get('description')}
key={attachment.get('url')}
/>
);
} else if (attachment.get('type') === 'audio') {
return (
<Audio
src={attachment.get('url')}
alt={attachment.get('description')}
poster={attachment.get('preview_url') !== attachment.get('url') ? attachment.get('preview_url') : (status && status.getIn(['account', 'avatar_static']))}
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
key={attachment.get('url')}
/>
);
} else if (attachment.get('type') === 'gifv') {
return (
<ExtendedVideoPlayer
src={attachment.get('url')}
muted
controls={false}
width={width}
link={link}
height={height}
key={attachment.get('preview_url')}
alt={attachment.get('description')}
onClick={this.toggleNavigation}
/>
);
}
return null;
}).toArray();
// you can't use 100vh, because the viewport height is taller
// than the visible part of the document in some mobile
// browsers when it's address bar is visible.
// https://developers.google.com/web/updates/2016/12/url-bar-resizing
const swipeableViewsStyle = {
width: '100%',
height: '100%',
};
const containerStyle = {
alignItems: 'center', // center vertically
};
const navigationClassName = classNames('media-modal__navigation', {
'media-modal__navigation--hidden': navigationHidden,
});
return (
<div className='modal-root__modal media-modal'>
<div
className='media-modal__closer'
role='presentation'
onClick={this.handleCloserClick}
>
<ReactSwipeableViews
style={swipeableViewsStyle}
containerStyle={containerStyle}
onChangeIndex={this.handleSwipe}
onSwitching={this.handleSwitching}
index={index}
>
{content}
</ReactSwipeableViews>
</div>
<div className={navigationClassName}>
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} src={require('@tabler/icons/x.svg')} onClick={onClose} />
{leftNav}
{rightNav}
{(status && !isMultiMedia[index]) && (
<div className={classNames('media-modal__meta', { 'media-modal__meta--shifted': media.size > 1 })}>
<a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>
</div>
)}
<ul className='media-modal__pagination'>
{pagination}
</ul>
</div>
</div>
);
}
}

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

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

@ -41,6 +41,7 @@ const VideoModal: React.FC<IVideoModal> = ({ status, account, media, time, onClo
link={link}
detailed
alt={media.description}
visible
/>
</div>
);

@ -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();
}
removeEventListeners() {
this.removers.forEach(listeners => listeners());
this.removers = [];
this.container?.removeEventListener('touchstart', this.handleTouchStart);
this.container?.removeEventListener('touchend', this.handleTouchMove);
}
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;

@ -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() {

@ -1,11 +1,13 @@
import * as React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useParams } from 'react-router-dom';
import { useHistory, useParams } from 'react-router-dom';
import snackbar from 'soapbox/actions/snackbar';
import { confirmEmailVerification } from 'soapbox/actions/verification';
import { Icon, Spinner, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { ChallengeTypes } from './index';
import type { AxiosError } from 'axios';
@ -30,6 +32,14 @@ const messages = defineMessages({
const Success = () => {
const intl = useIntl();
const history = useHistory();
const currentChallenge = useAppSelector((state) => state.verification.currentChallenge as ChallengeTypes);
// Bypass the user straight to the next step.
if (currentChallenge === ChallengeTypes.SMS) {
history.push('/verify');
return null;
}
return (
<Stack space={4} alignItems='center'>

@ -1,16 +1,15 @@
import * as React from 'react';
import { useDispatch } from 'react-redux';
import { Redirect } from 'react-router-dom';
import { fetchVerificationConfig } from 'soapbox/actions/verification';
import { useAppSelector } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import Registration from './registration';
import AgeVerification from './steps/age-verification';
import EmailVerification from './steps/email-verification';
import SmsVerification from './steps/sms-verification';
enum ChallengeTypes {
export enum ChallengeTypes {
EMAIL = 'email',
SMS = 'sms',
AGE = 'age',
@ -23,7 +22,7 @@ const verificationSteps = {
};
const Verification = () => {
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const isInstanceReady = useAppSelector((state) => state.verification.instance.get('isReady') === true);
const isRegistrationOpen = useAppSelector(state => state.verification.instance.get('registrations') === true);

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

@ -25,7 +25,13 @@ export const ChatMessageRecord = ImmutableRecord({
});
const normalizeMedia = (status: ImmutableMap<string, any>) => {
return status.update('attachment', null, normalizeAttachment);
const attachment = status.get('attachment');
if (attachment) {
return status.set('attachment', normalizeAttachment(attachment));
} else {
return status;
}
};
export const normalizeChatMessage = (chatMessage: Record<string, any>) => {

@ -6,15 +6,23 @@ import {
import { CardRecord, normalizeCard } from '../card';
export const AdRecord = ImmutableRecord({
import type { Ad } from 'soapbox/features/ads/providers';
export const AdRecord = ImmutableRecord<Ad>({
card: CardRecord(),
impression: undefined as string | undefined,
expires: undefined as Date | undefined,
expires_at: undefined as string | undefined,
reason: undefined as string | undefined,
});
/** Normalizes an ad from Soapbox Config. */
export const normalizeAd = (ad: Record<string, any>) => {
const map = ImmutableMap<string, any>(fromJS(ad));
const card = normalizeCard(map.get('card'));
return AdRecord(map.set('card', card));
const expiresAt = map.get('expires_at') || map.get('expires');
return AdRecord(map.merge({
card,
expires_at: expiresAt,
}));
};

@ -106,7 +106,6 @@ export const SoapboxConfigRecord = ImmutableRecord({
limit: 1,
}),
aboutPages: ImmutableMap<string, ImmutableMap<string, unknown>>(),
mobilePages: ImmutableMap<string, ImmutableMap<string, unknown>>(),
authenticatedProfile: true,
singleUserMode: false,
singleUserModeProfile: '',

@ -36,7 +36,7 @@ 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>
)}
{me && features.suggestions && (

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

@ -40,7 +40,7 @@ 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>
)}
{me && features.suggestions && (

@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query';
import { Ad, getProvider } from 'soapbox/features/ads/providers';
import { useAppDispatch } from 'soapbox/hooks';
import { normalizeAd } from 'soapbox/normalizers';
import { isExpired } from 'soapbox/utils/ads';
export default function useAds() {
@ -23,7 +24,7 @@ export default function useAds() {
});
// Filter out expired ads.
const data = result.data?.filter(ad => !isExpired(ad));
const data = result.data?.map(normalizeAd).filter(ad => !isExpired(ad));
return {
...result,

@ -1,4 +1,4 @@
import { normalizeCard } from 'soapbox/normalizers';
import { normalizeAd } from 'soapbox/normalizers';
import { isExpired } from '../ads';
@ -10,13 +10,14 @@ const fiveMins = 5 * 60 * 1000;
test('isExpired()', () => {
const now = new Date();
const card = normalizeCard({});
const iso = now.toISOString();
const epoch = now.getTime();
// Sanity tests.
expect(isExpired({ expires: now, card })).toBe(true);
expect(isExpired({ expires: new Date(now.getTime() + 999999), card })).toBe(false);
expect(isExpired(normalizeAd({ expires_at: iso }))).toBe(true);
expect(isExpired(normalizeAd({ expires_at: new Date(epoch + 999999).toISOString() }))).toBe(false);
// Testing the 5-minute mark.
expect(isExpired({ expires: new Date(now.getTime() + threeMins), card }, fiveMins)).toBe(true);
expect(isExpired({ expires: new Date(now.getTime() + fiveMins + 1000), card }, fiveMins)).toBe(false);
expect(isExpired(normalizeAd({ expires_at: new Date(epoch + threeMins).toISOString() }), fiveMins)).toBe(true);
expect(isExpired(normalizeAd({ expires_at: new Date(epoch + fiveMins + 1000).toISOString() }), fiveMins)).toBe(false);
});

@ -1,13 +1,13 @@
import type { Ad } from 'soapbox/features/ads/providers';
import type { Ad } from 'soapbox/types/soapbox';
/** Time (ms) window to not display an ad if it's about to expire. */
const AD_EXPIRY_THRESHOLD = 5 * 60 * 1000;
/** Whether the ad is expired or about to expire. */
const isExpired = (ad: Ad, threshold = AD_EXPIRY_THRESHOLD): boolean => {
if (ad.expires) {
if (ad.expires_at) {
const now = new Date();
return now.getTime() > (ad.expires.getTime() - threshold);
return now.getTime() > (new Date(ad.expires_at).getTime() - threshold);
} else {
return false;
}

@ -11,6 +11,12 @@ export const defaultMediaVisibility = (status: StatusEntity | undefined | null,
status = status.reblog;
}
const isUnderReview = status.visibility === 'self';
if (isUnderReview) {
return false;
}
return (displayMedia !== 'hide_all' && !status.sensitive || displayMedia === 'show_all');
};

@ -20,7 +20,7 @@
"dev": "${npm_execpath} run start",
"build": "npx webpack",
"jsdoc": "npx jsdoc -c jsdoc.conf.js",
"manage:translations": "node ./webpack/translationRunner.js",
"manage:translations": "node ./translationRunner.js",
"test": "npx cross-env NODE_ENV=test npx jest",
"test:coverage": "${npm_execpath} run test --coverage",
"test:all": "${npm_execpath} run test:coverage && ${npm_execpath} run lint",
@ -68,8 +68,8 @@
"@sentry/react": "^7.11.1",
"@sentry/tracing": "^7.11.1",
"@tabler/icons": "^1.92.0",
"@tailwindcss/forms": "^0.4.0",
"@tailwindcss/typography": "^0.5.1",
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.7",
"@tanstack/react-query": "^4.0.10",
"@testing-library/react": "^12.1.4",
"@types/escape-html": "^1.0.1",
@ -92,6 +92,9 @@
"@types/seedrandom": "^3.0.2",
"@types/semver": "^7.3.9",
"@types/uuid": "^8.3.4",
"@types/webpack-assets-manifest": "^5.1.0",
"@types/webpack-bundle-analyzer": "^4.6.0",
"@types/webpack-deadcode-plugin": "^0.1.2",
"array-includes": "^3.1.5",
"autoprefixer": "^10.4.2",
"axios": "^1.0.0-alpha.1",
@ -108,9 +111,9 @@
"browserslist": "^4.16.6",
"cheerio": "^1.0.0-rc.10",
"clsx": "^1.2.1",
"copy-webpack-plugin": "^9.0.1",
"copy-webpack-plugin": "^11.0.0",
"core-js": "^3.15.2",
"cryptocurrency-icons": "^0.18.0",
"cryptocurrency-icons": "^0.18.1",
"css-loader": "^6.7.1",
"cssnano": "^5.1.10",
"detect-passive-events": "^2.0.0",
@ -194,6 +197,7 @@
"terser-webpack-plugin": "^5.2.3",
"tiny-queue": "^0.2.1",
"ts-loader": "^9.3.0",
"ts-node": "^10.9.1",
"tslib": "^2.3.1",
"twemoji": "https://github.com/twitter/twemoji#v14.0.2",
"typescript": "^4.4.4",
@ -237,7 +241,7 @@
"stylelint": "^13.7.2",
"stylelint-config-standard": "^22.0.0",
"stylelint-scss": "^3.18.0",
"tailwindcss": "^3.0.15",
"tailwindcss": "^3.2.1",
"ts-jest": "^28.0.5",
"webpack-dev-server": "^4.9.1",
"yargs": "^16.0.3"

@ -1,5 +1,6 @@
const { parseColorMatrix } = require('./tailwind/colors');
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./app/**/*.{html,js,ts,tsx}', './custom/instance/**/*.html', './app/index.ejs'],
darkMode: 'class',

@ -14,5 +14,10 @@
"experimentalDecorators": true,
"esModuleInterop": true,
"typeRoots": [ "./types", "./node_modules/@types"]
},
"ts-node": {
"compilerOptions": {
"module": "CommonJS"
}
}
}

@ -1,13 +1,22 @@
require('dotenv').config();
import dotenv from 'dotenv';
import type { Configuration } from 'webpack';
dotenv.config();
const { NODE_ENV } = process.env;
let configuration: Configuration = {};
switch (NODE_ENV) {
case 'development':
case 'production':
case 'test':
module.exports = require(`./webpack/${NODE_ENV}`); break;
configuration = require(`./webpack/${NODE_ENV}`).default;
break;
default:
console.error('ERROR: NODE_ENV must be set to either `development`, `test`, or `production`.');
process.exit(1);
}
export default configuration;

@ -1,5 +1,5 @@
const { join } = require('path');
const { env } = require('process');
import { join } from 'path';
import { env } from 'process';
const {
FE_SUBDIRECTORY,
@ -21,10 +21,12 @@ const output = {
path: join(__dirname, '..', outputDir, FE_SUBDIRECTORY),
};
module.exports = {
const exportEnv = {
NODE_ENV: env.NODE_ENV,
};
export {
settings,
env: {
NODE_ENV: env.NODE_ENV,
},
exportEnv as env,
output,
};

@ -1,13 +1,16 @@
// Note: You must restart bin/webpack-dev-server for changes to take effect
console.log('Running in development mode'); // eslint-disable-line no-console
const { join } = require('path');
import { join } from 'path';
const { merge } = require('webpack-merge');
import { merge } from 'webpack-merge';
const sharedConfig = require('./shared');
import sharedConfig from './shared';
const watchOptions = {};
import type { Configuration } from 'webpack';
import type { Configuration as DevServerConfiguration, ProxyConfigMap } from 'webpack-dev-server';
const watchOptions: Configuration['watchOptions'] = {};
const {
DEVSERVER_URL,
@ -36,10 +39,10 @@ const backendEndpoints = [
'/favicon.png',
];
const makeProxyConfig = () => {
const makeProxyConfig = (): ProxyConfigMap => {
const secureProxy = PROXY_HTTPS_INSECURE !== 'true';
const proxyConfig = {};
const proxyConfig: ProxyConfigMap = {};
proxyConfig['/api/patron'] = {
target: PATRON_URL || DEFAULTS.PATRON_URL,
secure: secureProxy,
@ -47,7 +50,7 @@ const makeProxyConfig = () => {
};
backendEndpoints.map(endpoint => {
proxyConfig[endpoint] = {
target: BACKEND_URL || DEFAULTS.BACKEND_URL,
target: BACKEND_URL,
secure: secureProxy,
changeOrigin: true,
};
@ -64,13 +67,36 @@ if (process.env.VAGRANT) {
const devServerUrl = (() => {
try {
return new URL(DEVSERVER_URL);
return new URL(DEVSERVER_URL || DEFAULTS.DEVSERVER_URL);
} catch {
return new URL(DEFAULTS.DEVSERVER_URL);
}
})();
module.exports = merge(sharedConfig, {
const devServer: DevServerConfiguration = {
compress: true,
host: devServerUrl.hostname,
port: devServerUrl.port,
https: devServerUrl.protocol === 'https:',
hot: false,
allowedHosts: 'all',
historyApiFallback: {
disableDotRule: true,
index: join(FE_SUBDIRECTORY, '/'),
},
headers: {
'Access-Control-Allow-Origin': '*',
},
client: {
overlay: true,
},
static: {
serveIndex: true,
},
proxy: makeProxyConfig(),
};
const configuration: Configuration = {
mode: 'development',
cache: true,
devtool: 'source-map',
@ -90,26 +116,7 @@ module.exports = merge(sharedConfig, {
watchOptions,
),
devServer: {
compress: true,
host: devServerUrl.hostname,
port: devServerUrl.port,
https: devServerUrl.protocol === 'https:',
hot: false,
allowedHosts: 'all',
historyApiFallback: {
disableDotRule: true,
index: join(FE_SUBDIRECTORY, '/'),
},
headers: {
'Access-Control-Allow-Origin': '*',
},
client: {
overlay: true,
},
static: {
serveIndex: true,
},
proxy: makeProxyConfig(),
},
});
devServer,
};
export default merge<Configuration>(sharedConfig, configuration);

@ -1,8 +0,0 @@
const { resolve } = require('path');
// Forces recompile whenever the current commit changes
// Useful for generating the version hash in the UI
module.exports = function(source, map) {
this.addDependency(resolve(__dirname, '../../.git/logs/HEAD'));
this.callback(null, source, map);
};

@ -0,0 +1,14 @@
import { resolve } from 'path';
import type { LoaderContext } from 'webpack';
/**
* Forces recompile whenever the current commit changes.
* Useful for generating the version hash in the UI.
*/
function loader(this: LoaderContext<{}>, content: string) {
this.addDependency(resolve(__dirname, '../../.git/logs/HEAD'));
this.callback(undefined, content);
}
export default loader;

@ -1,18 +1,21 @@
// Note: You must restart bin/webpack-dev-server for changes to take effect
console.log('Running in production mode'); // eslint-disable-line no-console
const { join } = require('path');
import { join } from 'path';
const OfflinePlugin = require('@lcdp/offline-plugin');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
const { merge } = require('webpack-merge');
// @ts-ignore: No types available.
import OfflinePlugin from '@lcdp/offline-plugin';
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
import { merge } from 'webpack-merge';
const sharedConfig = require('./shared');
import sharedConfig from './shared';
import type { Configuration } from 'webpack';
const { FE_SUBDIRECTORY } = require(join(__dirname, '..', 'app', 'soapbox', 'build_config'));
const joinPublicPath = (...paths) => join(FE_SUBDIRECTORY, ...paths);
const joinPublicPath = (...paths: string[]) => join(FE_SUBDIRECTORY, ...paths);
module.exports = merge(sharedConfig, {
const configuration: Configuration = {
mode: 'production',
devtool: 'source-map',
stats: 'errors-warnings',
@ -100,7 +103,7 @@ module.exports = merge(sharedConfig, {
// NOTE: This function gets stringified by OfflinePlugin, so don't try
// moving it anywhere else or making it depend on anything outside it!
// https://github.com/NekR/offline-plugin/blob/master/docs/cache-maps.md
match: (url) => {
match: (url: URL) => {
const { pathname } = url;
const backendRoutes = [
@ -140,4 +143,6 @@ module.exports = merge(sharedConfig, {
appShell: join(FE_SUBDIRECTORY, '/'),
}),
],
});
};
export default merge<Configuration>(sharedConfig, configuration);

@ -1,11 +1,13 @@
// Asset modules
// https://webpack.js.org/guides/asset-modules/
const { resolve } = require('path');
import { resolve } from 'path';
import type { RuleSetRule } from 'webpack';
// These are processed in reverse-order
// We use the name 'packs' instead of 'assets' for legacy reasons
module.exports = [{
const rules: RuleSetRule[] = [{
test: /\.(png|svg)/,
type: 'asset/resource',
include: [
@ -82,3 +84,5 @@ module.exports = [{
filename: 'packs/images/crypto/[name]-[contenthash:8][ext]',
},
}];
export default rules;

@ -1,11 +1,13 @@
const { resolve } = require('path');
import { resolve } from 'path';
const { env } = require('../configuration');
import { env } from '../configuration';
import type { RuleSetRule } from 'webpack';
// This is a hack, used to force build_config @preval to recompile
// https://github.com/kentcdodds/babel-plugin-preval/issues/19
module.exports = {
const rule: RuleSetRule = {
test: resolve(__dirname, '../../app/soapbox/build_config.js'),
use: [
{
@ -18,3 +20,5 @@ module.exports = {
},
],
};
export default rule;

@ -1,11 +1,13 @@
const { resolve } = require('path');
import { resolve } from 'path';
const { env } = require('../configuration');
import { env } from '../configuration';
import type { RuleSetRule } from 'webpack';
// This is a hack, used in conjunction with rules/git-refresh.js
// https://github.com/kentcdodds/babel-plugin-preval/issues/19
module.exports = {
const rule: RuleSetRule = {
test: resolve(__dirname, '../../app/soapbox/utils/code.js'),
use: [
{
@ -18,3 +20,5 @@ module.exports = {
},
],
};
export default rule;

@ -1,8 +1,10 @@
const { join, resolve } = require('path');
import { join, resolve } from 'path';
const { env, settings } = require('../configuration');
import { env, settings } from '../configuration';
module.exports = {
import type { RuleSetRule } from 'webpack';
const rule: RuleSetRule = {
test: /\.(js|jsx|mjs|ts|tsx)$/,
include: [
settings.source_path,
@ -27,3 +29,5 @@ module.exports = {
},
],
};
export default rule;

@ -1,6 +1,8 @@
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
module.exports = {
import type { RuleSetRule } from 'webpack';
const rule: RuleSetRule = {
test: /\.s?css$/i,
use: [
MiniCssExtractPlugin.loader,
@ -26,3 +28,5 @@ module.exports = {
},
],
};
export default rule;

@ -1,9 +0,0 @@
const { resolve } = require('path');
// Recompile code.js whenever git changes
module.exports = {
test: resolve(__dirname, '../../app/soapbox/utils/code.js'),
use: {
loader: resolve(__dirname, '../loaders/git-loader.js'),
},
};

@ -0,0 +1,13 @@
import { resolve } from 'path';
import type { RuleSetRule } from 'webpack';
/** Recompile code.js whenever git changes. */
const rule: RuleSetRule = {
test: resolve(__dirname, '../../app/soapbox/utils/code.js'),
use: {
loader: resolve(__dirname, '../loaders/git-loader.ts'),
},
};
export default rule;

@ -1,19 +0,0 @@
const assets = require('./assets');
const babel = require('./babel');
const buildConfig = require('./babel-build-config');
const git = require('./babel-git');
const css = require('./css');
const gitRefresh = require('./git-refresh');
const nodeModules = require('./node_modules');
// Webpack loaders are processed in reverse order
// https://webpack.js.org/concepts/loaders/#loader-features
module.exports = [
...assets,
css,
nodeModules,
babel,
git,
gitRefresh,
buildConfig,
];

@ -0,0 +1,23 @@
import assets from './assets';
import babel from './babel';
import buildConfig from './babel-build-config';
import git from './babel-git';
import css from './css';
import gitRefresh from './git-refresh';
import nodeModules from './node-modules';
import type { RuleSetRule } from 'webpack';
// Webpack loaders are processed in reverse order
// https://webpack.js.org/concepts/loaders/#loader-features
const rules: RuleSetRule[] = [
...assets,
css,
nodeModules,
babel,
git,
gitRefresh,
buildConfig,
];
export default rules;

@ -1,8 +0,0 @@
if (process.env.NODE_ENV === 'production') {
module.exports = {};
} else {
module.exports = {
test: /\.js$/,
loader: 'mark-loader',
};
}

@ -0,0 +1,14 @@
import { env } from 'process';
import type { RuleSetRule } from 'webpack';
let rule: RuleSetRule = {};
if (env.NODE_ENV !== 'production') {
rule = {
test: /\.js$/,
loader: 'mark-loader',
};
}
export default rule;

@ -1,8 +1,10 @@
const { join } = require('path');
import { join } from 'path';
const { settings, env } = require('../configuration');
import { env, settings } from '../configuration';
module.exports = {
import type { RuleSetRule } from 'webpack';
const rule: RuleSetRule = {
test: /\.(js|mjs)$/,
include: /node_modules/,
exclude: [
@ -26,3 +28,5 @@ module.exports = {
},
],
};
export default rule;

@ -1,24 +1,24 @@
// Note: You must restart bin/webpack-dev-server for changes to take effect
const fs = require('fs');
const { join, resolve } = require('path');
import fs from 'fs';
import { join, resolve } from 'path';
const CopyPlugin = require('copy-webpack-plugin');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const HtmlWebpackHarddiskPlugin = require('html-webpack-harddisk-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const webpack = require('webpack');
const AssetsManifestPlugin = require('webpack-assets-manifest');
const DeadCodePlugin = require('webpack-deadcode-plugin');
import CopyPlugin from 'copy-webpack-plugin';
import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
import HtmlWebpackHarddiskPlugin from 'html-webpack-harddisk-plugin';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import webpack, { Configuration } from 'webpack';
import AssetsManifestPlugin from 'webpack-assets-manifest';
import DeadCodePlugin from 'webpack-deadcode-plugin';
const { env, settings, output } = require('./configuration');
const rules = require('./rules');
import { env, settings, output } from './configuration';
import rules from './rules';
const { FE_SUBDIRECTORY, FE_INSTANCE_SOURCE_DIR } = require(join(__dirname, '..', 'app', 'soapbox', 'build_config'));
// Return file as string, or return empty string
const readFile = filename => {
/** Return file as string, or return empty string. */
const readFile = (filename: string) => {
try {
return fs.readFileSync(filename, 'utf8');
} catch {
@ -26,8 +26,8 @@ const readFile = filename => {
}
};
const makeHtmlConfig = (params = {}) => {
return Object.assign({
const makeHtmlConfig = (params = {}): HtmlWebpackPlugin.Options => {
const defaults: HtmlWebpackPlugin.Options = {
template: 'app/index.ejs',
chunksSortMode: 'manual',
chunks: ['common', 'locale_en', 'application', 'styles'],
@ -43,10 +43,12 @@ const makeHtmlConfig = (params = {}) => {
templateParameters: {
snippets: readFile(resolve('custom/snippets.html')),
},
}, params);
};
return Object.assign(defaults, params);
};
module.exports = {
const configuration: Configuration = {
entry: {
application: resolve('app/application.ts'),
},
@ -94,6 +96,7 @@ module.exports = {
filename: 'packs/css/[name]-[contenthash:8].css',
chunkFilename: 'packs/css/[name]-[contenthash:8].chunk.css',
}),
// @ts-ignore
new AssetsManifestPlugin({
integrity: false,
entrypoints: true,
@ -101,6 +104,7 @@ module.exports = {
publicPath: true,
}),
// https://github.com/MQuy/webpack-deadcode-plugin
// @ts-ignore
new DeadCodePlugin({
patterns: [
'app/**/*',
@ -160,3 +164,5 @@ module.exports = {
modules: ['node_modules'],
},
};
export default configuration;

@ -1,10 +0,0 @@
// Note: You must restart bin/webpack-dev-server for changes to take effect
console.log('Running in test mode'); // eslint-disable-line no-console
const { merge } = require('webpack-merge');
const sharedConfig = require('./shared');
module.exports = merge(sharedConfig, {
mode: 'development',
});

@ -0,0 +1,14 @@
// Note: You must restart bin/webpack-dev-server for changes to take effect
console.log('Running in test mode'); // eslint-disable-line no-console
import { merge } from 'webpack-merge';
import sharedConfig from './shared';
import type { Configuration } from 'webpack';
const configuration: Configuration = {
mode: 'development',
};
export default merge<Configuration>(sharedConfig, configuration);

@ -1377,6 +1377,13 @@
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
"@cspotcode/source-map-support@^0.8.0":
version "0.8.1"
resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1"
integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==
dependencies:
"@jridgewell/trace-mapping" "0.3.9"
"@discoveryjs/json-ext@^0.5.0":
version "0.5.5"
resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.5.tgz#9283c9ce5b289a3c4f61c12757469e59377f81f3"
@ -1833,6 +1840,14 @@
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.11.tgz#771a1d8d744eeb71b6adb35808e1a6c7b9b8c8ec"
integrity sha512-Fg32GrJo61m+VqYSdRSjRXMjQ06j8YIYfcTqndLYVAaHmroZHLJZCydsWBOTDqXS2v+mjxohBWEMfg97GXmYQg==
"@jridgewell/trace-mapping@0.3.9":
version "0.3.9"
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9"
integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==
dependencies:
"@jridgewell/resolve-uri" "^3.0.3"
"@jridgewell/sourcemap-codec" "^1.4.10"
"@jridgewell/trace-mapping@^0.3.0":
version "0.3.4"
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.4.tgz#f6a0832dffd5b8a6aaa633b7d9f8e8e94c83a0c3"
@ -2280,21 +2295,22 @@
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-1.92.0.tgz#e068938db61d5f932d317a8fd18acd5c0204496e"
integrity sha512-eZP5jYvNPtZKiWj1Bn2C+zjvGXhQoormXAyE3TH7ihCxXBHMzKF/ZLYhU3rnMC/+NjuLDBifKc6dmwoh7s62vQ==
"@tailwindcss/forms@^0.4.0":
version "0.4.0"
resolved "https://registry.yarnpkg.com/@tailwindcss/forms/-/forms-0.4.0.tgz#a46715e347a32d216a3973eb67473bd29ae3798e"
integrity sha512-DeaQBx6EgEeuZPQACvC+mKneJsD8am1uiJugjgQK1+/Vt+Ai0GpFBC2T2fqnUad71WgOxyrZPE6BG1VaI6YqfQ==
"@tailwindcss/forms@^0.5.3":
version "0.5.3"
resolved "https://registry.yarnpkg.com/@tailwindcss/forms/-/forms-0.5.3.tgz#e4d7989686cbcaf416c53f1523df5225332a86e7"
integrity sha512-y5mb86JUoiUgBjY/o6FJSFZSEttfb3Q5gllE4xoKjAAD+vBrnIhE4dViwUuow3va8mpH4s9jyUbUbrRGoRdc2Q==
dependencies:
mini-svg-data-uri "^1.2.3"
"@tailwindcss/typography@^0.5.1":
version "0.5.1"
resolved "https://registry.yarnpkg.com/@tailwindcss/typography/-/typography-0.5.1.tgz#486248a9426501f11a9b0295f7cfc0eb29659c46"
integrity sha512-AmSzZSgLhHKlILKduU+PKBTHL6c+al82syZlRid1xgmlWwXagLigO+O++B4C0scpMfzW//f/3YCRcwwEHWoU3w==
"@tailwindcss/typography@^0.5.7":
version "0.5.7"
resolved "https://registry.yarnpkg.com/@tailwindcss/typography/-/typography-0.5.7.tgz#e0b95bea787ee14c5a34a74fc824e6fe86ea8855"
integrity sha512-JTTSTrgZfp6Ki4svhPA4mkd9nmQ/j9EfE7SbHJ1cLtthKkpW2OxsFXzSmxbhYbEkfNIyAyhle5p4SYyKRbz/jg==
dependencies:
lodash.castarray "^4.4.0"
lodash.isplainobject "^4.0.6"
lodash.merge "^4.6.2"
postcss-selector-parser "6.0.10"
"@tanstack/query-core@^4.0.0-beta.1":
version "4.0.10"
@ -2371,6 +2387,26 @@
resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad"
integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==
"@tsconfig/node10@^1.0.7":
version "1.0.9"
resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.9.tgz#df4907fc07a886922637b15e02d4cebc4c0021b2"
integrity sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==
"@tsconfig/node12@^1.0.7":
version "1.0.11"
resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d"
integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==
"@tsconfig/node14@^1.0.0":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1"
integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==
"@tsconfig/node16@^1.0.2":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.3.tgz#472eaab5f15c1ffdd7f8628bd4c4f753995ec79e"
integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==
"@types/aria-query@^4.2.0":
version "4.2.2"
resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-4.2.2.tgz#ed4e0ad92306a704f9fb132a0cfcf77486dbe2bc"
@ -2885,11 +2921,21 @@
dependencies:
"@types/node" "*"
"@types/source-list-map@*":
version "0.1.2"
resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.2.tgz#0078836063ffaf17412349bba364087e0ac02ec9"
integrity sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA==
"@types/stack-utils@^2.0.0":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c"
integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==
"@types/tapable@^1":
version "1.0.8"
resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.8.tgz#b94a4391c85666c7b73299fd3ad79d4faa435310"
integrity sha512-ipixuVrh2OdNmauvtT51o3d8z12p6LtFW9in7U79der/kwejjdNchQC5UMn5u/KxNoM7VHHOs/l8KS8uHxhODQ==
"@types/testing-library__jest-dom@^5.9.1":
version "5.14.3"
resolved "https://registry.yarnpkg.com/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.3.tgz#ee6c7ffe9f8595882ee7bda8af33ae7b8789ef17"
@ -2902,6 +2948,13 @@
resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.2.tgz#6286b4c7228d58ab7866d19716f3696e03a09397"
integrity sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==
"@types/uglify-js@*":
version "3.17.0"
resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.17.0.tgz#95271e7abe0bf7094c60284f76ee43232aef43b9"
integrity sha512-3HO6rm0y+/cqvOyA8xcYLweF0TKXlAxmQASjbOi49Co51A1N4nR4bEwBgRoD9kNM+rqFGArjKr654SLp2CoGmQ==
dependencies:
source-map "^0.6.1"
"@types/unist@*", "@types/unist@^2.0.0", "@types/unist@^2.0.2":
version "2.0.6"
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d"
@ -2917,6 +2970,52 @@
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc"
integrity sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==
"@types/webpack-assets-manifest@^5.1.0":
version "5.1.0"
resolved "https://registry.yarnpkg.com/@types/webpack-assets-manifest/-/webpack-assets-manifest-5.1.0.tgz#82ae3f8fb5ca306319d58b9cdfc4ad72afbdc6c6"
integrity sha512-XfqZOVP6n220SXYAVyFdPX+6DCGdejVQ/mj2l+eI5D0QL6+GaRTo4rBcmePDDQmVefpKq5Q7Gd4jiYQEjUwntA==
dependencies:
"@types/node" "*"
tapable "^2.2.0"
webpack "^5"
"@types/webpack-bundle-analyzer@^4.6.0":
version "4.6.0"
resolved "https://registry.yarnpkg.com/@types/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.6.0.tgz#8863d62d2432126c2b3a9239cafa469215981c24"
integrity sha512-XeQmQCCXdZdap+A/60UKmxW5Mz31Vp9uieGlHB3T4z/o2OLVLtTI3bvTuS6A2OWd/rbAAQiGGWIEFQACu16szA==
dependencies:
"@types/node" "*"
tapable "^2.2.0"
webpack "^5"
"@types/webpack-deadcode-plugin@^0.1.2":
version "0.1.2"
resolved "https://registry.yarnpkg.com/@types/webpack-deadcode-plugin/-/webpack-deadcode-plugin-0.1.2.tgz#0a9ae8d85ee7e6bd2f14f03085aee632aaa077b3"
integrity sha512-Zxy7Hhi0palMlMtashNAEyp7PsQFcv7bi2sCH2p5uYaYzlv7K29+w3tDZNFdBu1EPedjPU0VYzfK143nozZgVQ==
dependencies:
"@types/webpack" "^4"
"@types/webpack-sources@*":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@types/webpack-sources/-/webpack-sources-3.2.0.tgz#16d759ba096c289034b26553d2df1bf45248d38b"
integrity sha512-Ft7YH3lEVRQ6ls8k4Ff1oB4jN6oy/XmU6tQISKdhfh+1mR+viZFphS6WL0IrtDOzvefmJg5a0s7ZQoRXwqTEFg==
dependencies:
"@types/node" "*"
"@types/source-list-map" "*"
source-map "^0.7.3"
"@types/webpack@^4":
version "4.41.32"
resolved "https://registry.yarnpkg.com/@types/webpack/-/webpack-4.41.32.tgz#a7bab03b72904070162b2f169415492209e94212"
integrity sha512-cb+0ioil/7oz5//7tZUSwbrSAN/NWHrQylz5cW8G0dWTcF/g+/dSdMlKVZspBYuMAN1+WnwHrkxiRrLcwd0Heg==
dependencies:
"@types/node" "*"
"@types/tapable" "^1"
"@types/uglify-js" "*"
"@types/webpack-sources" "*"
anymatch "^3.0.0"
source-map "^0.6.0"
"@types/ws@^8.5.1":
version "8.5.3"
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.3.tgz#7d25a1ffbecd3c4f2d35068d0b283c037003274d"
@ -3227,7 +3326,7 @@ acorn-jsx@^5.3.1:
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
acorn-node@^1.6.1:
acorn-node@^1.8.2:
version "1.8.2"
resolved "https://registry.yarnpkg.com/acorn-node/-/acorn-node-1.8.2.tgz#114c95d64539e53dede23de8b9d96df7c7ae2af8"
integrity sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==
@ -3241,7 +3340,7 @@ acorn-walk@^7.0.0, acorn-walk@^7.1.1:
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc"
integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==
acorn-walk@^8.0.0:
acorn-walk@^8.0.0, acorn-walk@^8.1.1:
version "8.2.0"
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1"
integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==
@ -3261,6 +3360,11 @@ acorn@^8.5.0:
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30"
integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==
acorn@^8.7.1:
version "8.8.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.0.tgz#88c0187620435c7f6015803f5539dae05a9dbea8"
integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==
agent-base@4, agent-base@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee"
@ -3383,7 +3487,7 @@ ansi-styles@^5.0.0:
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b"
integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==
anymatch@^3.0.3, anymatch@~3.1.2:
anymatch@^3.0.0, anymatch@^3.0.3, anymatch@~3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716"
integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==
@ -3391,10 +3495,15 @@ anymatch@^3.0.3, anymatch@~3.1.2:
normalize-path "^3.0.0"
picomatch "^2.0.4"
arg@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.1.tgz#eb0c9a8f77786cad2af8ff2b862899842d7b6adb"
integrity sha512-e0hDa9H2Z9AwFkk2qDlwhoMYE4eToKarchkQHovNdLTCYMHZHeRjI71crOh+dio4K6u1IcwubQqo79Ga4CyAQA==
arg@^4.1.0:
version "4.1.3"
resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089"
integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==
arg@^5.0.2:
version "5.0.2"
resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c"
integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==
argparse@^1.0.7:
version "1.0.10"
@ -4143,7 +4252,7 @@ cheerio@^1.0.0-rc.10:
optionalDependencies:
fsevents "~2.3.2"
chokidar@^3.5.2, chokidar@^3.5.3:
chokidar@^3.5.3:
version "3.5.3"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
@ -4380,17 +4489,16 @@ cookie@0.5.0:
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b"
integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
copy-webpack-plugin@^9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-9.0.1.tgz#b71d21991599f61a4ee00ba79087b8ba279bbb59"
integrity sha512-14gHKKdYIxF84jCEgPgYXCPpldbwpxxLbCmA7LReY7gvbaT555DgeBWBgBZM116tv/fO6RRJrsivBqRyRlukhw==
copy-webpack-plugin@^11.0.0:
version "11.0.0"
resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz#96d4dbdb5f73d02dd72d0528d1958721ab72e04a"
integrity sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==
dependencies:
fast-glob "^3.2.5"
glob-parent "^6.0.0"
globby "^11.0.3"
fast-glob "^3.2.11"
glob-parent "^6.0.1"
globby "^13.1.1"
normalize-path "^3.0.0"
p-limit "^3.1.0"
schema-utils "^3.0.0"
schema-utils "^4.0.0"
serialize-javascript "^6.0.0"
core-js-compat@^3.21.0, core-js-compat@^3.22.1:
@ -4442,6 +4550,11 @@ cosmiconfig@^7.0.0, cosmiconfig@^7.0.1:
path-type "^4.0.0"
yaml "^1.10.0"
create-require@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
cross-env@^7.0.3:
version "7.0.3"
resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf"
@ -4458,10 +4571,10 @@ cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
shebang-command "^2.0.0"
which "^2.0.1"
cryptocurrency-icons@^0.18.0:
version "0.18.0"
resolved "https://registry.yarnpkg.com/cryptocurrency-icons/-/cryptocurrency-icons-0.18.0.tgz#823552afcd299f4899d26a869252d1d27b004bc0"
integrity sha512-Q8Uq/Vhzcn/B6ouoJk+5Q/Kfp0zz0DjfldPv836OKT5mxgYDZm8vWgMQ5wBuRvT8Z/ZHLeXaBUgJtufPXmG0NQ==
cryptocurrency-icons@^0.18.1:
version "0.18.1"
resolved "https://registry.yarnpkg.com/cryptocurrency-icons/-/cryptocurrency-icons-0.18.1.tgz#b1bdbfb4b78996ec003e63dbd7ec0b830d23b89c"
integrity sha512-dvR5O8JOmav3559Yb0Igpkia+3vpt/aeNvMu5ZIVUG2Bzpq9wNcOJRIQas49XJrPjtZ98GAEn3aDQO+w7uhS2w==
css-declaration-sorter@^6.2.2:
version "6.2.2"
@ -4872,14 +4985,14 @@ detect-passive-events@^2.0.0:
dependencies:
detect-it "^4.0.1"
detective@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/detective/-/detective-5.2.0.tgz#feb2a77e85b904ecdea459ad897cc90a99bd2a7b"
integrity sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg==
detective@^5.2.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/detective/-/detective-5.2.1.tgz#6af01eeda11015acb0e73f933242b70f24f91034"
integrity sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw==
dependencies:
acorn-node "^1.6.1"
acorn-node "^1.8.2"
defined "^1.0.0"
minimist "^1.1.1"
minimist "^1.2.6"
didyoumean@^1.2.2:
version "1.2.2"
@ -4896,6 +5009,11 @@ diff-sequences@^28.1.1:
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-28.1.1.tgz#9989dc731266dc2903457a70e996f3a041913ac6"
integrity sha512-FU0iFaH/E23a+a718l8Qa/19bF9p06kgE0KipMOMadwa3SjnaElKzPaUC0vnibs6/B/9ni97s61mcejk8W1fQw==
diff@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
dir-glob@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f"
@ -5144,6 +5262,14 @@ enhanced-resolve@^5.0.0:
graceful-fs "^4.2.4"
tapable "^2.2.0"
enhanced-resolve@^5.10.0:
version "5.10.0"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz#0dc579c3bb2a1032e357ac45b8f3a6f3ad4fb1e6"
integrity sha512-T0yTFjdpldGY8PmuXXR0PyQ1ufZpEGiHVrp7zHKB7jdR4qlmZHhONVM5AQOAWXuF/w3dnHbEQVrNptJgt7F+cQ==
dependencies:
graceful-fs "^4.2.4"
tapable "^2.2.0"
enhanced-resolve@^5.9.3:
version "5.9.3"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.9.3.tgz#44a342c012cbc473254af5cc6ae20ebd0aae5d88"
@ -5766,7 +5892,18 @@ fast-glob@^3.1.1, fast-glob@^3.2.5:
merge2 "^1.3.0"
micromatch "^4.0.4"
fast-glob@^3.2.7, fast-glob@^3.2.9:
fast-glob@^3.2.11, fast-glob@^3.2.12:
version "3.2.12"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80"
integrity sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==
dependencies:
"@nodelib/fs.stat" "^2.0.2"
"@nodelib/fs.walk" "^1.2.3"
glob-parent "^5.1.2"
merge2 "^1.3.0"
micromatch "^4.0.4"
fast-glob@^3.2.9:
version "3.2.11"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.11.tgz#a1172ad95ceb8a16e20caa5c5e56480e5129c1d9"
integrity sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==
@ -6127,14 +6264,7 @@ glob-parent@^5.1.2, glob-parent@~5.1.2:
dependencies:
is-glob "^4.0.1"
glob-parent@^6.0.0:
version "6.0.1"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.1.tgz#42054f685eb6a44e7a7d189a96efa40a54971aa7"
integrity sha512-kEVjS71mQazDBHKcsq4E9u/vUzaLcw1A8EtUeydawvIWQCJM0qQ08G1H7/XTjFUulla6XQiDOG6MXSaG0HDKog==
dependencies:
is-glob "^4.0.1"
glob-parent@^6.0.2:
glob-parent@^6.0.1, glob-parent@^6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3"
integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==
@ -6222,6 +6352,17 @@ globby@^11.0.4:
merge2 "^1.4.1"
slash "^3.0.0"
globby@^13.1.1:
version "13.1.2"
resolved "https://registry.yarnpkg.com/globby/-/globby-13.1.2.tgz#29047105582427ab6eca4f905200667b056da515"
integrity sha512-LKSDZXToac40u8Q1PQtZihbNdTYSNMuWe+K5l+oa6KgDzSvVrHXlJy40hUP522RjAIoNLJYBJi7ow+rbFpIhHQ==
dependencies:
dir-glob "^3.0.1"
fast-glob "^3.2.11"
ignore "^5.2.0"
merge2 "^1.4.1"
slash "^4.0.0"
globjoin@^0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/globjoin/-/globjoin-0.1.4.tgz#2f4494ac8919e3767c5cbb691e9f463324285d43"
@ -6847,6 +6988,13 @@ is-core-module@^2.8.1:
dependencies:
has "^1.0.3"
is-core-module@^2.9.0:
version "2.11.0"
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.11.0.tgz#ad4cb3e3863e814523c96f3f58d26cc570ff0144"
integrity sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==
dependencies:
has "^1.0.3"
is-date-object@^1.0.1:
version "1.0.5"
resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.5.tgz#0841d5536e724c25597bf6ea62e1bd38298df31f"
@ -7883,10 +8031,10 @@ lilconfig@^2.0.3:
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.5.tgz#19e57fd06ccc3848fd1891655b5a447092225b25"
integrity sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg==
lilconfig@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.4.tgz#f4507d043d7058b380b6a8f5cb7bcd4b34cee082"
integrity sha512-bfTIN7lEsiooCocSISTWXkiWJkRqtL9wYtYy+8EK3Y41qh3mpwPU0ycTOgjdY9ErwXCc8QyrQp82bdL0Xkm9yA==
lilconfig@^2.0.5, lilconfig@^2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.6.tgz#32a384558bd58af3d4c6e077dd1ad1d397bc69d4"
integrity sha512-9JROoBW7pobfsx+Sq2JsASvCo6Pfo6WWoUW79HuB1BCoBXD4PLWJPqDF6fNj67pqBYTbAHkE57M1kS/+L1neOg==
line-awesome@^1.3.0:
version "1.3.0"
@ -8174,7 +8322,7 @@ make-dir@^3.0.0, make-dir@^3.0.2, make-dir@^3.1.0:
dependencies:
semver "^6.0.0"
make-error@1.x:
make-error@1.x, make-error@^1.1.1:
version "1.3.6"
resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"
integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
@ -8348,7 +8496,7 @@ micromatch@^4.0.0, micromatch@^4.0.2:
braces "^3.0.1"
picomatch "^2.2.3"
micromatch@^4.0.4:
micromatch@^4.0.4, micromatch@^4.0.5:
version "4.0.5"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6"
integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==
@ -8453,7 +8601,7 @@ minimist-options@4.1.0:
is-plain-obj "^1.1.0"
kind-of "^6.0.3"
minimist@^1.1.1, minimist@^1.2.0:
minimist@^1.2.0:
version "1.2.5"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
@ -8463,6 +8611,11 @@ minimist@^1.2.5:
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
minimist@^1.2.6:
version "1.2.7"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18"
integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==
mkdirp@^0.5.1:
version "0.5.5"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def"
@ -8662,10 +8815,10 @@ object-fit-images@^3.2.3:
resolved "https://registry.yarnpkg.com/object-fit-images/-/object-fit-images-3.2.4.tgz#6c299d38fdf207746e5d2d46c2877f6f25d15b52"
integrity sha512-G+7LzpYfTfqUyrZlfrou/PLLLAPNC52FTy5y1CBywX+1/FkxIloOyQXBmZ3Zxa2AWO+lMF0JTuvqbr7G5e5CWg==
object-hash@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.2.0.tgz#5ad518581eefc443bd763472b8ff2e9c2c0d54a5"
integrity sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==
object-hash@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9"
integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==
object-inspect@^1.11.0, object-inspect@^1.9.0:
version "1.11.0"
@ -9050,6 +9203,11 @@ picomatch@^2.2.1:
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972"
integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==
pify@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==
pinpoint@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/pinpoint/-/pinpoint-1.1.0.tgz#0cf7757a6977f1bf7f6a32207b709e377388e874"
@ -9127,6 +9285,15 @@ postcss-html@^0.36.0:
dependencies:
htmlparser2 "^3.10.0"
postcss-import@^14.1.0:
version "14.1.0"
resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-14.1.0.tgz#a7333ffe32f0b8795303ee9e40215dac922781f0"
integrity sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==
dependencies:
postcss-value-parser "^4.0.0"
read-cache "^1.0.0"
resolve "^1.1.7"
postcss-js@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-4.0.0.tgz#31db79889531b80dc7bc9b0ad283e418dce0ac00"
@ -9141,12 +9308,12 @@ postcss-less@^3.1.4:
dependencies:
postcss "^7.0.14"
postcss-load-config@^3.1.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-3.1.1.tgz#2f53a17f2f543d9e63864460af42efdac0d41f87"
integrity sha512-c/9XYboIbSEUZpiD1UQD0IKiUe8n9WHYV7YFe7X7J+ZwCsEKkUJSFWjS9hBU1RR9THR7jMXst8sxiqP0jjo2mg==
postcss-load-config@^3.1.4:
version "3.1.4"
resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-3.1.4.tgz#1ab2571faf84bb078877e1d07905eabe9ebda855"
integrity sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==
dependencies:
lilconfig "^2.0.4"
lilconfig "^2.0.5"
yaml "^1.10.2"
postcss-loader@^7.0.0:
@ -9241,12 +9408,12 @@ postcss-modules-values@^4.0.0:
dependencies:
icss-utils "^5.0.0"
postcss-nested@5.0.6:
version "5.0.6"
resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-5.0.6.tgz#466343f7fc8d3d46af3e7dba3fcd47d052a945bc"
integrity sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA==
postcss-nested@6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-6.0.0.tgz#1572f1984736578f360cffc7eb7dca69e30d1735"
integrity sha512-0DkamqrPcmkBDsLn+vQDIrtkSbNkv5AD/M322ySo9kqFkCIYklym2xEmWkwo+Y3/qZo34tzEPNUw4y7yMCdv5w==
dependencies:
postcss-selector-parser "^6.0.6"
postcss-selector-parser "^6.0.10"
postcss-normalize-charset@^5.1.0:
version "5.1.0"
@ -9370,26 +9537,18 @@ postcss-scss@^2.1.1:
dependencies:
postcss "^7.0.6"
postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4, postcss-selector-parser@^6.0.5:
version "6.0.6"
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.6.tgz#2c5bba8174ac2f6981ab631a42ab0ee54af332ea"
integrity sha512-9LXrvaaX3+mcv5xkg5kFwqSzSH1JIObIx51PrndZwlmznwXRfxMddDvo9gve3gVR8ZTKgoFDdWkbRFmEhT4PMg==
dependencies:
cssesc "^3.0.0"
util-deprecate "^1.0.2"
postcss-selector-parser@^6.0.6, postcss-selector-parser@^6.0.8:
version "6.0.9"
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.9.tgz#ee71c3b9ff63d9cd130838876c13a2ec1a992b2f"
integrity sha512-UO3SgnZOVTwu4kyLR22UQ1xZh086RyNZppb7lLAKBFK8a32ttG5i87Y/P3+2bRSjZNyJ1B7hfFNo273tKe9YxQ==
postcss-selector-parser@6.0.10, postcss-selector-parser@^6.0.10, postcss-selector-parser@^6.0.9:
version "6.0.10"
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz#79b61e2c0d1bfc2602d549e11d0876256f8df88d"
integrity sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==
dependencies:
cssesc "^3.0.0"
util-deprecate "^1.0.2"
postcss-selector-parser@^6.0.9:
version "6.0.10"
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz#79b61e2c0d1bfc2602d549e11d0876256f8df88d"
integrity sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==
postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4, postcss-selector-parser@^6.0.5:
version "6.0.6"
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.6.tgz#2c5bba8174ac2f6981ab631a42ab0ee54af332ea"
integrity sha512-9LXrvaaX3+mcv5xkg5kFwqSzSH1JIObIx51PrndZwlmznwXRfxMddDvo9gve3gVR8ZTKgoFDdWkbRFmEhT4PMg==
dependencies:
cssesc "^3.0.0"
util-deprecate "^1.0.2"
@ -9414,16 +9573,16 @@ postcss-unique-selectors@^5.1.1:
dependencies:
postcss-selector-parser "^6.0.5"
postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss-value-parser@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz#443f6a20ced6481a2bda4fa8532a6e55d789a2cb"
integrity sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ==
postcss-value-parser@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^5.0.16:
version "5.2.18"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-5.2.18.tgz#badfa1497d46244f6390f58b319830d9107853c5"
@ -9452,6 +9611,15 @@ postcss@^8.4.14, postcss@^8.4.7:
picocolors "^1.0.0"
source-map-js "^1.0.2"
postcss@^8.4.17:
version "8.4.18"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.18.tgz#6d50046ea7d3d66a85e0e782074e7203bc7fbca2"
integrity sha512-Wi8mWhncLJm11GATDaQKobXSNEYGUHeQLiQqDFG1qQ5UTDPTEvKw0Xt5NsTpktGTwLps3ByrWsBrG0rB8YQ9oA==
dependencies:
nanoid "^3.3.4"
picocolors "^1.0.0"
source-map-js "^1.0.2"
prelude-ls@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
@ -9986,6 +10154,13 @@ reactcss@^1.2.0:
dependencies:
lodash "^4.0.1"
read-cache@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774"
integrity sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==
dependencies:
pify "^2.3.0"
read-pkg-up@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507"
@ -10324,6 +10499,15 @@ resolve.exports@^1.1.0:
resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-1.1.0.tgz#5ce842b94b05146c0e03076985d1d0e7e48c90c9"
integrity sha512-J1l+Zxxp4XK3LUDZ9m60LRJF/mAe4z6a4xyabPHk7pvK5t35dACV32iIjJDFeWZFfZlO29w6SZ67knR0tHzJtQ==
resolve@^1.1.7, resolve@^1.22.1:
version "1.22.1"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177"
integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==
dependencies:
is-core-module "^2.9.0"
path-parse "^1.0.7"
supports-preserve-symlinks-flag "^1.0.0"
resolve@^1.10.0, resolve@^1.12.0, resolve@^1.14.2, resolve@^1.20.0, resolve@^1.9.0:
version "1.20.0"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975"
@ -10341,15 +10525,6 @@ resolve@^1.19.0:
path-parse "^1.0.7"
supports-preserve-symlinks-flag "^1.0.0"
resolve@^1.21.0:
version "1.21.0"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.21.0.tgz#b51adc97f3472e6a5cf4444d34bc9d6b9037591f"
integrity sha512-3wCbTpk5WJlyE4mSOtDLhqQmGFi0/TD9VPwmiolnk8U0wRgMEktqCXd3vy5buTO3tljvalNvKrjHEfrd2WpEKA==
dependencies:
is-core-module "^2.8.0"
path-parse "^1.0.7"
supports-preserve-symlinks-flag "^1.0.0"
resolve@^2.0.0-next.3:
version "2.0.0-next.3"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-2.0.0-next.3.tgz#d41016293d4a8586a39ca5d9b5f15cbea1f55e46"
@ -10447,7 +10622,7 @@ scheduler@^0.20.2:
loose-envify "^1.1.0"
object-assign "^4.1.1"
schema-utils@*, schema-utils@^3.0, schema-utils@^3.0.0, schema-utils@^3.1.0, schema-utils@^3.1.1:
schema-utils@*, schema-utils@^3.0, schema-utils@^3.1.0, schema-utils@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.1.1.tgz#bc74c4b6b6995c1d88f76a8b77bea7219e0c8281"
integrity sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==
@ -10660,6 +10835,11 @@ slash@^3.0.0:
resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
slash@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/slash/-/slash-4.0.0.tgz#2422372176c4c6c5addb5e2ada885af984b396a7"
integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==
slice-ansi@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-3.0.0.tgz#31ddc10930a1b7e0b67b08c96c2f49b77a789787"
@ -10726,6 +10906,11 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1:
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
source-map@^0.7.3:
version "0.7.4"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.4.tgz#a9bbe705c9d8846f4e08ff6765acf0f1b0898656"
integrity sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==
source-map@~0.7.2:
version "0.7.3"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383"
@ -11193,31 +11378,34 @@ taffydb@2.6.2:
resolved "https://registry.yarnpkg.com/taffydb/-/taffydb-2.6.2.tgz#7cbcb64b5a141b6a2efc2c5d2c67b4e150b2a268"
integrity sha1-fLy2S1oUG2ou/CxdLGe04VCyomg=
tailwindcss@^3.0.15:
version "3.0.15"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.0.15.tgz#e4db219771eb7678a3bfd97b3f6c8fe20be0a410"
integrity sha512-bT2iy7FtjwgsXik4ZoJnHXR+SRCiGR1W95fVqpLZebr64m4ahwUwRbIAc5w5+2fzr1YF4Ct2eI7dojMRRl8sVQ==
tailwindcss@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.2.1.tgz#1bd828fff3172489962357f8d531c184080a6786"
integrity sha512-Uw+GVSxp5CM48krnjHObqoOwlCt5Qo6nw1jlCRwfGy68dSYb/LwS9ZFidYGRiM+w6rMawkZiu1mEMAsHYAfoLg==
dependencies:
arg "^5.0.1"
chalk "^4.1.2"
chokidar "^3.5.2"
arg "^5.0.2"
chokidar "^3.5.3"
color-name "^1.1.4"
cosmiconfig "^7.0.1"
detective "^5.2.0"
detective "^5.2.1"
didyoumean "^1.2.2"
dlv "^1.1.3"
fast-glob "^3.2.7"
fast-glob "^3.2.12"
glob-parent "^6.0.2"
is-glob "^4.0.3"
lilconfig "^2.0.6"
micromatch "^4.0.5"
normalize-path "^3.0.0"
object-hash "^2.2.0"
object-hash "^3.0.0"
picocolors "^1.0.0"
postcss "^8.4.17"
postcss-import "^14.1.0"
postcss-js "^4.0.0"
postcss-load-config "^3.1.0"
postcss-nested "5.0.6"
postcss-selector-parser "^6.0.8"
postcss-load-config "^3.1.4"
postcss-nested "6.0.0"
postcss-selector-parser "^6.0.10"
postcss-value-parser "^4.2.0"
quick-lru "^5.1.1"
resolve "^1.21.0"
resolve "^1.22.1"
tapable@^2.0, tapable@^2.0.0, tapable@^2.1.1, tapable@^2.2.0, tapable@^2.2.1:
version "2.2.1"
@ -11413,6 +11601,25 @@ ts-loader@^9.3.0:
micromatch "^4.0.0"
semver "^7.3.4"
ts-node@^10.9.1:
version "10.9.1"
resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.1.tgz#e73de9102958af9e1f0b168a6ff320e25adcff4b"
integrity sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==
dependencies:
"@cspotcode/source-map-support" "^0.8.0"
"@tsconfig/node10" "^1.0.7"
"@tsconfig/node12" "^1.0.7"
"@tsconfig/node14" "^1.0.0"
"@tsconfig/node16" "^1.0.2"
acorn "^8.4.1"
acorn-walk "^8.1.1"
arg "^4.1.0"
create-require "^1.1.0"
diff "^4.0.1"
make-error "^1.1.1"
v8-compile-cache-lib "^3.0.1"
yn "3.1.1"
tsconfig-paths@^3.12.0:
version "3.12.0"
resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.12.0.tgz#19769aca6ee8f6a1a341e38c8fa45dd9fb18899b"
@ -11733,6 +11940,11 @@ uuid@^8.3.2:
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
v8-compile-cache-lib@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf"
integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==
v8-compile-cache@^2.0.3, v8-compile-cache@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"
@ -11826,6 +12038,14 @@ watchpack@^2.3.1:
glob-to-regexp "^0.4.1"
graceful-fs "^4.1.2"
watchpack@^2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d"
integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==
dependencies:
glob-to-regexp "^0.4.1"
graceful-fs "^4.1.2"
wbuf@^1.1.0, wbuf@^1.7.3:
version "1.7.3"
resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.3.tgz#c1d8d149316d3ea852848895cb6a0bfe887b87df"
@ -11984,6 +12204,36 @@ webpack-sources@^3.2.3:
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde"
integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==
webpack@^5:
version "5.74.0"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.74.0.tgz#02a5dac19a17e0bb47093f2be67c695102a55980"
integrity sha512-A2InDwnhhGN4LYctJj6M1JEaGL7Luj6LOmyBHjcI8529cm5p6VXiTIW2sn6ffvEAKmveLzvu4jrihwXtPojlAA==
dependencies:
"@types/eslint-scope" "^3.7.3"
"@types/estree" "^0.0.51"
"@webassemblyjs/ast" "1.11.1"
"@webassemblyjs/wasm-edit" "1.11.1"
"@webassemblyjs/wasm-parser" "1.11.1"
acorn "^8.7.1"
acorn-import-assertions "^1.7.6"
browserslist "^4.14.5"
chrome-trace-event "^1.0.2"
enhanced-resolve "^5.10.0"
es-module-lexer "^0.9.0"
eslint-scope "5.1.1"
events "^3.2.0"
glob-to-regexp "^0.4.1"
graceful-fs "^4.2.9"
json-parse-even-better-errors "^2.3.1"
loader-runner "^4.2.0"
mime-types "^2.1.27"
neo-async "^2.6.2"
schema-utils "^3.1.0"
tapable "^2.1.1"
terser-webpack-plugin "^5.1.3"
watchpack "^2.4.0"
webpack-sources "^3.2.3"
webpack@^5.72.1:
version "5.72.1"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.72.1.tgz#3500fc834b4e9ba573b9f430b2c0a61e1bb57d13"
@ -12266,6 +12516,11 @@ yargs@^17.3.1:
y18n "^5.0.5"
yargs-parser "^21.0.0"
yn@3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50"
integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==
yocto-queue@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"

Loading…
Cancel
Save