mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-11 09:15:36 -05:00
Status media attachments refactor (#39601)
This commit is contained in:
@@ -29,3 +29,8 @@ export const deployPictureInPicture = createAppAsyncThunk(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export type DeployPictureInPictureCallback = (
|
||||
type: 'audio' | 'video',
|
||||
props: PIPMediaProps,
|
||||
) => void;
|
||||
|
||||
279
app/javascript/mastodon/components/status/attachments.tsx
Normal file
279
app/javascript/mastodon/components/status/attachments.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { lazy, Suspense, useCallback, useState } from 'react';
|
||||
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import type { DeployPictureInPictureCallback } from '@/mastodon/actions/picture_in_picture';
|
||||
import { deployPictureInPicture } from '@/mastodon/actions/picture_in_picture';
|
||||
import { CollectionPreviewCard } from '@/mastodon/features/collections/components/collection_preview_card';
|
||||
import Card from '@/mastodon/features/status/components/card';
|
||||
import { displayMedia } from '@/mastodon/initial_state';
|
||||
import type {
|
||||
MediaAttachment,
|
||||
MediaAttachmentShape,
|
||||
} from '@/mastodon/models/status';
|
||||
import { isMediaAttachmentOfType } from '@/mastodon/models/status';
|
||||
import {
|
||||
selectExpandedStatus,
|
||||
selectMediaMatchFilters,
|
||||
selectPictureInPicture,
|
||||
} from '@/mastodon/selectors/statuses';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import { compareUrls } from '@/mastodon/utils/compare_urls';
|
||||
|
||||
import { PictureInPicturePlaceholder } from '../picture_in_picture_placeholder';
|
||||
|
||||
export const StatusAttachments: React.FC<{
|
||||
statusId: string;
|
||||
contextType?: string;
|
||||
}> = ({ statusId, contextType }) => {
|
||||
// Selectors
|
||||
const status = useAppSelector((state) =>
|
||||
selectExpandedStatus(state, statusId),
|
||||
);
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const attachment = status.media_attachments[0];
|
||||
if (attachment) {
|
||||
return (
|
||||
<MediaAttachments
|
||||
statusId={statusId}
|
||||
accountId={status.account.id}
|
||||
contextType={contextType}
|
||||
sensitive={status.sensitive}
|
||||
language={status.translation?.language ?? status.language}
|
||||
attachment={attachment}
|
||||
restAttachments={status.media_attachments.slice(1)}
|
||||
defaultPosterUrl={status.account.avatar_static}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Don't display the card or collection if this is a quote.
|
||||
if (status.quote) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const card = status.card;
|
||||
const collection = card?.url
|
||||
? status.tagged_collections.find(({ url }) => compareUrls(url, card.url))
|
||||
: status.tagged_collections[0];
|
||||
if (card && !collection) {
|
||||
return (
|
||||
<Card
|
||||
key={`${status.id}-${status.edited_at}`}
|
||||
card={card}
|
||||
sensitive={status.sensitive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (collection) {
|
||||
return <CollectionPreviewCard collection={collection} headingLevel='h2' />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
type TMediaGallery = React.ComponentClass<
|
||||
{
|
||||
media: Immutable.List<MediaAttachment>;
|
||||
height: number;
|
||||
onOpenMedia: (index: number) => void;
|
||||
onToggleVisibility?: () => void;
|
||||
sensitive?: boolean;
|
||||
lang?: string;
|
||||
visible?: boolean;
|
||||
autoplay?: boolean;
|
||||
matchedFilters?: (string | null | undefined)[];
|
||||
cacheWidth?: () => void;
|
||||
defaultWidth?: number;
|
||||
},
|
||||
{ visible: boolean; width?: number }
|
||||
>;
|
||||
|
||||
const MediaGallery = lazy<TMediaGallery>(
|
||||
() => import('@/mastodon/components/media_gallery'),
|
||||
);
|
||||
const Audio = lazy(() => import('@/mastodon/features/audio'));
|
||||
const Video = lazy(() => import('@/mastodon/features/video'));
|
||||
|
||||
const MediaAttachments: React.FC<{
|
||||
statusId: string;
|
||||
accountId: string;
|
||||
contextType?: string;
|
||||
sensitive: boolean;
|
||||
language: string;
|
||||
attachment: MediaAttachmentShape;
|
||||
restAttachments: MediaAttachmentShape[];
|
||||
defaultPosterUrl: string;
|
||||
}> = ({
|
||||
statusId,
|
||||
accountId,
|
||||
contextType,
|
||||
sensitive,
|
||||
language,
|
||||
attachment,
|
||||
defaultPosterUrl,
|
||||
}) => {
|
||||
const description =
|
||||
attachment.translation?.description ?? attachment.description;
|
||||
|
||||
const immutableAttachments = useAppSelector(
|
||||
(state) =>
|
||||
state.statuses.getIn(
|
||||
statusId,
|
||||
'media_attachments',
|
||||
) as Immutable.List<MediaAttachment>,
|
||||
);
|
||||
const mediaFilters = useAppSelector((state) =>
|
||||
selectMediaMatchFilters(state, { statusId, contextType }),
|
||||
);
|
||||
const pictureInPicture = useAppSelector((state) =>
|
||||
selectPictureInPicture(state, statusId),
|
||||
);
|
||||
|
||||
const [showMedia, setShowMedia] = useState(
|
||||
() =>
|
||||
mediaFilters.length === 0 &&
|
||||
((displayMedia !== 'hide_all' && !sensitive) ||
|
||||
displayMedia === 'show_all'),
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleToggleMediaVisibility = useCallback(() => {
|
||||
setShowMedia((prev) => !prev);
|
||||
}, []);
|
||||
const handleOpenMedia = useCallback(
|
||||
(index: number) => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'MEDIA',
|
||||
modalProps: { statusId, media: attachment, index, lang: language },
|
||||
}),
|
||||
);
|
||||
},
|
||||
[attachment, dispatch, language, statusId],
|
||||
);
|
||||
const handleOpenVideo = useCallback(
|
||||
(options: {
|
||||
startTime: number;
|
||||
autoPlay: boolean;
|
||||
defaultVolume: number;
|
||||
}) => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'VIDEO',
|
||||
modalProps: {
|
||||
statusId,
|
||||
options,
|
||||
media: attachment,
|
||||
lang: language,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
[attachment, dispatch, language, statusId],
|
||||
);
|
||||
const handleDeployPictureInPicture: DeployPictureInPictureCallback =
|
||||
useCallback(
|
||||
(type, props) => {
|
||||
if (!accountId || !pictureInPicture.available) {
|
||||
return;
|
||||
}
|
||||
void dispatch(
|
||||
deployPictureInPicture({
|
||||
statusId,
|
||||
accountId,
|
||||
playerType: type,
|
||||
props,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dispatch, pictureInPicture.available, accountId, statusId],
|
||||
);
|
||||
|
||||
let aspectRatio = '3 / 2';
|
||||
if (
|
||||
isMediaAttachmentOfType(attachment, 'image') ||
|
||||
isMediaAttachmentOfType(attachment, 'video') ||
|
||||
isMediaAttachmentOfType(attachment, 'gifv')
|
||||
) {
|
||||
aspectRatio = `${attachment.meta.original.width} / ${attachment.meta.original.height}`;
|
||||
} else if (isMediaAttachmentOfType(attachment, 'audio')) {
|
||||
aspectRatio = '16 / 9';
|
||||
}
|
||||
|
||||
if (pictureInPicture.inUse) {
|
||||
return <PictureInPicturePlaceholder aspectRatio={aspectRatio} />;
|
||||
}
|
||||
|
||||
if (isMediaAttachmentOfType(attachment, 'audio')) {
|
||||
const { colors, original } = attachment.meta;
|
||||
return (
|
||||
<Suspense
|
||||
fallback={<div className='audio-player' style={{ aspectRatio }} />}
|
||||
>
|
||||
<Audio
|
||||
src={attachment.url}
|
||||
alt={description}
|
||||
lang={language}
|
||||
poster={attachment.preview_url || defaultPosterUrl}
|
||||
backgroundColor={colors.background}
|
||||
foregroundColor={colors.foreground}
|
||||
accentColor={colors.accent}
|
||||
duration={original.duration}
|
||||
deployPictureInPicture={handleDeployPictureInPicture}
|
||||
sensitive={sensitive}
|
||||
blurhash={attachment.blurhash}
|
||||
visible={showMedia}
|
||||
onToggleVisibility={handleToggleMediaVisibility}
|
||||
matchedFilters={mediaFilters}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMediaAttachmentOfType(attachment, 'video')) {
|
||||
const { original } = attachment.meta;
|
||||
return (
|
||||
<Suspense
|
||||
fallback={<div className='video-player' style={{ aspectRatio }} />}
|
||||
>
|
||||
<Video
|
||||
src={attachment.url}
|
||||
alt={description}
|
||||
lang={language}
|
||||
preview={attachment.preview_url}
|
||||
frameRate={original.frame_rate}
|
||||
aspectRatio={aspectRatio}
|
||||
blurhash={attachment.blurhash}
|
||||
sensitive={sensitive}
|
||||
onOpenVideo={handleOpenVideo}
|
||||
deployPictureInPicture={handleDeployPictureInPicture}
|
||||
visible={showMedia}
|
||||
onToggleVisibility={handleToggleMediaVisibility}
|
||||
matchedFilters={mediaFilters}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={<div className='media-player' style={{ aspectRatio }} />}
|
||||
>
|
||||
<MediaGallery
|
||||
media={immutableAttachments}
|
||||
lang={language}
|
||||
sensitive={sensitive}
|
||||
height={110}
|
||||
onOpenMedia={handleOpenMedia}
|
||||
visible={showMedia}
|
||||
onToggleVisibility={handleToggleMediaVisibility}
|
||||
matchedFilters={mediaFilters}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import classNames from 'classnames';
|
||||
|
||||
import { useSpring, animated, config } from '@react-spring/web';
|
||||
|
||||
import type { DeployPictureInPictureCallback } from '@/mastodon/actions/picture_in_picture';
|
||||
import DownloadIcon from '@/material-icons/400-24px/download.svg?react';
|
||||
import Forward5Icon from '@/material-icons/400-24px/forward_5-fill.svg?react';
|
||||
import PauseIcon from '@/material-icons/400-24px/pause-fill.svg?react';
|
||||
@@ -68,19 +69,7 @@ export const Audio: React.FC<{
|
||||
startPlaying?: boolean;
|
||||
startVolume?: number;
|
||||
startMuted?: boolean;
|
||||
deployPictureInPicture?: (
|
||||
type: string,
|
||||
mediaProps: {
|
||||
src: string;
|
||||
muted: boolean;
|
||||
volume: number;
|
||||
currentTime: number;
|
||||
poster?: string;
|
||||
backgroundColor: string;
|
||||
foregroundColor: string;
|
||||
accentColor: string;
|
||||
},
|
||||
) => void;
|
||||
deployPictureInPicture?: DeployPictureInPictureCallback;
|
||||
matchedFilters?: string[];
|
||||
}> = ({
|
||||
src,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useCallback, useId, useState } from 'react';
|
||||
import { useCallback, useId, useMemo, useState } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { isMap } from 'immutable';
|
||||
|
||||
import punycode from 'punycode/';
|
||||
|
||||
import DescriptionIcon from '@/material-icons/400-24px/description-fill.svg?react';
|
||||
@@ -14,7 +16,7 @@ import { Icon } from 'mastodon/components/icon';
|
||||
import { MoreFromAuthor } from 'mastodon/components/more_from_author';
|
||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||
import { displayMedia, useBlurhash } from 'mastodon/initial_state';
|
||||
import type { Card as CardType } from 'mastodon/models/status';
|
||||
import type { CardShape, Card as CardType } from 'mastodon/models/status';
|
||||
|
||||
const IDNA_PREFIX = 'xn--';
|
||||
|
||||
@@ -66,27 +68,26 @@ const handleIframeUrl = (html: string, url: string, providerName: string) => {
|
||||
const hideAllMedia = displayMedia === 'hide_all';
|
||||
|
||||
interface CardProps {
|
||||
card: CardType | null;
|
||||
card: CardType | CardShape | null;
|
||||
sensitive?: boolean;
|
||||
}
|
||||
|
||||
const CardVideo: React.FC<Pick<CardProps, 'card'>> = ({ card }) => (
|
||||
const CardVideo: React.FC<{ card: CardShape }> = ({ card }) => (
|
||||
<div
|
||||
className='status-card__image status-card-video'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: card
|
||||
? handleIframeUrl(
|
||||
card.get('html'),
|
||||
card.get('url'),
|
||||
card.get('provider_name'),
|
||||
)
|
||||
: '',
|
||||
__html: handleIframeUrl(card.html, card.url, card.provider_name),
|
||||
}}
|
||||
style={{ aspectRatio: '16 / 9' }}
|
||||
/>
|
||||
);
|
||||
|
||||
const Card: React.FC<CardProps> = ({ card, sensitive }) => {
|
||||
const Card: React.FC<CardProps> = ({ card: rawCard, sensitive }) => {
|
||||
const card: CardShape | null = useMemo(
|
||||
() => (isMap(rawCard) ? (rawCard.toJS() as CardShape) : rawCard),
|
||||
[rawCard],
|
||||
);
|
||||
|
||||
const [previewLoaded, setPreviewLoaded] = useState(false);
|
||||
const [embedded, setEmbedded] = useState(false);
|
||||
const [revealed, setRevealed] = useState(!sensitive && !hideAllMedia);
|
||||
@@ -116,52 +117,43 @@ const Card: React.FC<CardProps> = ({ card, sensitive }) => {
|
||||
}
|
||||
|
||||
const provider =
|
||||
card.get('provider_name').length === 0
|
||||
? decodeIDNA(getHostname(card.get('url')))
|
||||
: card.get('provider_name');
|
||||
const interactive = card.get('type') === 'video';
|
||||
const language = card.get('language') ?? '';
|
||||
const hasImage = (card.get('image')?.length ?? 0) > 0;
|
||||
const largeImage =
|
||||
(hasImage && card.get('width') > card.get('height')) || interactive;
|
||||
const showAuthor = !!card.getIn(['authors', 0, 'accountId']);
|
||||
card.provider_name.length === 0
|
||||
? decodeIDNA(getHostname(card.url))
|
||||
: card.provider_name;
|
||||
const interactive = card.type === 'video';
|
||||
const language = card.language ?? '';
|
||||
const hasImage = (card.image?.length ?? 0) > 0;
|
||||
const largeImage = (hasImage && card.width > card.height) || interactive;
|
||||
const author = card.authors.at(0)?.accountId;
|
||||
|
||||
const description = (
|
||||
<div className='status-card__content' dir='auto'>
|
||||
<span className='status-card__host'>
|
||||
<span lang={language}>{provider}</span>
|
||||
{card.get('published_at') && (
|
||||
{card.published_at && (
|
||||
<>
|
||||
{' '}
|
||||
·{' '}
|
||||
<RelativeTimestamp
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
timestamp={card.get('published_at')!}
|
||||
/>
|
||||
· <RelativeTimestamp timestamp={card.published_at} />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<strong
|
||||
className='status-card__title'
|
||||
title={card.get('title')}
|
||||
lang={language}
|
||||
>
|
||||
{card.get('title')}
|
||||
<strong className='status-card__title' title={card.title} lang={language}>
|
||||
{card.title}
|
||||
</strong>
|
||||
|
||||
{!showAuthor &&
|
||||
(card.get('author_name').length > 0 ? (
|
||||
{!author &&
|
||||
(card.author_name.length > 0 ? (
|
||||
<span className='status-card__author'>
|
||||
<FormattedMessage
|
||||
id='link_preview.author'
|
||||
defaultMessage='By {name}'
|
||||
values={{ name: <strong>{card.get('author_name')}</strong> }}
|
||||
values={{ name: <strong>{card.author_name}</strong> }}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span className='status-card__description' lang={language}>
|
||||
{card.get('description')}
|
||||
{card.description}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -172,7 +164,7 @@ const Card: React.FC<CardProps> = ({ card, sensitive }) => {
|
||||
aspectRatio: '1',
|
||||
};
|
||||
|
||||
if (largeImage && card.get('type') === 'video') {
|
||||
if (largeImage && card.type === 'video') {
|
||||
thumbnailStyle.aspectRatio = `16 / 9`;
|
||||
} else if (largeImage) {
|
||||
thumbnailStyle.aspectRatio = '1.91 / 1';
|
||||
@@ -185,15 +177,15 @@ const Card: React.FC<CardProps> = ({ card, sensitive }) => {
|
||||
className={classNames('status-card__image-preview', {
|
||||
'status-card__image-preview--hidden': revealed && previewLoaded,
|
||||
})}
|
||||
hash={card.get('blurhash')}
|
||||
hash={card.blurhash}
|
||||
dummy={!useBlurhash}
|
||||
/>
|
||||
);
|
||||
|
||||
const thumbnailDescription = card.get('image_description');
|
||||
const thumbnailDescription = card.image_description;
|
||||
const thumbnail = (
|
||||
<img
|
||||
src={card.get('image') ?? undefined}
|
||||
src={card.image ?? undefined}
|
||||
alt={thumbnailDescription}
|
||||
title={thumbnailDescription}
|
||||
lang={language}
|
||||
@@ -251,7 +243,7 @@ const Card: React.FC<CardProps> = ({ card, sensitive }) => {
|
||||
<Icon id='play' icon={PlayArrowIcon} />
|
||||
</button>
|
||||
<a
|
||||
href={card.get('url')}
|
||||
href={card.url}
|
||||
onClick={handleExternalLinkClick}
|
||||
target='_blank'
|
||||
rel='noopener'
|
||||
@@ -271,7 +263,7 @@ const Card: React.FC<CardProps> = ({ card, sensitive }) => {
|
||||
<div className={classNames('status-card', { expanded: largeImage })}>
|
||||
{embed}
|
||||
<a
|
||||
href={card.get('url')}
|
||||
href={card.url}
|
||||
target='_blank'
|
||||
rel='noopener'
|
||||
onClick={revealed ? undefined : handleReveal}
|
||||
@@ -281,7 +273,7 @@ const Card: React.FC<CardProps> = ({ card, sensitive }) => {
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
} else if (card.get('image')) {
|
||||
} else if (card.image) {
|
||||
embed = (
|
||||
<div className='status-card__image'>
|
||||
{canvas}
|
||||
@@ -300,10 +292,10 @@ const Card: React.FC<CardProps> = ({ card, sensitive }) => {
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
href={card.get('url')}
|
||||
href={card.url}
|
||||
className={classNames('status-card', {
|
||||
expanded: largeImage,
|
||||
bottomless: showAuthor,
|
||||
bottomless: !!author,
|
||||
})}
|
||||
target='_blank'
|
||||
rel='noopener'
|
||||
@@ -312,11 +304,7 @@ const Card: React.FC<CardProps> = ({ card, sensitive }) => {
|
||||
{description}
|
||||
</a>
|
||||
|
||||
{showAuthor && (
|
||||
<MoreFromAuthor
|
||||
accountId={card.getIn(['authors', 0, 'accountId']) as string}
|
||||
/>
|
||||
)}
|
||||
{author && <MoreFromAuthor accountId={author} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import classNames from 'classnames';
|
||||
import { useSpring, animated, config } from '@react-spring/web';
|
||||
import { throttle } from 'lodash';
|
||||
|
||||
import type { DeployPictureInPictureCallback } from '@/mastodon/actions/picture_in_picture';
|
||||
import Forward5Icon from '@/material-icons/400-24px/forward_5-fill.svg?react';
|
||||
import FullscreenIcon from '@/material-icons/400-24px/fullscreen.svg?react';
|
||||
import FullscreenExitIcon from '@/material-icons/400-24px/fullscreen_exit.svg?react';
|
||||
@@ -174,15 +175,7 @@ export const Video: React.FC<{
|
||||
alwaysVisible?: boolean;
|
||||
visible?: boolean;
|
||||
onToggleVisibility?: () => void;
|
||||
deployPictureInPicture?: (
|
||||
type: string,
|
||||
mediaProps: {
|
||||
src: string;
|
||||
muted: boolean;
|
||||
volume: number;
|
||||
currentTime: number;
|
||||
},
|
||||
) => void;
|
||||
deployPictureInPicture?: DeployPictureInPictureCallback;
|
||||
blurhash?: string;
|
||||
startPlaying?: boolean;
|
||||
startTime?: number;
|
||||
@@ -241,7 +234,7 @@ export const Video: React.FC<{
|
||||
(c: HTMLVideoElement | null) => {
|
||||
if (videoRef.current && !videoRef.current.paused && c === null) {
|
||||
deployPictureInPicture?.('video', {
|
||||
src: src,
|
||||
src,
|
||||
currentTime: videoRef.current.currentTime,
|
||||
muted: videoRef.current.muted,
|
||||
volume: videoRef.current.volume,
|
||||
|
||||
@@ -55,6 +55,14 @@ export interface AccountShape extends Required<
|
||||
moved: string | null;
|
||||
url: string;
|
||||
}
|
||||
export type AccountShapeFull = Omit<
|
||||
AccountShape,
|
||||
'emojis' | 'fields' | 'roles'
|
||||
> & {
|
||||
emojis: CustomEmoji[];
|
||||
fields: AccountFieldShape[];
|
||||
roles: AccountRoleShape[];
|
||||
};
|
||||
|
||||
export type Account = RecordOf<AccountShape>;
|
||||
|
||||
|
||||
@@ -2,7 +2,15 @@ import type { RecordOf } from 'immutable';
|
||||
|
||||
import type { ApiCollectionJSON } from '@/mastodon/api_types/collections';
|
||||
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
||||
import type { ApiMediaAttachmentJSON } from '@/mastodon/api_types/media_attachments';
|
||||
import type {
|
||||
ApiAudioAttachmentJSON,
|
||||
ApiGifvAttachmentJSON,
|
||||
ApiImageAttachmentJSON,
|
||||
ApiMediaAttachmentJSON,
|
||||
ApiUnknownAttachmentJSON,
|
||||
ApiVideoAttachmentJSON,
|
||||
MediaAttachmentType,
|
||||
} from '@/mastodon/api_types/media_attachments';
|
||||
import type {
|
||||
ApiQuoteJSON,
|
||||
ApiQuotePolicyJSON,
|
||||
@@ -17,6 +25,8 @@ import type {
|
||||
StatusVisibility,
|
||||
} from '@/mastodon/api_types/statuses';
|
||||
|
||||
import type { AccountShapeFull } from './account';
|
||||
|
||||
export type { StatusVisibility } from '@/mastodon/api_types/statuses';
|
||||
|
||||
// Temporary until we type it correctly
|
||||
@@ -75,6 +85,10 @@ export interface StatusShape {
|
||||
replies_count: number;
|
||||
visibility: StatusVisibility;
|
||||
}
|
||||
export type ExpandedStatusShape = Omit<StatusShape, 'account' | 'reblog'> & {
|
||||
account: AccountShapeFull;
|
||||
reblog?: Omit<ExpandedStatusShape, 'reblog'>;
|
||||
};
|
||||
|
||||
export type CardShape = Omit<ApiPreviewCardJSON, 'authors'> & {
|
||||
authors: (Omit<ApiPreviewCardAuthorJSON, 'author'> & {
|
||||
@@ -86,14 +100,42 @@ export type Card = RecordOf<CardShape>;
|
||||
|
||||
export type MediaAttachment = Immutable.Map<string, unknown>;
|
||||
|
||||
export type MediaAttachmentShape = Omit<
|
||||
ApiMediaAttachmentJSON,
|
||||
'remote_url'
|
||||
> & {
|
||||
export type MediaAttachmentShape<
|
||||
TAttachmentJSON extends ApiMediaAttachmentJSON = ApiMediaAttachmentJSON,
|
||||
> = Omit<TAttachmentJSON, 'remote_url'> & {
|
||||
remote_url: string | null;
|
||||
translation?: string;
|
||||
translation?: {
|
||||
description: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function isMediaAttachmentOfType(
|
||||
attachment: MediaAttachmentShape,
|
||||
type: 'image',
|
||||
): attachment is MediaAttachmentShape<ApiImageAttachmentJSON>;
|
||||
export function isMediaAttachmentOfType(
|
||||
attachment: MediaAttachmentShape,
|
||||
type: 'video',
|
||||
): attachment is MediaAttachmentShape<ApiVideoAttachmentJSON>;
|
||||
export function isMediaAttachmentOfType(
|
||||
attachment: MediaAttachmentShape,
|
||||
type: 'gifv',
|
||||
): attachment is MediaAttachmentShape<ApiGifvAttachmentJSON>;
|
||||
export function isMediaAttachmentOfType(
|
||||
attachment: MediaAttachmentShape,
|
||||
type: 'audio',
|
||||
): attachment is MediaAttachmentShape<ApiAudioAttachmentJSON>;
|
||||
export function isMediaAttachmentOfType(
|
||||
attachment: MediaAttachmentShape,
|
||||
type: 'unknown',
|
||||
): attachment is MediaAttachmentShape<ApiUnknownAttachmentJSON>;
|
||||
export function isMediaAttachmentOfType(
|
||||
attachment: MediaAttachmentShape,
|
||||
type: MediaAttachmentType,
|
||||
) {
|
||||
return attachment.type === type;
|
||||
}
|
||||
|
||||
export type CollectionAttachment = RecordOf<ApiCollectionJSON>;
|
||||
|
||||
export type FilterResult = Omit<ApiFilterResultJSON, 'filter'> & {
|
||||
|
||||
@@ -11,10 +11,10 @@ export interface PIPMediaProps {
|
||||
muted: boolean;
|
||||
volume: number;
|
||||
currentTime: number;
|
||||
poster: string;
|
||||
backgroundColor: string;
|
||||
foregroundColor: string;
|
||||
accentColor: string;
|
||||
poster?: string;
|
||||
backgroundColor?: string;
|
||||
foregroundColor?: string;
|
||||
accentColor?: string;
|
||||
}
|
||||
|
||||
interface PIPStateWithValue extends Partial<PIPMediaProps> {
|
||||
@@ -34,7 +34,7 @@ const initialState = {
|
||||
muted: false,
|
||||
volume: 0,
|
||||
currentTime: 0,
|
||||
};
|
||||
} satisfies PIPStateEmpty;
|
||||
|
||||
export const pictureInPictureReducer: Reducer<PIPState> = (
|
||||
state = initialState,
|
||||
|
||||
@@ -3,7 +3,11 @@ import { Record as ImmutableRecord, List as ImmutableList } from 'immutable';
|
||||
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import { accountDefaultValues } from 'mastodon/models/account';
|
||||
import type { Account, AccountShape } from 'mastodon/models/account';
|
||||
import type {
|
||||
Account,
|
||||
AccountShape,
|
||||
AccountShapeFull,
|
||||
} from 'mastodon/models/account';
|
||||
import type { Relationship } from 'mastodon/models/relationship';
|
||||
import { createAppSelector } from 'mastodon/store';
|
||||
import type { RootState } from 'mastodon/store';
|
||||
@@ -50,6 +54,11 @@ export function makeGetAccount() {
|
||||
);
|
||||
}
|
||||
|
||||
export const selectPlainAccount = createAppSelector(
|
||||
[(state, accountId: string) => state.accounts.get(accountId)],
|
||||
(account) => (account ? (account.toJS() as AccountShapeFull) : null),
|
||||
);
|
||||
|
||||
export const getAccountHidden = createAppSelector(
|
||||
[
|
||||
(state, id: string) => state.accounts.get(id)?.hidden,
|
||||
|
||||
@@ -3,6 +3,14 @@ import { createSelector } from '@reduxjs/toolkit';
|
||||
import type { RootState } from 'mastodon/store';
|
||||
import { toServerSideType } from 'mastodon/utils/filters';
|
||||
|
||||
export interface FilterShape {
|
||||
id: string;
|
||||
title: string;
|
||||
context: string[];
|
||||
expires_at: string | null;
|
||||
filter_action: 'hide' | 'blur' | 'warn';
|
||||
}
|
||||
|
||||
// TODO: move to `app/javascript/mastodon/models` and use more globally
|
||||
type Filter = Immutable.Map<string, unknown>;
|
||||
|
||||
@@ -12,7 +20,7 @@ type FilterResult = Immutable.Map<string, unknown>;
|
||||
export const getFilters = createSelector(
|
||||
[
|
||||
(state: RootState) => state.filters as Immutable.Map<string, Filter>,
|
||||
(_, { contextType }: { contextType: string }) => contextType,
|
||||
(_, { contextType }: { contextType?: string }) => contextType,
|
||||
],
|
||||
(filters, contextType) => {
|
||||
if (!contextType) {
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
|
||||
import { createAppSelector } from 'mastodon/store';
|
||||
|
||||
import type { StatusShape } from '../models/status';
|
||||
import type { ExpandedStatusShape, StatusShape } from '../models/status';
|
||||
|
||||
import { selectPlainAccount } from './accounts';
|
||||
import type { FilterShape } from './filters';
|
||||
import { getFilters } from './filters';
|
||||
|
||||
export const getStatusList = createAppSelector(
|
||||
[
|
||||
@@ -21,3 +25,84 @@ export const selectPlainStatus = createAppSelector(
|
||||
return status.toJS() as unknown as StatusShape;
|
||||
},
|
||||
);
|
||||
|
||||
export const selectAccountStatus = createAppSelector(
|
||||
[
|
||||
selectPlainStatus,
|
||||
(state, statusId: string) => {
|
||||
const accountId = state.statuses.getIn(statusId, 'account');
|
||||
if (typeof accountId !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return selectPlainAccount(state, accountId);
|
||||
},
|
||||
],
|
||||
(status, account) => {
|
||||
if (!status || !account) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...status,
|
||||
account,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export const selectExpandedStatus = createAppSelector(
|
||||
[
|
||||
selectAccountStatus,
|
||||
(state, statusId: string) => {
|
||||
const reblogId = state.statuses.getIn(statusId, 'reblog');
|
||||
if (typeof reblogId !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return selectAccountStatus(state, reblogId);
|
||||
},
|
||||
],
|
||||
(status, reblog): ExpandedStatusShape | null => {
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...status,
|
||||
reblog: reblog ?? undefined,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export const selectPictureInPicture = createAppSelector(
|
||||
[
|
||||
(state, statusId: string) =>
|
||||
state.picture_in_picture.type !== null &&
|
||||
state.picture_in_picture.statusId === statusId,
|
||||
(state) => state.meta.get('layout') !== 'mobile',
|
||||
],
|
||||
(inUse, available) => ({
|
||||
inUse: inUse && available,
|
||||
available,
|
||||
}),
|
||||
);
|
||||
|
||||
export const selectMediaMatchFilters = createAppSelector(
|
||||
[
|
||||
(state, { statusId }: { statusId: string }) =>
|
||||
selectPlainStatus(state, statusId),
|
||||
getFilters,
|
||||
],
|
||||
(status, immutableFilters) => {
|
||||
const filters = immutableFilters
|
||||
? (immutableFilters.toJS() as unknown as Record<string, FilterShape>)
|
||||
: null;
|
||||
const mediaFilters: string[] = [];
|
||||
if (status?.filtered && filters) {
|
||||
for (const { filter } of status.filtered) {
|
||||
if (filters[filter]?.filter_action === 'blur') {
|
||||
mediaFilters.push(filters[filter].title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mediaFilters;
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user