mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-28 17:38:23 -05:00
Status redesign: Video embeds and author attribution (#40566)
This commit is contained in:
@@ -1,13 +1,17 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { fn } from 'storybook/test';
|
||||
|
||||
import { Card, CardBody, CardTitle } from './index';
|
||||
import { Button } from '../button/redesign';
|
||||
|
||||
import { Card, CardActions, CardBody, CardTitle } from './index';
|
||||
|
||||
interface StoryProps {
|
||||
actions: boolean;
|
||||
avatar: boolean;
|
||||
bodyText: string;
|
||||
clamp: boolean;
|
||||
delete: boolean;
|
||||
descriptionText: string;
|
||||
image: boolean;
|
||||
linked: boolean;
|
||||
timestamp: boolean;
|
||||
@@ -16,25 +20,30 @@ interface StoryProps {
|
||||
|
||||
const img =
|
||||
'https://images.pexels.com/photos/16859306/pexels-photo-16859306.jpeg';
|
||||
const actionCb = fn().mockName('action click');
|
||||
const deleteCb = fn().mockName('onDelete');
|
||||
|
||||
const meta = {
|
||||
title: 'Redesign/Card',
|
||||
args: {
|
||||
actions: false,
|
||||
avatar: false,
|
||||
bodyText: 'Here is some card text.',
|
||||
clamp: true,
|
||||
delete: false,
|
||||
descriptionText: '',
|
||||
image: false,
|
||||
linked: false,
|
||||
timestamp: false,
|
||||
titleText: 'Example title',
|
||||
},
|
||||
render({
|
||||
actions,
|
||||
avatar,
|
||||
bodyText,
|
||||
clamp,
|
||||
delete: deleteBtn,
|
||||
descriptionText,
|
||||
image,
|
||||
linked,
|
||||
timestamp,
|
||||
@@ -65,6 +74,16 @@ const meta = {
|
||||
{titleText}
|
||||
</CardTitle>
|
||||
<CardBody noClamp={!clamp}>{body}</CardBody>
|
||||
|
||||
{descriptionText && (
|
||||
<CardBody isDescription>{descriptionText}</CardBody>
|
||||
)}
|
||||
|
||||
{actions && (
|
||||
<CardActions>
|
||||
<Button onClick={actionCb}>Click me</Button>
|
||||
</CardActions>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
@@ -108,6 +127,12 @@ export const Linked: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Description: Story = {
|
||||
args: {
|
||||
descriptionText: 'This is a description',
|
||||
},
|
||||
};
|
||||
|
||||
export const Long: Story = {
|
||||
args: {
|
||||
bodyText: [
|
||||
@@ -118,3 +143,9 @@ export const Long: Story = {
|
||||
].join('\n'),
|
||||
},
|
||||
};
|
||||
|
||||
export const Actions: Story = {
|
||||
args: {
|
||||
actions: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type React from 'react';
|
||||
import { createContext, use, useId } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
@@ -136,3 +135,25 @@ export const CardBody = <As extends React.ElementType = 'div'>({
|
||||
</Comp>
|
||||
);
|
||||
};
|
||||
|
||||
type CardActionProps<As extends React.ElementType> = PolymorphicProps<
|
||||
{
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
},
|
||||
As
|
||||
>;
|
||||
|
||||
export const CardActions = <As extends React.ElementType = 'div'>({
|
||||
as: asComp,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CardActionProps<As>) => {
|
||||
const Comp = asComp ?? 'div';
|
||||
return (
|
||||
<Comp {...props} className={classNames(className, classes.actions)}>
|
||||
{children}
|
||||
</Comp>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -69,7 +69,7 @@ a.root {
|
||||
flex-grow: 1;
|
||||
|
||||
* + & {
|
||||
margin-block-start: var(--space-xs);
|
||||
padding-block-start: var(--space-xs);
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -102,17 +102,20 @@ a.body {
|
||||
.image {
|
||||
contain: size;
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
grid-row: 1 / span 9999; // Handles unlimited space
|
||||
margin-block: calc(-1 * var(--space-sm));
|
||||
|
||||
&,
|
||||
& > img {
|
||||
> img {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-block-start: var(--space-sm);
|
||||
}
|
||||
|
||||
.clamp {
|
||||
@include mixins.line-clamp(2);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
@use '@/styles/mastodon/mixins';
|
||||
|
||||
.cardMedia {
|
||||
> :global(.status-card) {
|
||||
margin-top: 0;
|
||||
border: var(--border-thin);
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
> a {
|
||||
transition: background 200ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-bg-highlight);
|
||||
}
|
||||
|
||||
:global(.status-card__host) {
|
||||
@include mixins.type-label-md;
|
||||
|
||||
// Overrides hover styles.
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
:global(.status-card__title) {
|
||||
@include mixins.type-label-lg;
|
||||
|
||||
// Overrides hover styles.
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
:global(.status-card__author) {
|
||||
@include mixins.type-body-compact;
|
||||
|
||||
// Overrides hover styles.
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cardAuthor {
|
||||
padding-inline-start: var(--space-xs);
|
||||
gap: var(--space-2xs);
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { lazy, Suspense, useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
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 MediaCard from '@/mastodon/features/status/components/card';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useExpandedStatus } from '@/mastodon/hooks/useStatus';
|
||||
import { useToggle } from '@/mastodon/hooks/useToggle';
|
||||
import { displayMedia } from '@/mastodon/initial_state';
|
||||
@@ -19,10 +22,15 @@ import { selectMediaFilters } from '@/mastodon/selectors/filters';
|
||||
import { selectPictureInPicture } from '@/mastodon/selectors/statuses';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import { compareUrls } from '@/mastodon/utils/compare_urls';
|
||||
import { decodeIDNA } from '@/mastodon/utils/links';
|
||||
|
||||
import { Card, CardBody, CardTitle } from '../card';
|
||||
import { Avatar } from '../avatar';
|
||||
import { Button } from '../button/redesign';
|
||||
import { Card, CardActions, CardBody, CardTitle } from '../card';
|
||||
import { DisplayName } from '../display_name';
|
||||
import { RelativeTimestamp } from '../relative_timestamp';
|
||||
|
||||
import classes from './attachments.module.scss';
|
||||
import { useStatusContext } from './hooks';
|
||||
import { PictureInPicturePlaceholder } from './legacy/picture_in_picture_placeholder';
|
||||
import { StatusQuote } from './quote';
|
||||
@@ -278,9 +286,9 @@ const LinkCard: React.FC<{ card: CardShape; status: ExpandedStatusShape }> = ({
|
||||
status,
|
||||
}) => {
|
||||
// Use the old card if we have authors as the new design doesn't have attribution yet.
|
||||
if (card.type === 'video' || card.authors.length > 0) {
|
||||
if (card.type === 'video') {
|
||||
return (
|
||||
<div>
|
||||
<div className={classes.cardMedia}>
|
||||
<MediaCard
|
||||
key={`${status.id}-${status.edited_at}`}
|
||||
card={card}
|
||||
@@ -298,6 +306,8 @@ const LinkCard: React.FC<{ card: CardShape; status: ExpandedStatusShape }> = ({
|
||||
target: '_blank',
|
||||
rel: 'noopener',
|
||||
} as const;
|
||||
// While possible there is more than one author, the previous UI didn't handle it.
|
||||
const authorAccountId = card.authors.at(0)?.accountId;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -307,13 +317,16 @@ const LinkCard: React.FC<{ card: CardShape; status: ExpandedStatusShape }> = ({
|
||||
<RelativeTimestamp timestamp={card.published_at} />
|
||||
)
|
||||
}
|
||||
lang={card.language ?? undefined}
|
||||
>
|
||||
<a
|
||||
href={`${providerUrl.protocol}//${providerUrl.host}`}
|
||||
target='_blank'
|
||||
rel='noopener'
|
||||
>
|
||||
{card.author_name || card.provider_name || providerUrl.host}
|
||||
{card.author_name ||
|
||||
card.provider_name ||
|
||||
decodeIDNA(providerUrl.host)}
|
||||
</a>
|
||||
</CardTitle>
|
||||
<CardBody {...cardLinkProps}>{card.title}</CardBody>
|
||||
@@ -322,6 +335,33 @@ const LinkCard: React.FC<{ card: CardShape; status: ExpandedStatusShape }> = ({
|
||||
{card.description}
|
||||
</CardBody>
|
||||
)}
|
||||
|
||||
{authorAccountId && <LinkCardAuthor authorId={authorAccountId} />}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const LinkCardAuthor: React.FC<{ authorId: string }> = ({ authorId }) => {
|
||||
const author = useAccount(authorId);
|
||||
|
||||
if (!author) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CardActions>
|
||||
<Button
|
||||
as='link'
|
||||
to={`/@${author.get('acct')}`}
|
||||
className={classes.cardAuthor}
|
||||
>
|
||||
<Avatar account={author} />
|
||||
<FormattedMessage
|
||||
id='link_preview.more_from_author'
|
||||
defaultMessage='More from {name}'
|
||||
values={{ name: <DisplayName variant='simple' account={author} /> }}
|
||||
/>
|
||||
</Button>
|
||||
</CardActions>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,8 +6,7 @@ import classNames from 'classnames';
|
||||
|
||||
import { isMap } from 'immutable';
|
||||
|
||||
import punycode from 'punycode/';
|
||||
|
||||
import { decodeIDNA } from '@/mastodon/utils/links';
|
||||
import DescriptionIcon from '@/material-icons/400-24px/description-fill.svg?react';
|
||||
import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react';
|
||||
import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react';
|
||||
@@ -18,19 +17,6 @@ import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||
import { displayMedia, useBlurhash } from 'mastodon/initial_state';
|
||||
import type { CardShape, Card as CardType } from 'mastodon/models/status';
|
||||
|
||||
const IDNA_PREFIX = 'xn--';
|
||||
|
||||
const decodeIDNA = (domain: string) => {
|
||||
return domain
|
||||
.split('.')
|
||||
.map((part) =>
|
||||
part.startsWith(IDNA_PREFIX)
|
||||
? punycode.decode(part.slice(IDNA_PREFIX.length))
|
||||
: part,
|
||||
)
|
||||
.join('.');
|
||||
};
|
||||
|
||||
const getHostname = (url: string) => {
|
||||
const parser = document.createElement('a');
|
||||
parser.href = url;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { on } from 'delegated-events';
|
||||
import punycode from 'punycode/punycode';
|
||||
|
||||
export function setupLinkListeners() {
|
||||
on('click', 'a[data-confirm]', handleConfirmLink);
|
||||
@@ -19,6 +20,19 @@ export function urlToDomain(input: string | URL) {
|
||||
}
|
||||
}
|
||||
|
||||
const IDNA_PREFIX = 'xn--';
|
||||
|
||||
export function decodeIDNA(domain: string) {
|
||||
return domain
|
||||
.split('.')
|
||||
.map((part) =>
|
||||
part.startsWith(IDNA_PREFIX)
|
||||
? punycode.decode(part.slice(IDNA_PREFIX.length))
|
||||
: part,
|
||||
)
|
||||
.join('.');
|
||||
}
|
||||
|
||||
function handleConfirmLink(event: MouseEvent) {
|
||||
const target = event.currentTarget;
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user