Merge remote-tracking branch 'origin/develop' into chats
This commit is contained in:
commit
65a8bf9aa1
55 changed files with 1245 additions and 212 deletions
|
@ -148,6 +148,8 @@ docker:
|
||||||
image: docker:20.10.17
|
image: docker:20.10.17
|
||||||
services:
|
services:
|
||||||
- docker:20.10.17-dind
|
- docker:20.10.17-dind
|
||||||
|
tags:
|
||||||
|
- dind
|
||||||
# https://medium.com/devops-with-valentine/how-to-build-a-docker-image-and-push-it-to-the-gitlab-container-registry-from-a-gitlab-ci-pipeline-acac0d1f26df
|
# https://medium.com/devops-with-valentine/how-to-build-a-docker-image-and-push-it-to-the-gitlab-container-registry-from-a-gitlab-ci-pipeline-acac0d1f26df
|
||||||
script:
|
script:
|
||||||
- echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER $CI_REGISTRY --password-stdin
|
- echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER $CI_REGISTRY --password-stdin
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 1.4 KiB |
|
@ -1,27 +0,0 @@
|
||||||
import { staticClient } from '../api';
|
|
||||||
|
|
||||||
import type { AppDispatch } from 'soapbox/store';
|
|
||||||
|
|
||||||
const FETCH_MOBILE_PAGE_REQUEST = 'FETCH_MOBILE_PAGE_REQUEST';
|
|
||||||
const FETCH_MOBILE_PAGE_SUCCESS = 'FETCH_MOBILE_PAGE_SUCCESS';
|
|
||||||
const FETCH_MOBILE_PAGE_FAIL = 'FETCH_MOBILE_PAGE_FAIL';
|
|
||||||
|
|
||||||
const fetchMobilePage = (slug = 'index', locale?: string) =>
|
|
||||||
(dispatch: AppDispatch) => {
|
|
||||||
dispatch({ type: FETCH_MOBILE_PAGE_REQUEST, slug, locale });
|
|
||||||
const filename = `${slug}${locale ? `.${locale}` : ''}.html`;
|
|
||||||
return staticClient.get(`/instance/mobile/${filename}`).then(({ data: html }) => {
|
|
||||||
dispatch({ type: FETCH_MOBILE_PAGE_SUCCESS, slug, locale, html });
|
|
||||||
return html;
|
|
||||||
}).catch(error => {
|
|
||||||
dispatch({ type: FETCH_MOBILE_PAGE_FAIL, slug, locale, error });
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export {
|
|
||||||
FETCH_MOBILE_PAGE_REQUEST,
|
|
||||||
FETCH_MOBILE_PAGE_SUCCESS,
|
|
||||||
FETCH_MOBILE_PAGE_FAIL,
|
|
||||||
fetchMobilePage,
|
|
||||||
};
|
|
|
@ -34,22 +34,23 @@ const Announcement: React.FC<IAnnouncement> = ({ announcement, addReaction, remo
|
||||||
<Text theme='muted'>
|
<Text theme='muted'>
|
||||||
<FormattedDate
|
<FormattedDate
|
||||||
value={startsAt}
|
value={startsAt}
|
||||||
hour12={false}
|
hour12
|
||||||
year={(skipYear || startsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'}
|
year={(skipYear || startsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'}
|
||||||
month='short'
|
month='short'
|
||||||
day='2-digit'
|
day='2-digit'
|
||||||
hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'}
|
hour={skipTime ? undefined : 'numeric'}
|
||||||
|
minute={skipTime ? undefined : '2-digit'}
|
||||||
/>
|
/>
|
||||||
{' '}
|
{' '}
|
||||||
-
|
-
|
||||||
{' '}
|
{' '}
|
||||||
<FormattedDate
|
<FormattedDate
|
||||||
value={endsAt}
|
value={endsAt}
|
||||||
hour12={false}
|
hour12
|
||||||
year={(skipYear || endsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'}
|
year={(skipYear || endsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'}
|
||||||
month={skipEndDate ? undefined : 'short'}
|
month={skipEndDate ? undefined : 'short'}
|
||||||
day={skipEndDate ? undefined : '2-digit'}
|
day={skipEndDate ? undefined : '2-digit'}
|
||||||
hour={skipTime ? undefined : '2-digit'}
|
hour={skipTime ? undefined : 'numeric'}
|
||||||
minute={skipTime ? undefined : '2-digit'}
|
minute={skipTime ? undefined : '2-digit'}
|
||||||
/>
|
/>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
|
@ -6,9 +6,10 @@ import Bundle from 'soapbox/features/ui/components/bundle';
|
||||||
import { MediaGallery } from 'soapbox/features/ui/util/async-components';
|
import { MediaGallery } from 'soapbox/features/ui/util/async-components';
|
||||||
|
|
||||||
import type { List as ImmutableList } from 'immutable';
|
import type { List as ImmutableList } from 'immutable';
|
||||||
|
import type { Attachment } from 'soapbox/types/entities';
|
||||||
|
|
||||||
interface IAttachmentThumbs {
|
interface IAttachmentThumbs {
|
||||||
media: ImmutableList<Immutable.Record<any>>
|
media: ImmutableList<Attachment>
|
||||||
onClick?(): void
|
onClick?(): void
|
||||||
sensitive?: boolean
|
sensitive?: boolean
|
||||||
}
|
}
|
||||||
|
@ -18,7 +19,7 @@ const AttachmentThumbs = (props: IAttachmentThumbs) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
const renderLoading = () => <div className='media-gallery--compact' />;
|
const renderLoading = () => <div className='media-gallery--compact' />;
|
||||||
const onOpenMedia = (media: Immutable.Record<any>, index: number) => dispatch(openModal('MEDIA', { media, index }));
|
const onOpenMedia = (media: ImmutableList<Attachment>, index: number) => dispatch(openModal('MEDIA', { media, index }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='attachment-thumbs'>
|
<div className='attachment-thumbs'>
|
||||||
|
@ -30,6 +31,7 @@ const AttachmentThumbs = (props: IAttachmentThumbs) => {
|
||||||
height={50}
|
height={50}
|
||||||
compact
|
compact
|
||||||
sensitive={sensitive}
|
sensitive={sensitive}
|
||||||
|
visible
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Bundle>
|
</Bundle>
|
||||||
|
|
|
@ -263,14 +263,13 @@ const Item: React.FC<IItem> = ({
|
||||||
interface IMediaGallery {
|
interface IMediaGallery {
|
||||||
sensitive?: boolean,
|
sensitive?: boolean,
|
||||||
media: ImmutableList<Attachment>,
|
media: ImmutableList<Attachment>,
|
||||||
size: number,
|
|
||||||
height: number,
|
height: number,
|
||||||
onOpenMedia: (media: ImmutableList<Attachment>, index: number) => void,
|
onOpenMedia: (media: ImmutableList<Attachment>, index: number) => void,
|
||||||
defaultWidth: number,
|
defaultWidth?: number,
|
||||||
cacheWidth: (width: number) => void,
|
cacheWidth?: (width: number) => void,
|
||||||
visible?: boolean,
|
visible?: boolean,
|
||||||
onToggleVisibility?: () => void,
|
onToggleVisibility?: () => void,
|
||||||
displayMedia: string,
|
displayMedia?: string,
|
||||||
compact: boolean,
|
compact: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -278,7 +277,7 @@ const MediaGallery: React.FC<IMediaGallery> = (props) => {
|
||||||
const {
|
const {
|
||||||
media,
|
media,
|
||||||
sensitive = false,
|
sensitive = false,
|
||||||
defaultWidth,
|
defaultWidth = 0,
|
||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
onOpenMedia,
|
onOpenMedia,
|
||||||
cacheWidth,
|
cacheWidth,
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import classNames from 'clsx';
|
import classNames from 'clsx';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { useIntl, FormattedMessage } from 'react-intl';
|
||||||
import { usePopper } from 'react-popper';
|
import { usePopper } from 'react-popper';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
@ -15,9 +15,10 @@ import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
|
||||||
import { UserPanel } from 'soapbox/features/ui/util/async-components';
|
import { UserPanel } from 'soapbox/features/ui/util/async-components';
|
||||||
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
||||||
import { makeGetAccount } from 'soapbox/selectors';
|
import { makeGetAccount } from 'soapbox/selectors';
|
||||||
|
import { isLocal } from 'soapbox/utils/accounts';
|
||||||
|
|
||||||
import { showProfileHoverCard } from './hover_ref_wrapper';
|
import { showProfileHoverCard } from './hover_ref_wrapper';
|
||||||
import { Card, CardBody, Stack, Text } from './ui';
|
import { Card, CardBody, HStack, Icon, Stack, Text } from './ui';
|
||||||
|
|
||||||
import type { AppDispatch } from 'soapbox/store';
|
import type { AppDispatch } from 'soapbox/store';
|
||||||
import type { Account } from 'soapbox/types/entities';
|
import type { Account } from 'soapbox/types/entities';
|
||||||
|
@ -60,6 +61,7 @@ interface IProfileHoverCard {
|
||||||
export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }) => {
|
export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
const [popperElement, setPopperElement] = useState<HTMLElement | null>(null);
|
const [popperElement, setPopperElement] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
@ -88,6 +90,7 @@ export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }
|
||||||
|
|
||||||
if (!account) return null;
|
if (!account) return null;
|
||||||
const accountBio = { __html: account.note_emojified };
|
const accountBio = { __html: account.note_emojified };
|
||||||
|
const memberSinceDate = intl.formatDate(account.created_at, { month: 'long', year: 'numeric' });
|
||||||
const followedBy = me !== account.id && account.relationship?.followed_by === true;
|
const followedBy = me !== account.id && account.relationship?.followed_by === true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -116,6 +119,23 @@ export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }
|
||||||
)}
|
)}
|
||||||
</BundleContainer>
|
</BundleContainer>
|
||||||
|
|
||||||
|
{isLocal(account) ? (
|
||||||
|
<HStack alignItems='center' space={0.5}>
|
||||||
|
<Icon
|
||||||
|
src={require('@tabler/icons/calendar.svg')}
|
||||||
|
className='w-4 h-4 text-gray-800 dark:text-gray-200'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text size='sm'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account.member_since' defaultMessage='Joined {date}' values={{
|
||||||
|
date: memberSinceDate,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{account.source.get('note', '').length > 0 && (
|
{account.source.get('note', '').length > 0 && (
|
||||||
<Text size='sm' dangerouslySetInnerHTML={accountBio} />
|
<Text size='sm' dangerouslySetInnerHTML={accountBio} />
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -17,11 +17,11 @@ const messages = defineMessages({
|
||||||
});
|
});
|
||||||
|
|
||||||
const dateFormatOptions: FormatDateOptions = {
|
const dateFormatOptions: FormatDateOptions = {
|
||||||
hour12: false,
|
hour12: true,
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
hour: '2-digit',
|
hour: 'numeric',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -32,8 +32,8 @@ const shortDateFormatOptions: FormatDateOptions = {
|
||||||
|
|
||||||
const SECOND = 1000;
|
const SECOND = 1000;
|
||||||
const MINUTE = 1000 * 60;
|
const MINUTE = 1000 * 60;
|
||||||
const HOUR = 1000 * 60 * 60;
|
const HOUR = 1000 * 60 * 60;
|
||||||
const DAY = 1000 * 60 * 60 * 24;
|
const DAY = 1000 * 60 * 60 * 24;
|
||||||
|
|
||||||
const MAX_DELAY = 2147483647;
|
const MAX_DELAY = 2147483647;
|
||||||
|
|
||||||
|
@ -170,12 +170,12 @@ class RelativeTimestamp extends React.Component<RelativeTimestampProps, Relative
|
||||||
clearTimeout(this._timer);
|
clearTimeout(this._timer);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { timestamp } = this.props;
|
const { timestamp } = this.props;
|
||||||
const delta = (new Date(timestamp)).getTime() - this.state.now;
|
const delta = (new Date(timestamp)).getTime() - this.state.now;
|
||||||
const unitDelay = getUnitDelay(selectUnits(delta));
|
const unitDelay = getUnitDelay(selectUnits(delta));
|
||||||
const unitRemainder = Math.abs(delta % unitDelay);
|
const unitRemainder = Math.abs(delta % unitDelay);
|
||||||
const updateInterval = 1000 * 10;
|
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._timer = setTimeout(() => {
|
||||||
this.setState({ now: Date.now() });
|
this.setState({ now: Date.now() });
|
||||||
|
|
|
@ -374,7 +374,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||||
menu.push({
|
menu.push({
|
||||||
text: intl.formatMessage(status.pinned ? messages.unpin : messages.pin),
|
text: intl.formatMessage(status.pinned ? messages.unpin : messages.pin),
|
||||||
action: handlePinClick,
|
action: handlePinClick,
|
||||||
icon: mutingConversation ? require('@tabler/icons/pinned-off.svg') : require('@tabler/icons/pin.svg'),
|
icon: status.pinned ? require('@tabler/icons/pinned-off.svg') : require('@tabler/icons/pin.svg'),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (status.visibility === 'private') {
|
if (status.visibility === 'private') {
|
||||||
|
|
|
@ -1,12 +1,32 @@
|
||||||
import classNames from 'clsx';
|
import classNames from 'clsx';
|
||||||
|
|
||||||
type ButtonThemes = 'primary' | 'secondary' | 'tertiary' | 'accent' | 'danger' | 'transparent' | 'outline'
|
const themes = {
|
||||||
type ButtonSizes = 'sm' | 'md' | 'lg'
|
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 = {
|
type IButtonStyles = {
|
||||||
theme: ButtonThemes,
|
theme: ButtonThemes
|
||||||
block: boolean,
|
block: boolean
|
||||||
disabled: boolean,
|
disabled: boolean
|
||||||
size: ButtonSizes
|
size: ButtonSizes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -17,26 +37,6 @@ const useButtonStyles = ({
|
||||||
disabled,
|
disabled,
|
||||||
size,
|
size,
|
||||||
}: IButtonStyles) => {
|
}: 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({
|
const buttonStyle = classNames({
|
||||||
'inline-flex items-center border font-medium rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 appearance-none transition-all': true,
|
'inline-flex items-center border font-medium rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 appearance-none transition-all': true,
|
||||||
'select-none disabled:opacity-75 disabled:cursor-default': disabled,
|
'select-none disabled:opacity-75 disabled:cursor-default': disabled,
|
||||||
|
|
|
@ -18,13 +18,13 @@ const messages = defineMessages({
|
||||||
|
|
||||||
interface ICard {
|
interface ICard {
|
||||||
/** The type of card. */
|
/** The type of card. */
|
||||||
variant?: 'default' | 'rounded',
|
variant?: 'default' | 'rounded'
|
||||||
/** Card size preset. */
|
/** Card size preset. */
|
||||||
size?: 'md' | 'lg' | 'xl',
|
size?: keyof typeof sizes
|
||||||
/** Extra classnames for the <div> element. */
|
/** Extra classnames for the <div> element. */
|
||||||
className?: string,
|
className?: string
|
||||||
/** Elements inside the card. */
|
/** Elements inside the card. */
|
||||||
children: React.ReactNode,
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An opaque backdrop to hold a collection of related elements. */
|
/** An opaque backdrop to hold a collection of related elements. */
|
||||||
|
|
|
@ -17,7 +17,7 @@ const alignItemsOptions = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const spaces = {
|
const spaces = {
|
||||||
'0.5': 'space-x-0.5',
|
[0.5]: 'space-x-0.5',
|
||||||
1: 'space-x-1',
|
1: 'space-x-1',
|
||||||
1.5: 'space-x-1.5',
|
1.5: 'space-x-1.5',
|
||||||
2: 'space-x-2',
|
2: 'space-x-2',
|
||||||
|
@ -29,21 +29,21 @@ const spaces = {
|
||||||
|
|
||||||
interface IHStack {
|
interface IHStack {
|
||||||
/** Vertical alignment of children. */
|
/** Vertical alignment of children. */
|
||||||
alignItems?: 'top' | 'bottom' | 'center' | 'start',
|
alignItems?: keyof typeof alignItemsOptions
|
||||||
/** Extra class names on the <div> element. */
|
/** Extra class names on the <div> element. */
|
||||||
className?: string,
|
className?: string
|
||||||
/** Children */
|
/** Children */
|
||||||
children?: React.ReactNode,
|
children?: React.ReactNode
|
||||||
/** Horizontal alignment of children. */
|
/** Horizontal alignment of children. */
|
||||||
justifyContent?: 'between' | 'center' | 'start' | 'end' | 'around',
|
justifyContent?: keyof typeof justifyContentOptions
|
||||||
/** Size of the gap between elements. */
|
/** Size of the gap between elements. */
|
||||||
space?: 0.5 | 1 | 1.5 | 2 | 3 | 4 | 6 | 8,
|
space?: keyof typeof spaces
|
||||||
/** Whether to let the flexbox grow. */
|
/** Whether to let the flexbox grow. */
|
||||||
grow?: boolean,
|
grow?: boolean
|
||||||
/** Extra CSS styles for the <div> */
|
/** Extra CSS styles for the <div> */
|
||||||
style?: React.CSSProperties
|
style?: React.CSSProperties
|
||||||
/** Whether to let the flexbox wrap onto multiple lines. */
|
/** Whether to let the flexbox wrap onto multiple lines. */
|
||||||
wrap?: boolean,
|
wrap?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Horizontal row of child elements. */
|
/** Horizontal row of child elements. */
|
||||||
|
|
|
@ -11,8 +11,6 @@ const messages = defineMessages({
|
||||||
confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
||||||
});
|
});
|
||||||
|
|
||||||
type Widths = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl'
|
|
||||||
|
|
||||||
const widths = {
|
const widths = {
|
||||||
xs: 'max-w-xs',
|
xs: 'max-w-xs',
|
||||||
sm: 'max-w-sm',
|
sm: 'max-w-sm',
|
||||||
|
@ -52,7 +50,7 @@ interface IModal {
|
||||||
skipFocus?: boolean,
|
skipFocus?: boolean,
|
||||||
/** Title text for the modal. */
|
/** Title text for the modal. */
|
||||||
title?: React.ReactNode,
|
title?: React.ReactNode,
|
||||||
width?: Widths,
|
width?: keyof typeof widths,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Displays a modal dialog box. */
|
/** Displays a modal dialog box. */
|
||||||
|
|
|
@ -1,13 +1,11 @@
|
||||||
import classNames from 'clsx';
|
import classNames from 'clsx';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
type SIZES = 0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 10
|
|
||||||
|
|
||||||
const spaces = {
|
const spaces = {
|
||||||
0: 'space-y-0',
|
0: 'space-y-0',
|
||||||
'0.5': 'space-y-0.5',
|
[0.5]: 'space-y-0.5',
|
||||||
1: 'space-y-1',
|
1: 'space-y-1',
|
||||||
'1.5': 'space-y-1.5',
|
[1.5]: 'space-y-1.5',
|
||||||
2: 'space-y-2',
|
2: 'space-y-2',
|
||||||
3: 'space-y-3',
|
3: 'space-y-3',
|
||||||
4: 'space-y-4',
|
4: 'space-y-4',
|
||||||
|
@ -27,15 +25,15 @@ const alignItemsOptions = {
|
||||||
|
|
||||||
interface IStack extends React.HTMLAttributes<HTMLDivElement> {
|
interface IStack extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
/** Size of the gap between elements. */
|
/** Size of the gap between elements. */
|
||||||
space?: SIZES,
|
space?: keyof typeof spaces
|
||||||
/** Horizontal alignment of children. */
|
/** Horizontal alignment of children. */
|
||||||
alignItems?: 'center' | 'start',
|
alignItems?: 'center' | 'start',
|
||||||
/** Vertical alignment of children. */
|
/** Vertical alignment of children. */
|
||||||
justifyContent?: 'center',
|
justifyContent?: 'center'
|
||||||
/** Extra class names on the <div> element. */
|
/** Extra class names on the <div> element. */
|
||||||
className?: string,
|
className?: string
|
||||||
/** Whether to let the flexbox grow. */
|
/** Whether to let the flexbox grow. */
|
||||||
grow?: boolean,
|
grow?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vertical stack of child elements. */
|
/** Vertical stack of child elements. */
|
||||||
|
|
Binary file not shown.
|
@ -1,16 +1,6 @@
|
||||||
import classNames from 'clsx';
|
import classNames from 'clsx';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
type Themes = 'default' | 'danger' | 'primary' | 'muted' | 'subtle' | 'success' | 'inherit' | 'white'
|
|
||||||
type Weights = 'normal' | 'medium' | 'semibold' | 'bold'
|
|
||||||
export type Sizes = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'
|
|
||||||
type Alignments = 'left' | 'center' | 'right'
|
|
||||||
type TrackingSizes = 'normal' | 'wide'
|
|
||||||
type TransformProperties = 'uppercase' | 'normal'
|
|
||||||
type Families = 'sans' | 'mono'
|
|
||||||
type Tags = 'abbr' | 'p' | 'span' | 'pre' | 'time' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'label'
|
|
||||||
type Directions = 'ltr' | 'rtl'
|
|
||||||
|
|
||||||
const themes = {
|
const themes = {
|
||||||
default: 'text-gray-900 dark:text-gray-100',
|
default: 'text-gray-900 dark:text-gray-100',
|
||||||
danger: 'text-danger-600',
|
danger: 'text-danger-600',
|
||||||
|
@ -60,15 +50,19 @@ const families = {
|
||||||
mono: 'font-mono',
|
mono: 'font-mono',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Sizes = keyof typeof sizes
|
||||||
|
type Tags = 'abbr' | 'p' | 'span' | 'pre' | 'time' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'label'
|
||||||
|
type Directions = 'ltr' | 'rtl'
|
||||||
|
|
||||||
interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'dangerouslySetInnerHTML'> {
|
interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'dangerouslySetInnerHTML'> {
|
||||||
/** How to align the text. */
|
/** How to align the text. */
|
||||||
align?: Alignments,
|
align?: keyof typeof alignments,
|
||||||
/** Extra class names for the outer element. */
|
/** Extra class names for the outer element. */
|
||||||
className?: string,
|
className?: string,
|
||||||
/** Text direction. */
|
/** Text direction. */
|
||||||
direction?: Directions,
|
direction?: Directions,
|
||||||
/** Typeface of the text. */
|
/** Typeface of the text. */
|
||||||
family?: Families,
|
family?: keyof typeof families,
|
||||||
/** The "for" attribute specifies which form element a label is bound to. */
|
/** The "for" attribute specifies which form element a label is bound to. */
|
||||||
htmlFor?: string,
|
htmlFor?: string,
|
||||||
/** Font size of the text. */
|
/** Font size of the text. */
|
||||||
|
@ -76,15 +70,15 @@ interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'danger
|
||||||
/** HTML element name of the outer element. */
|
/** HTML element name of the outer element. */
|
||||||
tag?: Tags,
|
tag?: Tags,
|
||||||
/** Theme for the text. */
|
/** Theme for the text. */
|
||||||
theme?: Themes,
|
theme?: keyof typeof themes,
|
||||||
/** Letter-spacing of the text. */
|
/** Letter-spacing of the text. */
|
||||||
tracking?: TrackingSizes,
|
tracking?: keyof typeof trackingSizes,
|
||||||
/** Transform (eg uppercase) for the text. */
|
/** Transform (eg uppercase) for the text. */
|
||||||
transform?: TransformProperties,
|
transform?: keyof typeof transformProperties,
|
||||||
/** Whether to truncate the text if its container is too small. */
|
/** Whether to truncate the text if its container is too small. */
|
||||||
truncate?: boolean,
|
truncate?: boolean,
|
||||||
/** Font weight of the text. */
|
/** Font weight of the text. */
|
||||||
weight?: Weights,
|
weight?: keyof typeof weights,
|
||||||
/** Tooltip title. */
|
/** Tooltip title. */
|
||||||
title?: string,
|
title?: string,
|
||||||
}
|
}
|
||||||
|
|
|
@ -139,7 +139,6 @@ const SoapboxMount = () => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Route exact path='/about/:slug?' component={PublicLayout} />
|
<Route exact path='/about/:slug?' component={PublicLayout} />
|
||||||
<Route exact path='/mobile/:slug?' component={PublicLayout} />
|
|
||||||
<Route path='/login' component={AuthLayout} />
|
<Route path='/login' component={AuthLayout} />
|
||||||
|
|
||||||
{(features.accountCreation && instance.registrations) && (
|
{(features.accountCreation && instance.registrations) && (
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
import { List as ImmutableList } from 'immutable';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
import { Link, useHistory } from 'react-router-dom';
|
import { Link, useHistory } from 'react-router-dom';
|
||||||
|
@ -23,6 +23,7 @@ import MovedNote from 'soapbox/features/account_timeline/components/moved_note';
|
||||||
import ActionButton from 'soapbox/features/ui/components/action-button';
|
import ActionButton from 'soapbox/features/ui/components/action-button';
|
||||||
import SubscriptionButton from 'soapbox/features/ui/components/subscription-button';
|
import SubscriptionButton from 'soapbox/features/ui/components/subscription-button';
|
||||||
import { useAppDispatch, useFeatures, useOwnAccount } from 'soapbox/hooks';
|
import { useAppDispatch, useFeatures, useOwnAccount } from 'soapbox/hooks';
|
||||||
|
import { normalizeAttachment } from 'soapbox/normalizers';
|
||||||
import { Account } from 'soapbox/types/entities';
|
import { Account } from 'soapbox/types/entities';
|
||||||
import { isRemote } from 'soapbox/utils/accounts';
|
import { isRemote } from 'soapbox/utils/accounts';
|
||||||
|
|
||||||
|
@ -207,12 +208,9 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAvatarClick = () => {
|
const onAvatarClick = () => {
|
||||||
const avatar_url = account.avatar;
|
const avatar = normalizeAttachment({
|
||||||
const avatar = ImmutableMap({
|
|
||||||
type: 'image',
|
type: 'image',
|
||||||
preview_url: avatar_url,
|
url: account.avatar,
|
||||||
url: avatar_url,
|
|
||||||
description: '',
|
|
||||||
});
|
});
|
||||||
dispatch(openModal('MEDIA', { media: ImmutableList.of(avatar), index: 0 }));
|
dispatch(openModal('MEDIA', { media: ImmutableList.of(avatar), index: 0 }));
|
||||||
};
|
};
|
||||||
|
@ -225,12 +223,9 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const onHeaderClick = () => {
|
const onHeaderClick = () => {
|
||||||
const header_url = account.header;
|
const header = normalizeAttachment({
|
||||||
const header = ImmutableMap({
|
|
||||||
type: 'image',
|
type: 'image',
|
||||||
preview_url: header_url,
|
url: account.header,
|
||||||
url: header_url,
|
|
||||||
description: '',
|
|
||||||
});
|
});
|
||||||
dispatch(openModal('MEDIA', { media: ImmutableList.of(header), index: 0 }));
|
dispatch(openModal('MEDIA', { media: ImmutableList.of(header), index: 0 }));
|
||||||
};
|
};
|
||||||
|
|
|
@ -32,7 +32,7 @@ const ModerationLog = () => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setLastPage(1);
|
setLastPage(1);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => { });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLoadMore = () => {
|
const handleLoadMore = () => {
|
||||||
|
@ -43,7 +43,7 @@ const ModerationLog = () => {
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setLastPage(page);
|
setLastPage(page);
|
||||||
}).catch(() => {});
|
}).catch(() => { });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -62,11 +62,11 @@ const ModerationLog = () => {
|
||||||
<div className='logentry__timestamp'>
|
<div className='logentry__timestamp'>
|
||||||
<FormattedDate
|
<FormattedDate
|
||||||
value={new Date(item.time * 1000)}
|
value={new Date(item.time * 1000)}
|
||||||
hour12={false}
|
hour12
|
||||||
year='numeric'
|
year='numeric'
|
||||||
month='short'
|
month='short'
|
||||||
day='2-digit'
|
day='2-digit'
|
||||||
hour='2-digit'
|
hour='numeric'
|
||||||
minute='2-digit'
|
minute='2-digit'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -23,7 +23,7 @@ interface IAccount {
|
||||||
const Account: React.FC<IAccount> = ({ accountId, aliases }) => {
|
const Account: React.FC<IAccount> = ({ accountId, aliases }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const getAccount = useCallback(makeGetAccount(), []);
|
const getAccount = useCallback(makeGetAccount(), []);
|
||||||
|
|
||||||
const account = useAppSelector((state) => getAccount(state, accountId));
|
const account = useAppSelector((state) => getAccount(state, accountId));
|
||||||
|
|
Binary file not shown.
583
app/soapbox/features/audio/index.tsx
Normal file
583
app/soapbox/features/audio/index.tsx
Normal file
|
@ -0,0 +1,583 @@
|
||||||
|
import classNames from 'clsx';
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
|
import throttle from 'lodash/throttle';
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import Icon from 'soapbox/components/icon';
|
||||||
|
import { formatTime, getPointerPosition } from 'soapbox/features/video';
|
||||||
|
|
||||||
|
import Visualizer from './visualizer';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
play: { id: 'video.play', defaultMessage: 'Play' },
|
||||||
|
pause: { id: 'video.pause', defaultMessage: 'Pause' },
|
||||||
|
mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
|
||||||
|
unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
|
||||||
|
download: { id: 'video.download', defaultMessage: 'Download file' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const TICK_SIZE = 10;
|
||||||
|
const PADDING = 180;
|
||||||
|
|
||||||
|
interface IAudio {
|
||||||
|
src: string,
|
||||||
|
alt?: string,
|
||||||
|
poster?: string,
|
||||||
|
duration?: number,
|
||||||
|
width?: number,
|
||||||
|
height?: number,
|
||||||
|
editable?: boolean,
|
||||||
|
fullscreen?: boolean,
|
||||||
|
cacheWidth?: (width: number) => void,
|
||||||
|
backgroundColor?: string,
|
||||||
|
foregroundColor?: string,
|
||||||
|
accentColor?: string,
|
||||||
|
currentTime?: number,
|
||||||
|
autoPlay?: boolean,
|
||||||
|
volume?: number,
|
||||||
|
muted?: boolean,
|
||||||
|
deployPictureInPicture?: (type: string, opts: Record<string, any>) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
const Audio: React.FC<IAudio> = (props) => {
|
||||||
|
const {
|
||||||
|
src,
|
||||||
|
alt = '',
|
||||||
|
poster,
|
||||||
|
accentColor,
|
||||||
|
backgroundColor,
|
||||||
|
foregroundColor,
|
||||||
|
cacheWidth,
|
||||||
|
fullscreen,
|
||||||
|
autoPlay,
|
||||||
|
editable,
|
||||||
|
deployPictureInPicture = false,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const [width, setWidth] = useState<number | undefined>(props.width);
|
||||||
|
const [height, setHeight] = useState<number | undefined>(props.height);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [buffer, setBuffer] = useState(0);
|
||||||
|
const [duration, setDuration] = useState<number | undefined>(undefined);
|
||||||
|
const [paused, setPaused] = useState(true);
|
||||||
|
const [muted, setMuted] = useState(false);
|
||||||
|
const [volume, setVolume] = useState(0.5);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
const visualizer = useRef<Visualizer>(new Visualizer(TICK_SIZE));
|
||||||
|
const audioContext = useRef<AudioContext | null>(null);
|
||||||
|
|
||||||
|
const player = useRef<HTMLDivElement>(null);
|
||||||
|
const audio = useRef<HTMLAudioElement>(null);
|
||||||
|
const seek = useRef<HTMLDivElement>(null);
|
||||||
|
const slider = useRef<HTMLDivElement>(null);
|
||||||
|
const canvas = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
const _pack = () => ({
|
||||||
|
src: props.src,
|
||||||
|
volume: audio.current?.volume,
|
||||||
|
muted: audio.current?.muted,
|
||||||
|
currentTime: audio.current?.currentTime,
|
||||||
|
poster: props.poster,
|
||||||
|
backgroundColor: props.backgroundColor,
|
||||||
|
foregroundColor: props.foregroundColor,
|
||||||
|
accentColor: props.accentColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
const _setDimensions = () => {
|
||||||
|
if (player.current) {
|
||||||
|
const width = player.current.offsetWidth;
|
||||||
|
const height = fullscreen ? player.current.offsetHeight : (width / (16 / 9));
|
||||||
|
|
||||||
|
if (cacheWidth) {
|
||||||
|
cacheWidth(width);
|
||||||
|
}
|
||||||
|
|
||||||
|
setWidth(width);
|
||||||
|
setHeight(height);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (!audioContext.current) {
|
||||||
|
_initAudioContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paused) {
|
||||||
|
audio.current?.play();
|
||||||
|
} else {
|
||||||
|
audio.current?.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
setPaused(!paused);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResize = debounce(() => {
|
||||||
|
if (player.current) {
|
||||||
|
_setDimensions();
|
||||||
|
}
|
||||||
|
}, 250, {
|
||||||
|
trailing: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePlay = () => {
|
||||||
|
setPaused(false);
|
||||||
|
|
||||||
|
if (audioContext.current?.state === 'suspended') {
|
||||||
|
audioContext.current?.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderCanvas();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePause = () => {
|
||||||
|
setPaused(true);
|
||||||
|
audioContext.current?.suspend();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProgress = () => {
|
||||||
|
if (audio.current) {
|
||||||
|
const lastTimeRange = audio.current.buffered.length - 1;
|
||||||
|
|
||||||
|
if (lastTimeRange > -1) {
|
||||||
|
setBuffer(Math.ceil(audio.current.buffered.end(lastTimeRange) / audio.current.duration * 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
const nextMuted = !muted;
|
||||||
|
|
||||||
|
setMuted(nextMuted);
|
||||||
|
|
||||||
|
if (audio.current) {
|
||||||
|
audio.current.muted = nextMuted;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVolumeMouseDown: React.MouseEventHandler = e => {
|
||||||
|
document.addEventListener('mousemove', handleMouseVolSlide, true);
|
||||||
|
document.addEventListener('mouseup', handleVolumeMouseUp, true);
|
||||||
|
document.addEventListener('touchmove', handleMouseVolSlide, true);
|
||||||
|
document.addEventListener('touchend', handleVolumeMouseUp, true);
|
||||||
|
|
||||||
|
handleMouseVolSlide(e);
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVolumeMouseUp = () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseVolSlide, true);
|
||||||
|
document.removeEventListener('mouseup', handleVolumeMouseUp, true);
|
||||||
|
document.removeEventListener('touchmove', handleMouseVolSlide, true);
|
||||||
|
document.removeEventListener('touchend', handleVolumeMouseUp, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseDown: React.MouseEventHandler = e => {
|
||||||
|
document.addEventListener('mousemove', handleMouseMove, true);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp, true);
|
||||||
|
document.addEventListener('touchmove', handleMouseMove, true);
|
||||||
|
document.addEventListener('touchend', handleMouseUp, true);
|
||||||
|
|
||||||
|
setDragging(true);
|
||||||
|
audio.current?.pause();
|
||||||
|
handleMouseMove(e);
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove, true);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp, true);
|
||||||
|
document.removeEventListener('touchmove', handleMouseMove, true);
|
||||||
|
document.removeEventListener('touchend', handleMouseUp, true);
|
||||||
|
|
||||||
|
setDragging(false);
|
||||||
|
audio.current?.play();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = throttle((e) => {
|
||||||
|
if (audio.current && seek.current) {
|
||||||
|
const { x } = getPointerPosition(seek.current, e);
|
||||||
|
const currentTime = audio.current.duration * x;
|
||||||
|
|
||||||
|
if (!isNaN(currentTime)) {
|
||||||
|
setCurrentTime(currentTime);
|
||||||
|
audio.current.currentTime = currentTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 15);
|
||||||
|
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
if (audio.current) {
|
||||||
|
setCurrentTime(audio.current.currentTime);
|
||||||
|
setDuration(audio.current.duration);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseVolSlide = throttle(e => {
|
||||||
|
if (audio.current && slider.current) {
|
||||||
|
const { x } = getPointerPosition(slider.current, e);
|
||||||
|
|
||||||
|
if (!isNaN(x)) {
|
||||||
|
setVolume(x);
|
||||||
|
audio.current.volume = x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 15);
|
||||||
|
|
||||||
|
const handleScroll = throttle(() => {
|
||||||
|
if (!canvas.current || !audio.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { top, height } = canvas.current.getBoundingClientRect();
|
||||||
|
const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
|
||||||
|
|
||||||
|
if (!paused && !inView) {
|
||||||
|
audio.current.pause();
|
||||||
|
|
||||||
|
if (deployPictureInPicture) {
|
||||||
|
deployPictureInPicture('audio', _pack());
|
||||||
|
}
|
||||||
|
|
||||||
|
setPaused(true);
|
||||||
|
}
|
||||||
|
}, 150, { trailing: true });
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
setHovered(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
setHovered(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadedData = () => {
|
||||||
|
if (audio.current) {
|
||||||
|
setDuration(audio.current.duration);
|
||||||
|
|
||||||
|
if (currentTime) {
|
||||||
|
audio.current.currentTime = currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (volume !== undefined) {
|
||||||
|
audio.current.volume = volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (muted !== undefined) {
|
||||||
|
audio.current.muted = muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoPlay) {
|
||||||
|
togglePlay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _initAudioContext = () => {
|
||||||
|
if (audio.current) {
|
||||||
|
// @ts-ignore
|
||||||
|
// eslint-disable-next-line compat/compat
|
||||||
|
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||||
|
const context = new AudioContext();
|
||||||
|
const source = context.createMediaElementSource(audio.current);
|
||||||
|
|
||||||
|
visualizer.current.setAudioContext(context, source);
|
||||||
|
source.connect(context.destination);
|
||||||
|
|
||||||
|
audioContext.current = context;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _renderCanvas = () => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!audio.current) return;
|
||||||
|
|
||||||
|
handleTimeUpdate();
|
||||||
|
_clear();
|
||||||
|
_draw();
|
||||||
|
|
||||||
|
if (!paused) {
|
||||||
|
_renderCanvas();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const _clear = () => {
|
||||||
|
visualizer.current?.clear(width || 0, height || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _draw = () => {
|
||||||
|
visualizer.current?.draw(_getCX(), _getCY(), _getAccentColor(), _getRadius(), _getScaleCoefficient());
|
||||||
|
};
|
||||||
|
|
||||||
|
const _getRadius = (): number => {
|
||||||
|
return ((height || props.height || 0) - (PADDING * _getScaleCoefficient()) * 2) / 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
const _getScaleCoefficient = (): number => {
|
||||||
|
return (height || props.height || 0) / 982;
|
||||||
|
};
|
||||||
|
|
||||||
|
const _getCX = (): number => {
|
||||||
|
return Math.floor((width || 0) / 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _getCY = (): number => {
|
||||||
|
return Math.floor(_getRadius() + (PADDING * _getScaleCoefficient()));
|
||||||
|
};
|
||||||
|
|
||||||
|
const _getAccentColor = (): string => {
|
||||||
|
return accentColor || '#ffffff';
|
||||||
|
};
|
||||||
|
|
||||||
|
const _getBackgroundColor = (): string => {
|
||||||
|
return backgroundColor || '#000000';
|
||||||
|
};
|
||||||
|
|
||||||
|
const _getForegroundColor = (): string => {
|
||||||
|
return foregroundColor || '#ffffff';
|
||||||
|
};
|
||||||
|
|
||||||
|
const seekBy = (time: number) => {
|
||||||
|
if (audio.current) {
|
||||||
|
const currentTime = audio.current.currentTime + time;
|
||||||
|
|
||||||
|
if (!isNaN(currentTime)) {
|
||||||
|
setCurrentTime(currentTime);
|
||||||
|
audio.current.currentTime = currentTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAudioKeyDown: React.KeyboardEventHandler = e => {
|
||||||
|
// On the audio element or the seek bar, we can safely use the space bar
|
||||||
|
// for playback control because there are no buttons to press
|
||||||
|
|
||||||
|
if (e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
togglePlay();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown: React.KeyboardEventHandler = e => {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'k':
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
togglePlay();
|
||||||
|
break;
|
||||||
|
case 'm':
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleMute();
|
||||||
|
break;
|
||||||
|
case 'j':
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
seekBy(-10);
|
||||||
|
break;
|
||||||
|
case 'l':
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
seekBy(10);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDuration = () => duration || props.duration || 0;
|
||||||
|
|
||||||
|
const progress = Math.min((currentTime / getDuration()) * 100, 100);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (player.current) {
|
||||||
|
_setDimensions();
|
||||||
|
}
|
||||||
|
}, [player.current]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (audio.current) {
|
||||||
|
setVolume(audio.current.volume);
|
||||||
|
setMuted(audio.current.muted);
|
||||||
|
}
|
||||||
|
}, [audio.current]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (canvas.current && visualizer.current) {
|
||||||
|
visualizer.current.setCanvas(canvas.current);
|
||||||
|
}
|
||||||
|
}, [canvas.current, visualizer.current]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('scroll', handleScroll);
|
||||||
|
window.addEventListener('resize', handleResize, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', handleScroll);
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
if (!paused && audio.current && deployPictureInPicture) {
|
||||||
|
deployPictureInPicture('audio', _pack());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
_clear();
|
||||||
|
_draw();
|
||||||
|
}, [src, width, height, accentColor]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames('audio-player', { editable })}
|
||||||
|
ref={player}
|
||||||
|
style={{
|
||||||
|
backgroundColor: _getBackgroundColor(),
|
||||||
|
color: _getForegroundColor(),
|
||||||
|
width: '100%',
|
||||||
|
height: fullscreen ? '100%' : (height || props.height),
|
||||||
|
}}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<audio
|
||||||
|
src={src}
|
||||||
|
ref={audio}
|
||||||
|
preload='auto'
|
||||||
|
onPlay={handlePlay}
|
||||||
|
onPause={handlePause}
|
||||||
|
onProgress={handleProgress}
|
||||||
|
onLoadedData={handleLoadedData}
|
||||||
|
crossOrigin='anonymous'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<canvas
|
||||||
|
role='button'
|
||||||
|
tabIndex={0}
|
||||||
|
className='audio-player__canvas'
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
style={{ width: '100%', position: 'absolute', top: 0, left: 0 }}
|
||||||
|
ref={canvas}
|
||||||
|
onClick={togglePlay}
|
||||||
|
onKeyDown={handleAudioKeyDown}
|
||||||
|
title={alt}
|
||||||
|
aria-label={alt}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{poster && (
|
||||||
|
<img
|
||||||
|
src={poster}
|
||||||
|
alt=''
|
||||||
|
width={(_getRadius() - TICK_SIZE) * 2}
|
||||||
|
height={(_getRadius() - TICK_SIZE) * 2}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: _getCX(),
|
||||||
|
top: _getCY(),
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='video-player__seek' onMouseDown={handleMouseDown} ref={seek}>
|
||||||
|
|
||||||
|
<div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
className='video-player__seek__progress'
|
||||||
|
style={{ width: `${progress}%`, backgroundColor: _getAccentColor() }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={classNames('video-player__seek__handle', { active: dragging })}
|
||||||
|
tabIndex={0}
|
||||||
|
style={{ left: `${progress}%`, backgroundColor: _getAccentColor() }}
|
||||||
|
onKeyDown={handleAudioKeyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='video-player__controls active'>
|
||||||
|
<div className='video-player__buttons-bar'>
|
||||||
|
<div className='video-player__buttons left'>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
title={intl.formatMessage(paused ? messages.play : messages.pause)}
|
||||||
|
aria-label={intl.formatMessage(paused ? messages.play : messages.pause)}
|
||||||
|
className='player-button'
|
||||||
|
onClick={togglePlay}
|
||||||
|
>
|
||||||
|
<Icon src={paused ? require('@tabler/icons/player-play.svg') : require('@tabler/icons/player-pause.svg')} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
title={intl.formatMessage(muted ? messages.unmute : messages.mute)}
|
||||||
|
aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)}
|
||||||
|
className='player-button'
|
||||||
|
onClick={toggleMute}
|
||||||
|
>
|
||||||
|
<Icon src={muted ? require('@tabler/icons/volume-3.svg') : require('@tabler/icons/volume.svg')} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={classNames('video-player__volume', { active: hovered })}
|
||||||
|
ref={slider}
|
||||||
|
onMouseDown={handleVolumeMouseDown}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='video-player__volume__current'
|
||||||
|
style={{
|
||||||
|
width: `${volume * 100}%`,
|
||||||
|
backgroundColor: _getAccentColor(),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className='video-player__volume__handle'
|
||||||
|
tabIndex={0}
|
||||||
|
style={{ left: `${volume * 100}%`, backgroundColor: _getAccentColor() }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className='video-player__time'>
|
||||||
|
<span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
|
||||||
|
{getDuration() && (<>
|
||||||
|
<span className='video-player__time-sep'>/</span>
|
||||||
|
<span className='video-player__time-total'>{formatTime(Math.floor(getDuration()))}</span>
|
||||||
|
</>)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='video-player__buttons right'>
|
||||||
|
<a
|
||||||
|
title={intl.formatMessage(messages.download)}
|
||||||
|
aria-label={intl.formatMessage(messages.download)}
|
||||||
|
className='video-player__download__icon player-button'
|
||||||
|
href={src}
|
||||||
|
download
|
||||||
|
target='_blank'
|
||||||
|
>
|
||||||
|
<Icon src={require('@tabler/icons/download.svg')} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Audio;
|
|
@ -31,11 +31,11 @@ const AuthToken: React.FC<IAuthToken> = ({ token }) => {
|
||||||
<Text size='sm' theme='muted'>
|
<Text size='sm' theme='muted'>
|
||||||
<FormattedDate
|
<FormattedDate
|
||||||
value={new Date(token.valid_until)}
|
value={new Date(token.valid_until)}
|
||||||
hour12={false}
|
hour12
|
||||||
year='numeric'
|
year='numeric'
|
||||||
month='short'
|
month='short'
|
||||||
day='2-digit'
|
day='2-digit'
|
||||||
hour='2-digit'
|
hour='numeric'
|
||||||
minute='2-digit'
|
minute='2-digit'
|
||||||
/>
|
/>
|
||||||
</Text>
|
</Text>
|
||||||
|
@ -51,7 +51,7 @@ const AuthToken: React.FC<IAuthToken> = ({ token }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AuthTokenList: React.FC = () =>{
|
const AuthTokenList: React.FC = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const tokens = useAppSelector(state => state.security.get('tokens').reverse());
|
const tokens = useAppSelector(state => state.security.get('tokens').reverse());
|
||||||
|
|
|
@ -185,6 +185,7 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat, autosize }) => {
|
||||||
media={ImmutableList([attachment])}
|
media={ImmutableList([attachment])}
|
||||||
height={120}
|
height={120}
|
||||||
onOpenMedia={onOpenMedia}
|
onOpenMedia={onOpenMedia}
|
||||||
|
visible
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Bundle>
|
</Bundle>
|
||||||
|
|
|
@ -13,7 +13,7 @@ interface IQuotedStatusContainer {
|
||||||
const QuotedStatusContainer: React.FC<IQuotedStatusContainer> = ({ composeId }) => {
|
const QuotedStatusContainer: React.FC<IQuotedStatusContainer> = ({ composeId }) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const getStatus = useCallback(makeGetStatus(), []);
|
const getStatus = useCallback(makeGetStatus(), []);
|
||||||
|
|
||||||
const status = useAppSelector(state => getStatus(state, { id: state.compose.get(composeId)?.quote! }));
|
const status = useAppSelector(state => getStatus(state, { id: state.compose.get(composeId)?.quote! }));
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
|
|
|
@ -23,7 +23,7 @@ interface IAccountAuthorize {
|
||||||
const AccountAuthorize: React.FC<IAccountAuthorize> = ({ id }) => {
|
const AccountAuthorize: React.FC<IAccountAuthorize> = ({ id }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
const getAccount = useCallback(makeGetAccount(), []);
|
const getAccount = useCallback(makeGetAccount(), []);
|
||||||
|
|
||||||
const account = useAppSelector((state) => getAccount(state, id));
|
const account = useAppSelector((state) => getAccount(state, id));
|
||||||
|
|
115
app/soapbox/features/hashtag-timeline/index.tsx
Normal file
115
app/soapbox/features/hashtag-timeline/index.tsx
Normal file
|
@ -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;
|
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,21 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Stack } from 'soapbox/components/ui';
|
||||||
|
|
||||||
|
import { randomIntFromInterval, generateText } from '../utils';
|
||||||
|
|
||||||
|
export default ({ limit }: { limit: number }) => {
|
||||||
|
const trend = randomIntFromInterval(6, 3);
|
||||||
|
const stat = randomIntFromInterval(10, 3);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{new Array(limit).fill(undefined).map((_, idx) => (
|
||||||
|
<Stack key={idx} className='animate-pulse text-primary-200 dark:text-primary-700'>
|
||||||
|
<p>{generateText(trend)}</p>
|
||||||
|
<p>{generateText(stat)}</p>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -7,7 +7,6 @@ import { isStandalone } from 'soapbox/utils/state';
|
||||||
|
|
||||||
import AboutPage from '../about';
|
import AboutPage from '../about';
|
||||||
import LandingPage from '../landing_page';
|
import LandingPage from '../landing_page';
|
||||||
import MobilePage from '../mobile';
|
|
||||||
|
|
||||||
import Footer from './components/footer';
|
import Footer from './components/footer';
|
||||||
import Header from './components/header';
|
import Header from './components/header';
|
||||||
|
@ -31,7 +30,6 @@ const PublicLayout = () => {
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route exact path='/' component={LandingPage} />
|
<Route exact path='/' component={LandingPage} />
|
||||||
<Route exact path='/about/:slug?' component={AboutPage} />
|
<Route exact path='/about/:slug?' component={AboutPage} />
|
||||||
<Route exact path='/mobile/:slug?' component={MobilePage} />
|
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -48,6 +48,7 @@ const messages = defineMessages({
|
||||||
promoPanelIconsLink: { id: 'soapbox_config.hints.promo_panel_icons.link', defaultMessage: 'Soapbox Icons List' },
|
promoPanelIconsLink: { id: 'soapbox_config.hints.promo_panel_icons.link', defaultMessage: 'Soapbox Icons List' },
|
||||||
authenticatedProfileLabel: { id: 'soapbox_config.authenticated_profile_label', defaultMessage: 'Profiles require authentication' },
|
authenticatedProfileLabel: { id: 'soapbox_config.authenticated_profile_label', defaultMessage: 'Profiles require authentication' },
|
||||||
authenticatedProfileHint: { id: 'soapbox_config.authenticated_profile_hint', defaultMessage: 'Users must be logged-in to view replies and media on user profiles.' },
|
authenticatedProfileHint: { id: 'soapbox_config.authenticated_profile_hint', defaultMessage: 'Users must be logged-in to view replies and media on user profiles.' },
|
||||||
|
displayCtaLabel: { id: 'soapbox_config.cta_label', defaultMessage: 'Display call to action panels if not authenticated' },
|
||||||
singleUserModeLabel: { id: 'soapbox_config.single_user_mode_label', defaultMessage: 'Single user mode' },
|
singleUserModeLabel: { id: 'soapbox_config.single_user_mode_label', defaultMessage: 'Single user mode' },
|
||||||
singleUserModeHint: { id: 'soapbox_config.single_user_mode_hint', defaultMessage: 'Front page will redirect to a given user profile.' },
|
singleUserModeHint: { id: 'soapbox_config.single_user_mode_hint', defaultMessage: 'Front page will redirect to a given user profile.' },
|
||||||
singleUserModeProfileLabel: { id: 'soapbox_config.single_user_mode_profile_label', defaultMessage: 'Main user handle' },
|
singleUserModeProfileLabel: { id: 'soapbox_config.single_user_mode_profile_label', defaultMessage: 'Main user handle' },
|
||||||
|
@ -261,6 +262,13 @@ const SoapboxConfig: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem label={intl.formatMessage(messages.displayCtaLabel)}>
|
||||||
|
<Toggle
|
||||||
|
checked={soapbox.displayCta === true}
|
||||||
|
onChange={handleChange(['displayCta'], (e) => e.target.checked)}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<ListItem
|
<ListItem
|
||||||
label={intl.formatMessage(messages.authenticatedProfileLabel)}
|
label={intl.formatMessage(messages.authenticatedProfileLabel)}
|
||||||
hint={intl.formatMessage(messages.authenticatedProfileHint)}
|
hint={intl.formatMessage(messages.authenticatedProfileHint)}
|
||||||
|
|
|
@ -108,7 +108,7 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
|
||||||
<span>
|
<span>
|
||||||
<a href={actualStatus.url} target='_blank' rel='noopener' className='hover:underline'>
|
<a href={actualStatus.url} target='_blank' rel='noopener' className='hover:underline'>
|
||||||
<Text tag='span' theme='muted' size='sm'>
|
<Text tag='span' theme='muted' size='sm'>
|
||||||
<FormattedDate value={new Date(actualStatus.created_at)} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
|
<FormattedDate value={new Date(actualStatus.created_at)} hour12 year='numeric' month='short' day='2-digit' hour='numeric' minute='2-digit' />
|
||||||
</Text>
|
</Text>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
@ -122,7 +122,7 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<Text tag='span' theme='muted' size='sm'>
|
<Text tag='span' theme='muted' size='sm'>
|
||||||
<FormattedMessage id='actualStatus.edited' defaultMessage='Edited {date}' values={{ date: intl.formatDate(new Date(actualStatus.edited_at), { hour12: false, month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }} />
|
<FormattedMessage id='actualStatus.edited' defaultMessage='Edited {date}' values={{ date: intl.formatDate(new Date(actualStatus.edited_at), { hour12: true, month: 'short', day: '2-digit', hour: 'numeric', minute: '2-digit' }) }} />
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -2,12 +2,15 @@ import React from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { Card, CardTitle, Text, Stack, Button } from 'soapbox/components/ui';
|
import { Card, CardTitle, Text, Stack, Button } from 'soapbox/components/ui';
|
||||||
import { useAppSelector } from 'soapbox/hooks';
|
import { useAppSelector, useSoapboxConfig } from 'soapbox/hooks';
|
||||||
|
|
||||||
/** Prompts logged-out users to log in when viewing a thread. */
|
/** Prompts logged-out users to log in when viewing a thread. */
|
||||||
const ThreadLoginCta: React.FC = () => {
|
const ThreadLoginCta: React.FC = () => {
|
||||||
|
const { displayCta } = useSoapboxConfig();
|
||||||
const siteTitle = useAppSelector(state => state.instance.title);
|
const siteTitle = useAppSelector(state => state.instance.title);
|
||||||
|
|
||||||
|
if (!displayCta) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className='px-6 py-12 space-y-6 text-center' variant='rounded'>
|
<Card className='px-6 py-12 space-y-6 text-center' variant='rounded'>
|
||||||
<Stack>
|
<Stack>
|
||||||
|
|
|
@ -79,7 +79,7 @@ const CompareHistoryModal: React.FC<ICompareHistoryModal> = ({ onClose, statusId
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Text align='right' tag='span' theme='muted' size='sm'>
|
<Text align='right' tag='span' theme='muted' size='sm'>
|
||||||
<FormattedDate value={new Date(version.created_at)} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
|
<FormattedDate value={new Date(version.created_at)} hour12 year='numeric' month='short' day='2-digit' hour='numeric' minute='2-digit' />
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -5,11 +5,11 @@ import { Banner, Button, HStack, Stack, Text } from 'soapbox/components/ui';
|
||||||
import { useAppSelector, useSoapboxConfig } from 'soapbox/hooks';
|
import { useAppSelector, useSoapboxConfig } from 'soapbox/hooks';
|
||||||
|
|
||||||
const CtaBanner = () => {
|
const CtaBanner = () => {
|
||||||
const { singleUserMode } = useSoapboxConfig();
|
const { displayCta, singleUserMode } = useSoapboxConfig();
|
||||||
const siteTitle = useAppSelector((state) => state.instance.title);
|
const siteTitle = useAppSelector((state) => state.instance.title);
|
||||||
const me = useAppSelector((state) => state.me);
|
const me = useAppSelector((state) => state.me);
|
||||||
|
|
||||||
if (me || singleUserMode) return null;
|
if (me || !displayCta || singleUserMode) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid='cta-banner' className='hidden lg:block'>
|
<div data-testid='cta-banner' className='hidden lg:block'>
|
||||||
|
|
Binary file not shown.
Binary file not shown.
300
app/soapbox/features/ui/components/media-modal.tsx
Normal file
300
app/soapbox/features/ui/components/media-modal.tsx
Normal file
|
@ -0,0 +1,300 @@
|
||||||
|
import classNames from 'clsx';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
import ReactSwipeableViews from 'react-swipeable-views';
|
||||||
|
|
||||||
|
import ExtendedVideoPlayer from 'soapbox/components/extended_video_player';
|
||||||
|
import Icon from 'soapbox/components/icon';
|
||||||
|
import IconButton from 'soapbox/components/icon_button';
|
||||||
|
import Audio from 'soapbox/features/audio';
|
||||||
|
import Video from 'soapbox/features/video';
|
||||||
|
|
||||||
|
import ImageLoader from './image-loader';
|
||||||
|
|
||||||
|
import type { List as ImmutableList } from 'immutable';
|
||||||
|
import type { Account, Attachment, Status } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||||
|
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
||||||
|
next: { id: 'lightbox.next', defaultMessage: 'Next' },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IMediaModal {
|
||||||
|
media: ImmutableList<Attachment>,
|
||||||
|
status: Status,
|
||||||
|
account: Account,
|
||||||
|
index: number,
|
||||||
|
time?: number,
|
||||||
|
onClose: () => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MediaModal: React.FC<IMediaModal> = (props) => {
|
||||||
|
const {
|
||||||
|
media,
|
||||||
|
status,
|
||||||
|
account,
|
||||||
|
onClose,
|
||||||
|
time = 0,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const intl = useIntl();
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const [index, setIndex] = useState<number | null>(null);
|
||||||
|
const [navigationHidden, setNavigationHidden] = useState(false);
|
||||||
|
|
||||||
|
const handleSwipe = (index: number) => {
|
||||||
|
setIndex(index % media.size);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextClick = () => {
|
||||||
|
setIndex((getIndex() + 1) % media.size);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevClick = () => {
|
||||||
|
setIndex((media.size + getIndex() - 1) % media.size);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangeIndex: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||||
|
const index = Number(e.currentTarget.getAttribute('data-index'));
|
||||||
|
setIndex(index % media.size);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowLeft':
|
||||||
|
handlePrevClick();
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
handleNextClick();
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('keydown', handleKeyDown, false);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getIndex = () => {
|
||||||
|
return index !== null ? index : props.index;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleNavigation = () => {
|
||||||
|
setNavigationHidden(!navigationHidden);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusClick: React.MouseEventHandler = e => {
|
||||||
|
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
history.push(`/@${account.acct}/posts/${status.id}`);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloserClick: React.MouseEventHandler = ({ currentTarget }) => {
|
||||||
|
const whitelist = ['zoomable-image'];
|
||||||
|
const activeSlide = document.querySelector('.media-modal .react-swipeable-view-container > div[aria-hidden="false"]');
|
||||||
|
|
||||||
|
const isClickOutside = currentTarget === activeSlide || !activeSlide?.contains(currentTarget);
|
||||||
|
const isWhitelisted = whitelist.some(w => currentTarget.classList.contains(w));
|
||||||
|
|
||||||
|
if (isClickOutside || isWhitelisted) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let pagination: React.ReactNode[] = [];
|
||||||
|
|
||||||
|
const leftNav = media.size > 1 && (
|
||||||
|
<button
|
||||||
|
tabIndex={0}
|
||||||
|
className='media-modal__nav media-modal__nav--left'
|
||||||
|
onClick={handlePrevClick}
|
||||||
|
aria-label={intl.formatMessage(messages.previous)}
|
||||||
|
>
|
||||||
|
<Icon src={require('@tabler/icons/arrow-left.svg')} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const rightNav = media.size > 1 && (
|
||||||
|
<button
|
||||||
|
tabIndex={0}
|
||||||
|
className='media-modal__nav media-modal__nav--right'
|
||||||
|
onClick={handleNextClick}
|
||||||
|
aria-label={intl.formatMessage(messages.next)}
|
||||||
|
>
|
||||||
|
<Icon src={require('@tabler/icons/arrow-right.svg')} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (media.size > 1) {
|
||||||
|
pagination = media.toArray().map((item, i) => {
|
||||||
|
const classes = ['media-modal__button'];
|
||||||
|
if (i === getIndex()) {
|
||||||
|
classes.push('media-modal__button--active');
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<li className='media-modal__page-dot' key={i}>
|
||||||
|
<button
|
||||||
|
tabIndex={0}
|
||||||
|
className={classes.join(' ')}
|
||||||
|
onClick={handleChangeIndex}
|
||||||
|
data-index={i}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMultiMedia = media.map((image) => {
|
||||||
|
if (image.type !== 'image') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}).toArray();
|
||||||
|
|
||||||
|
const content = media.map(attachment => {
|
||||||
|
const width = (attachment.meta.getIn(['original', 'width']) || undefined) as number | undefined;
|
||||||
|
const height = (attachment.meta.getIn(['original', 'height']) || undefined) as number | undefined;
|
||||||
|
|
||||||
|
const link = (status && account && (
|
||||||
|
<a href={status.url} onClick={handleStatusClick}>
|
||||||
|
<FormattedMessage id='lightbox.view_context' defaultMessage='View context' />
|
||||||
|
</a>
|
||||||
|
));
|
||||||
|
|
||||||
|
if (attachment.type === 'image') {
|
||||||
|
return (
|
||||||
|
<ImageLoader
|
||||||
|
previewSrc={attachment.preview_url}
|
||||||
|
src={attachment.url}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
alt={attachment.description}
|
||||||
|
key={attachment.url}
|
||||||
|
onClick={toggleNavigation}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (attachment.type === 'video') {
|
||||||
|
return (
|
||||||
|
<Video
|
||||||
|
preview={attachment.preview_url}
|
||||||
|
blurhash={attachment.blurhash}
|
||||||
|
src={attachment.url}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
startTime={time}
|
||||||
|
onCloseVideo={onClose}
|
||||||
|
detailed
|
||||||
|
link={link}
|
||||||
|
alt={attachment.description}
|
||||||
|
key={attachment.url}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (attachment.type === 'audio') {
|
||||||
|
return (
|
||||||
|
<Audio
|
||||||
|
src={attachment.url}
|
||||||
|
alt={attachment.description}
|
||||||
|
poster={attachment.preview_url !== attachment.url ? attachment.preview_url : (status.getIn(['account', 'avatar_static'])) as string | undefined}
|
||||||
|
backgroundColor={attachment.meta.getIn(['colors', 'background']) as string | undefined}
|
||||||
|
foregroundColor={attachment.meta.getIn(['colors', 'foreground']) as string | undefined}
|
||||||
|
accentColor={attachment.meta.getIn(['colors', 'accent']) as string | undefined}
|
||||||
|
duration={attachment.meta.getIn(['original', 'duration'], 0) as number | undefined}
|
||||||
|
key={attachment.url}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (attachment.type === 'gifv') {
|
||||||
|
return (
|
||||||
|
<ExtendedVideoPlayer
|
||||||
|
src={attachment.url}
|
||||||
|
muted
|
||||||
|
controls={false}
|
||||||
|
width={width}
|
||||||
|
link={link}
|
||||||
|
height={height}
|
||||||
|
key={attachment.preview_url}
|
||||||
|
alt={attachment.description}
|
||||||
|
onClick={toggleNavigation}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}).toArray();
|
||||||
|
|
||||||
|
// you can't use 100vh, because the viewport height is taller
|
||||||
|
// than the visible part of the document in some mobile
|
||||||
|
// browsers when it's address bar is visible.
|
||||||
|
// https://developers.google.com/web/updates/2016/12/url-bar-resizing
|
||||||
|
const swipeableViewsStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
};
|
||||||
|
|
||||||
|
const containerStyle: React.CSSProperties = {
|
||||||
|
alignItems: 'center', // center vertically
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigationClassName = classNames('media-modal__navigation', {
|
||||||
|
'media-modal__navigation--hidden': navigationHidden,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='modal-root__modal media-modal'>
|
||||||
|
<div
|
||||||
|
className='media-modal__closer'
|
||||||
|
role='presentation'
|
||||||
|
onClick={handleCloserClick}
|
||||||
|
>
|
||||||
|
<ReactSwipeableViews
|
||||||
|
style={swipeableViewsStyle}
|
||||||
|
containerStyle={containerStyle}
|
||||||
|
onChangeIndex={handleSwipe}
|
||||||
|
index={getIndex()}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactSwipeableViews>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={navigationClassName}>
|
||||||
|
<IconButton
|
||||||
|
className='media-modal__close'
|
||||||
|
title={intl.formatMessage(messages.close)}
|
||||||
|
src={require('@tabler/icons/x.svg')}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{leftNav}
|
||||||
|
{rightNav}
|
||||||
|
|
||||||
|
{(status && !isMultiMedia[getIndex()]) && (
|
||||||
|
<div className={classNames('media-modal__meta', { 'media-modal__meta--shifted': media.size > 1 })}>
|
||||||
|
<a href={status.url} onClick={handleStatusClick}>
|
||||||
|
<FormattedMessage id='lightbox.view_context' defaultMessage='View context' />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className='media-modal__pagination'>
|
||||||
|
{pagination}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MediaModal;
|
Binary file not shown.
Binary file not shown.
|
@ -22,8 +22,8 @@ const dateFormatOptions: FormatDateOptions = {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
hour12: false,
|
hour12: true,
|
||||||
hour: '2-digit',
|
hour: 'numeric',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,26 +1,57 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { setFilter } from 'soapbox/actions/search';
|
||||||
import Hashtag from 'soapbox/components/hashtag';
|
import Hashtag from 'soapbox/components/hashtag';
|
||||||
import { Widget } from 'soapbox/components/ui';
|
import { Text, Widget } from 'soapbox/components/ui';
|
||||||
|
import PlaceholderSidebarTrends from 'soapbox/features/placeholder/components/placeholder-sidebar-trends';
|
||||||
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
import useTrends from 'soapbox/queries/trends';
|
import useTrends from 'soapbox/queries/trends';
|
||||||
|
|
||||||
interface ITrendsPanel {
|
interface ITrendsPanel {
|
||||||
limit: number
|
limit: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
viewAll: {
|
||||||
|
id: 'trendsPanel.viewAll',
|
||||||
|
defaultMessage: 'View all',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const TrendsPanel = ({ limit }: ITrendsPanel) => {
|
const TrendsPanel = ({ limit }: ITrendsPanel) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
const { data: trends, isFetching } = useTrends();
|
const { data: trends, isFetching } = useTrends();
|
||||||
|
|
||||||
if (trends?.length === 0 || isFetching) {
|
const setHashtagsFilter = () => {
|
||||||
|
dispatch(setFilter('hashtags'));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isFetching && !trends?.length) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Widget title={<FormattedMessage id='trends.title' defaultMessage='Trends' />}>
|
<Widget
|
||||||
{trends?.slice(0, limit).map((hashtag) => (
|
title={<FormattedMessage id='trends.title' defaultMessage='Trends' />}
|
||||||
<Hashtag key={hashtag.name} hashtag={hashtag} />
|
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>
|
</Widget>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
Binary file not shown.
|
@ -23,7 +23,7 @@ export function CommunityTimeline() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HashtagTimeline() {
|
export function HashtagTimeline() {
|
||||||
return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline');
|
return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag-timeline');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DirectTimeline() {
|
export function DirectTimeline() {
|
||||||
|
@ -123,7 +123,7 @@ export function Audio() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MediaModal() {
|
export function MediaModal() {
|
||||||
return import(/* webpackChunkName: "features/ui" */'../components/media_modal');
|
return import(/* webpackChunkName: "features/ui" */'../components/media-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VideoModal() {
|
export function VideoModal() {
|
||||||
|
@ -146,10 +146,6 @@ export function ActionsModal() {
|
||||||
return import(/* webpackChunkName: "features/ui" */'../components/actions_modal');
|
return import(/* webpackChunkName: "features/ui" */'../components/actions_modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FocalPointModal() {
|
|
||||||
return import(/* webpackChunkName: "features/ui" */'../components/focal_point_modal');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HotkeysModal() {
|
export function HotkeysModal() {
|
||||||
return import(/* webpackChunkName: "features/ui" */'../components/hotkeys_modal');
|
return import(/* webpackChunkName: "features/ui" */'../components/hotkeys_modal');
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,6 +20,10 @@ const messages = defineMessages({
|
||||||
id: 'registrations.success',
|
id: 'registrations.success',
|
||||||
defaultMessage: 'Welcome to {siteTitle}!',
|
defaultMessage: 'Welcome to {siteTitle}!',
|
||||||
},
|
},
|
||||||
|
usernameHint: {
|
||||||
|
id: 'registrations.username.hint',
|
||||||
|
defaultMessage: 'May only contain A-Z, 0-9, and underscores',
|
||||||
|
},
|
||||||
usernameTaken: {
|
usernameTaken: {
|
||||||
id: 'registrations.unprocessable_entity',
|
id: 'registrations.unprocessable_entity',
|
||||||
defaultMessage: 'This username has already been taken.',
|
defaultMessage: 'This username has already been taken.',
|
||||||
|
@ -104,7 +108,7 @@ const Registration = () => {
|
||||||
|
|
||||||
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto space-y-4'>
|
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto space-y-4'>
|
||||||
<Form onSubmit={handleSubmit}>
|
<Form onSubmit={handleSubmit}>
|
||||||
<FormGroup labelText='Your username'>
|
<FormGroup labelText='Your username' hintText={intl.formatMessage(messages.usernameHint)}>
|
||||||
<Input
|
<Input
|
||||||
name='username'
|
name='username'
|
||||||
type='text'
|
type='text'
|
||||||
|
@ -112,6 +116,7 @@ const Registration = () => {
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
required
|
required
|
||||||
icon={require('@tabler/icons/at.svg')}
|
icon={require('@tabler/icons/at.svg')}
|
||||||
|
placeholder='LibertyForAll'
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
||||||
|
|
|
@ -49,6 +49,7 @@
|
||||||
"account.requested": "Oczekująca prośba, kliknij aby anulować",
|
"account.requested": "Oczekująca prośba, kliknij aby anulować",
|
||||||
"account.requested_small": "Oczekująca prośba",
|
"account.requested_small": "Oczekująca prośba",
|
||||||
"account.search": "Szukaj wpisów @{name}",
|
"account.search": "Szukaj wpisów @{name}",
|
||||||
|
"account.search_self": "Szukaj własnych wpisów",
|
||||||
"account.share": "Udostępnij profil @{name}",
|
"account.share": "Udostępnij profil @{name}",
|
||||||
"account.show_reblogs": "Pokazuj podbicia od @{name}",
|
"account.show_reblogs": "Pokazuj podbicia od @{name}",
|
||||||
"account.subscribe": "Subskrybuj wpisy @{name}",
|
"account.subscribe": "Subskrybuj wpisy @{name}",
|
||||||
|
@ -67,6 +68,18 @@
|
||||||
"account.verified": "Zweryfikowane konto",
|
"account.verified": "Zweryfikowane konto",
|
||||||
"account.welcome": "Welcome",
|
"account.welcome": "Welcome",
|
||||||
"account_gallery.none": "Brak zawartości multimedialnej do wyświetlenia.",
|
"account_gallery.none": "Brak zawartości multimedialnej do wyświetlenia.",
|
||||||
|
"account_moderation_modal.admin_fe": "Otwórz w AdminFE",
|
||||||
|
"account_moderation_modal.fields.account_role": "Poziom uprawnień",
|
||||||
|
"account_moderation_modal.fields.badges": "Niestandaradowe odznaki",
|
||||||
|
"account_moderation_modal.fields.deactivate": "Dezaktywuj konto",
|
||||||
|
"account_moderation_modal.fields.delete": "Usuń konto",
|
||||||
|
"account_moderation_modal.fields.suggested": "Proponuj obserwację tego konta",
|
||||||
|
"account_moderation_modal.fields.verified": "Zweryfikowane konto",
|
||||||
|
"account_moderation_modal.info.id": "ID: {id}",
|
||||||
|
"account_moderation_modal.roles.admin": "Administrator",
|
||||||
|
"account_moderation_modal.roles.moderator": "Moderator",
|
||||||
|
"account_moderation_modal.roles.user": "Użytkownik",
|
||||||
|
"account_moderation_modal.title": "Moderuj @{acct}",
|
||||||
"account_note.hint": "Możesz pozostawić dla siebie notatkę o tym użytkowniku (tylko ty ją zobaczysz):",
|
"account_note.hint": "Możesz pozostawić dla siebie notatkę o tym użytkowniku (tylko ty ją zobaczysz):",
|
||||||
"account_note.placeholder": "Nie wprowadzono opisu",
|
"account_note.placeholder": "Nie wprowadzono opisu",
|
||||||
"account_note.save": "Zapisz",
|
"account_note.save": "Zapisz",
|
||||||
|
@ -125,6 +138,7 @@
|
||||||
"admin.users.actions.unsuggest_user": "Przestań polecać @{name}",
|
"admin.users.actions.unsuggest_user": "Przestań polecać @{name}",
|
||||||
"admin.users.actions.unverify_user": "Cofnij weryfikację @{name}",
|
"admin.users.actions.unverify_user": "Cofnij weryfikację @{name}",
|
||||||
"admin.users.actions.verify_user": "Weryfikuj @{name}",
|
"admin.users.actions.verify_user": "Weryfikuj @{name}",
|
||||||
|
"admin.users.badges_saved_message": "Zaktualizowano niestandardowe odznaki.",
|
||||||
"admin.users.remove_donor_message": "Usunięto @{acct} ze wspierających",
|
"admin.users.remove_donor_message": "Usunięto @{acct} ze wspierających",
|
||||||
"admin.users.set_donor_message": "Ustawiono @{acct} jako wspierającego",
|
"admin.users.set_donor_message": "Ustawiono @{acct} jako wspierającego",
|
||||||
"admin.users.user_deactivated_message": "Zdezaktywowano @{acct}",
|
"admin.users.user_deactivated_message": "Zdezaktywowano @{acct}",
|
||||||
|
@ -173,6 +187,7 @@
|
||||||
"backups.empty_message": "Nie znaleziono kopii zapasowych. {action}",
|
"backups.empty_message": "Nie znaleziono kopii zapasowych. {action}",
|
||||||
"backups.empty_message.action": "Chcesz utworzyć?",
|
"backups.empty_message.action": "Chcesz utworzyć?",
|
||||||
"backups.pending": "Oczekująca",
|
"backups.pending": "Oczekująca",
|
||||||
|
"badge_input.placeholder": "Wprowadź odznakę…",
|
||||||
"beta.also_available": "Dostępne w językach:",
|
"beta.also_available": "Dostępne w językach:",
|
||||||
"birthday_panel.title": "Urodziny",
|
"birthday_panel.title": "Urodziny",
|
||||||
"birthdays_modal.empty": "Żaden z Twoich znajomych nie ma dziś urodzin.",
|
"birthdays_modal.empty": "Żaden z Twoich znajomych nie ma dziś urodzin.",
|
||||||
|
@ -736,8 +751,10 @@
|
||||||
"migration.fields.acct.placeholder": "konto@domena",
|
"migration.fields.acct.placeholder": "konto@domena",
|
||||||
"migration.fields.confirm_password.label": "Obecne hasło",
|
"migration.fields.confirm_password.label": "Obecne hasło",
|
||||||
"migration.hint": "Ta opcja przeniesie Twoich obserwujących na nowe konto. Żadne inne dane nie zostaną przeniesione. Aby dokonać migracji, musisz najpierw {link} na swoim nowym koncie.",
|
"migration.hint": "Ta opcja przeniesie Twoich obserwujących na nowe konto. Żadne inne dane nie zostaną przeniesione. Aby dokonać migracji, musisz najpierw {link} na swoim nowym koncie.",
|
||||||
|
"migration.hint.cooldown_period": "Jeżeli przemigrujesz swoje konto, nie będziesz móc wykonać kolejnej migracji przez {cooldownPeriod, plural, one {jeden dzień} other {kolejne # dni}}.",
|
||||||
"migration.hint.link": "utworzyć alias konta",
|
"migration.hint.link": "utworzyć alias konta",
|
||||||
"migration.move_account.fail": "Przenoszenie konta nie powiodło się.",
|
"migration.move_account.fail": "Przenoszenie konta nie powiodło się.",
|
||||||
|
"migration.move_account.fail.cooldown_period": "Niedawno migrowałeś(-aś) swoje konto. Spróbuj ponownie później.",
|
||||||
"migration.move_account.success": "Pomyślnie przeniesiono konto.",
|
"migration.move_account.success": "Pomyślnie przeniesiono konto.",
|
||||||
"migration.submit": "Przenieś obserwujących",
|
"migration.submit": "Przenieś obserwujących",
|
||||||
"missing_description_modal.cancel": "Anuluj",
|
"missing_description_modal.cancel": "Anuluj",
|
||||||
|
@ -747,6 +764,9 @@
|
||||||
"missing_indicator.label": "Nie znaleziono",
|
"missing_indicator.label": "Nie znaleziono",
|
||||||
"missing_indicator.sublabel": "Nie można odnaleźć tego zasobu",
|
"missing_indicator.sublabel": "Nie można odnaleźć tego zasobu",
|
||||||
"mobile.also_available": "Dostępne w językach:",
|
"mobile.also_available": "Dostępne w językach:",
|
||||||
|
"moderation_overlay.contact": "Kontakt",
|
||||||
|
"moderation_overlay.hide": "Ukryj",
|
||||||
|
"moderation_overlay.show": "Wyświetl",
|
||||||
"morefollows.followers_label": "…i {count} więcej {count, plural, one {obserwujący(-a)} few {obserwujących} many {obserwujących} other {obserwujących}} na zdalnych stronach.",
|
"morefollows.followers_label": "…i {count} więcej {count, plural, one {obserwujący(-a)} few {obserwujących} many {obserwujących} other {obserwujących}} na zdalnych stronach.",
|
||||||
"morefollows.following_label": "…i {count} więcej {count, plural, one {obserwowany(-a)} few {obserwowanych} many {obserwowanych} other {obserwowanych}} na zdalnych stronach.",
|
"morefollows.following_label": "…i {count} więcej {count, plural, one {obserwowany(-a)} few {obserwowanych} many {obserwowanych} other {obserwowanych}} na zdalnych stronach.",
|
||||||
"mute_modal.hide_notifications": "Chcesz ukryć powiadomienia od tego użytkownika?",
|
"mute_modal.hide_notifications": "Chcesz ukryć powiadomienia od tego użytkownika?",
|
||||||
|
@ -843,6 +863,10 @@
|
||||||
"onboarding.display_name.subtitle": "Możesz ją zawsze zmienić później.",
|
"onboarding.display_name.subtitle": "Możesz ją zawsze zmienić później.",
|
||||||
"onboarding.display_name.title": "Wybierz wyświetlaną nazwę",
|
"onboarding.display_name.title": "Wybierz wyświetlaną nazwę",
|
||||||
"onboarding.done": "Gotowe",
|
"onboarding.done": "Gotowe",
|
||||||
|
"onboarding.fediverse.its_you": "Oto Twoje konto! Inni ludzie mogą Cię obserwować z innych serwerów używając pełnej @nazwy.",
|
||||||
|
"onboarding.fediverse.next": "Dalej",
|
||||||
|
"onboarding.fediverse.title": "{siteTitle} to tylko jedna z części Fediwersum",
|
||||||
|
"onboarding.fediverse.other_instances": "Kiedy przeglądasz oś czasum, zwróć uwagę na pełną nazwę użytkownika po znaku @, aby wiedzieć z którego serwera pochodzi wpis.",
|
||||||
"onboarding.finished.message": "Cieszymy się, że możemy powitać Cię w naszej społeczności! Naciśnij poniższy przycisk, aby rozpocząć.",
|
"onboarding.finished.message": "Cieszymy się, że możemy powitać Cię w naszej społeczności! Naciśnij poniższy przycisk, aby rozpocząć.",
|
||||||
"onboarding.finished.title": "Wprowadzenie ukończone",
|
"onboarding.finished.title": "Wprowadzenie ukończone",
|
||||||
"onboarding.header.subtitle": "Będzie widoczny w górnej części Twojego profilu",
|
"onboarding.header.subtitle": "Będzie widoczny w górnej części Twojego profilu",
|
||||||
|
@ -1008,6 +1032,7 @@
|
||||||
"report.target": "Zgłaszanie {target}",
|
"report.target": "Zgłaszanie {target}",
|
||||||
"reset_password.fail": "Token wygasł, spróbuj ponownie.",
|
"reset_password.fail": "Token wygasł, spróbuj ponownie.",
|
||||||
"reset_password.header": "Ustaw nowe hasło",
|
"reset_password.header": "Ustaw nowe hasło",
|
||||||
|
"save": "Zapisz",
|
||||||
"schedule.post_time": "Data/godzina publikacji",
|
"schedule.post_time": "Data/godzina publikacji",
|
||||||
"schedule.remove": "Usuń zaplanowany wpis",
|
"schedule.remove": "Usuń zaplanowany wpis",
|
||||||
"schedule_button.add_schedule": "Zaplanuj wpis na później",
|
"schedule_button.add_schedule": "Zaplanuj wpis na później",
|
||||||
|
@ -1128,7 +1153,7 @@
|
||||||
"sponsored.info.title": "Dlaczego widzę tę reklamę?",
|
"sponsored.info.title": "Dlaczego widzę tę reklamę?",
|
||||||
"sponsored.subtitle": "Wpis sponsorowany",
|
"sponsored.subtitle": "Wpis sponsorowany",
|
||||||
"status.actions.more": "Więcej",
|
"status.actions.more": "Więcej",
|
||||||
"status.admin_account": "Otwórz interfejs moderacyjny dla @{name}",
|
"status.admin_account": "Moderuj @{name}",
|
||||||
"status.admin_status": "Otwórz ten wpis w interfejsie moderacyjnym",
|
"status.admin_status": "Otwórz ten wpis w interfejsie moderacyjnym",
|
||||||
"status.block": "Zablokuj @{name}",
|
"status.block": "Zablokuj @{name}",
|
||||||
"status.bookmark": "Dodaj do zakładek",
|
"status.bookmark": "Dodaj do zakładek",
|
||||||
|
|
|
@ -5,6 +5,8 @@ import {
|
||||||
fromJS,
|
fromJS,
|
||||||
} from 'immutable';
|
} from 'immutable';
|
||||||
|
|
||||||
|
import { normalizeAttachment } from 'soapbox/normalizers/attachment';
|
||||||
|
|
||||||
import type { Attachment, Card, Emoji } from 'soapbox/types/entities';
|
import type { Attachment, Card, Emoji } from 'soapbox/types/entities';
|
||||||
|
|
||||||
export const ChatMessageRecord = ImmutableRecord({
|
export const ChatMessageRecord = ImmutableRecord({
|
||||||
|
@ -22,8 +24,14 @@ export const ChatMessageRecord = ImmutableRecord({
|
||||||
pending: false,
|
pending: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const normalizeMedia = (status: ImmutableMap<string, any>) => {
|
||||||
|
return status.update('attachment', null, normalizeAttachment);
|
||||||
|
};
|
||||||
|
|
||||||
export const normalizeChatMessage = (chatMessage: Record<string, any>) => {
|
export const normalizeChatMessage = (chatMessage: Record<string, any>) => {
|
||||||
return ChatMessageRecord(
|
return ChatMessageRecord(
|
||||||
ImmutableMap(fromJS(chatMessage)),
|
ImmutableMap(fromJS(chatMessage)).withMutations(chatMessage => {
|
||||||
|
normalizeMedia(chatMessage);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -106,12 +106,12 @@ export const SoapboxConfigRecord = ImmutableRecord({
|
||||||
limit: 1,
|
limit: 1,
|
||||||
}),
|
}),
|
||||||
aboutPages: ImmutableMap<string, ImmutableMap<string, unknown>>(),
|
aboutPages: ImmutableMap<string, ImmutableMap<string, unknown>>(),
|
||||||
mobilePages: ImmutableMap<string, ImmutableMap<string, unknown>>(),
|
|
||||||
authenticatedProfile: true,
|
authenticatedProfile: true,
|
||||||
singleUserMode: false,
|
singleUserMode: false,
|
||||||
singleUserModeProfile: '',
|
singleUserModeProfile: '',
|
||||||
linkFooterMessage: '',
|
linkFooterMessage: '',
|
||||||
links: ImmutableMap<string, string>(),
|
links: ImmutableMap<string, string>(),
|
||||||
|
displayCta: true,
|
||||||
}, 'SoapboxConfig');
|
}, 'SoapboxConfig');
|
||||||
|
|
||||||
type SoapboxConfigMap = ImmutableMap<string, any>;
|
type SoapboxConfigMap = ImmutableMap<string, any>;
|
||||||
|
|
|
@ -36,10 +36,10 @@ const DefaultPage: React.FC = ({ children }) => {
|
||||||
)}
|
)}
|
||||||
{features.trends && (
|
{features.trends && (
|
||||||
<BundleContainer fetchComponent={TrendsPanel}>
|
<BundleContainer fetchComponent={TrendsPanel}>
|
||||||
{Component => <Component limit={3} key='trends-panel' />}
|
{Component => <Component limit={5} key='trends-panel' />}
|
||||||
</BundleContainer>
|
</BundleContainer>
|
||||||
)}
|
)}
|
||||||
{features.suggestions && (
|
{me && features.suggestions && (
|
||||||
<BundleContainer fetchComponent={WhoToFollowPanel}>
|
<BundleContainer fetchComponent={WhoToFollowPanel}>
|
||||||
{Component => <Component limit={3} key='wtf-panel' />}
|
{Component => <Component limit={3} key='wtf-panel' />}
|
||||||
</BundleContainer>
|
</BundleContainer>
|
||||||
|
|
|
@ -82,7 +82,7 @@ const HomePage: React.FC = ({ children }) => {
|
||||||
)}
|
)}
|
||||||
{features.trends && (
|
{features.trends && (
|
||||||
<BundleContainer fetchComponent={TrendsPanel}>
|
<BundleContainer fetchComponent={TrendsPanel}>
|
||||||
{Component => <Component limit={3} />}
|
{Component => <Component limit={5} />}
|
||||||
</BundleContainer>
|
</BundleContainer>
|
||||||
)}
|
)}
|
||||||
{hasPatron && (
|
{hasPatron && (
|
||||||
|
@ -103,7 +103,7 @@ const HomePage: React.FC = ({ children }) => {
|
||||||
{Component => <Component limit={10} />}
|
{Component => <Component limit={10} />}
|
||||||
</BundleContainer>
|
</BundleContainer>
|
||||||
)}
|
)}
|
||||||
{features.suggestions && (
|
{me && features.suggestions && (
|
||||||
<BundleContainer fetchComponent={WhoToFollowPanel}>
|
<BundleContainer fetchComponent={WhoToFollowPanel}>
|
||||||
{Component => <Component limit={3} />}
|
{Component => <Component limit={3} />}
|
||||||
</BundleContainer>
|
</BundleContainer>
|
||||||
|
|
|
@ -137,7 +137,7 @@ const ProfilePage: React.FC<IProfilePage> = ({ params, children }) => {
|
||||||
<BundleContainer fetchComponent={PinnedAccountsPanel}>
|
<BundleContainer fetchComponent={PinnedAccountsPanel}>
|
||||||
{Component => <Component account={account} limit={5} key='pinned-accounts-panel' />}
|
{Component => <Component account={account} limit={5} key='pinned-accounts-panel' />}
|
||||||
</BundleContainer>
|
</BundleContainer>
|
||||||
) : features.suggestions && (
|
) : me && features.suggestions && (
|
||||||
<BundleContainer fetchComponent={WhoToFollowPanel}>
|
<BundleContainer fetchComponent={WhoToFollowPanel}>
|
||||||
{Component => <Component limit={3} key='wtf-panel' />}
|
{Component => <Component limit={3} key='wtf-panel' />}
|
||||||
</BundleContainer>
|
</BundleContainer>
|
||||||
|
|
|
@ -40,10 +40,10 @@ const StatusPage: React.FC<IStatusPage> = ({ children }) => {
|
||||||
)}
|
)}
|
||||||
{features.trends && (
|
{features.trends && (
|
||||||
<BundleContainer fetchComponent={TrendsPanel}>
|
<BundleContainer fetchComponent={TrendsPanel}>
|
||||||
{Component => <Component limit={3} key='trends-panel' />}
|
{Component => <Component limit={5} key='trends-panel' />}
|
||||||
</BundleContainer>
|
</BundleContainer>
|
||||||
)}
|
)}
|
||||||
{features.suggestions && (
|
{me && features.suggestions && (
|
||||||
<BundleContainer fetchComponent={WhoToFollowPanel}>
|
<BundleContainer fetchComponent={WhoToFollowPanel}>
|
||||||
{Component => <Component limit={3} key='wtf-panel' />}
|
{Component => <Component limit={3} key='wtf-panel' />}
|
||||||
</BundleContainer>
|
</BundleContainer>
|
||||||
|
|
|
@ -242,39 +242,3 @@
|
||||||
@apply block shadow-md;
|
@apply block shadow-md;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.focal-point {
|
|
||||||
position: relative;
|
|
||||||
cursor: pointer;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&.dragging {
|
|
||||||
cursor: move;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
max-width: 80vw;
|
|
||||||
max-height: 80vh;
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__reticle {
|
|
||||||
position: absolute;
|
|
||||||
width: 100px;
|
|
||||||
height: 100px;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
background: url('../images/reticle.png') no-repeat 0 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__overlay {
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -387,12 +387,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.focal-point-modal {
|
|
||||||
max-width: 80vw;
|
|
||||||
max-height: 80vh;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.column-inline-form {
|
.column-inline-form {
|
||||||
padding: 7px 15px;
|
padding: 7px 15px;
|
||||||
padding-right: 5px;
|
padding-right: 5px;
|
||||||
|
|
Loading…
Reference in a new issue