Status redesign: Video embeds and author attribution (#40566)

This commit is contained in:
Echo
2026-09-17 11:36:42 +00:00
committed by GitHub
parent fb59dd9419
commit 4e58dca2be
7 changed files with 163 additions and 25 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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