diff --git a/app/javascript/mastodon/components/card/card.stories.tsx b/app/javascript/mastodon/components/card/card.stories.tsx
index c8dc7983bdd..3d0b97c03b8 100644
--- a/app/javascript/mastodon/components/card/card.stories.tsx
+++ b/app/javascript/mastodon/components/card/card.stories.tsx
@@ -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}
{body}
+
+ {descriptionText && (
+ {descriptionText}
+ )}
+
+ {actions && (
+
+
+
+ )}
);
},
@@ -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,
+ },
+};
diff --git a/app/javascript/mastodon/components/card/index.tsx b/app/javascript/mastodon/components/card/index.tsx
index 7306d4d1fc7..efb16204a88 100644
--- a/app/javascript/mastodon/components/card/index.tsx
+++ b/app/javascript/mastodon/components/card/index.tsx
@@ -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 = ({
);
};
+
+type CardActionProps = PolymorphicProps<
+ {
+ children: React.ReactNode;
+ className?: string;
+ },
+ As
+>;
+
+export const CardActions = ({
+ as: asComp,
+ children,
+ className,
+ ...props
+}: CardActionProps) => {
+ const Comp = asComp ?? 'div';
+ return (
+
+ {children}
+
+ );
+};
diff --git a/app/javascript/mastodon/components/card/styles.module.scss b/app/javascript/mastodon/components/card/styles.module.scss
index ef8011f4a33..4304f3951d7 100644
--- a/app/javascript/mastodon/components/card/styles.module.scss
+++ b/app/javascript/mastodon/components/card/styles.module.scss
@@ -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);
}
diff --git a/app/javascript/mastodon/components/status/attachments.module.scss b/app/javascript/mastodon/components/status/attachments.module.scss
new file mode 100644
index 00000000000..867f1c64030
--- /dev/null
+++ b/app/javascript/mastodon/components/status/attachments.module.scss
@@ -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);
+}
diff --git a/app/javascript/mastodon/components/status/attachments.tsx b/app/javascript/mastodon/components/status/attachments.tsx
index 5b319256d17..ac78d2e2a75 100644
--- a/app/javascript/mastodon/components/status/attachments.tsx
+++ b/app/javascript/mastodon/components/status/attachments.tsx
@@ -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 (
-
+
= ({
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 (
@@ -307,13 +317,16 @@ const LinkCard: React.FC<{ card: CardShape; status: ExpandedStatusShape }> = ({
)
}
+ lang={card.language ?? undefined}
>
- {card.author_name || card.provider_name || providerUrl.host}
+ {card.author_name ||
+ card.provider_name ||
+ decodeIDNA(providerUrl.host)}
{card.title}
@@ -322,6 +335,33 @@ const LinkCard: React.FC<{ card: CardShape; status: ExpandedStatusShape }> = ({
{card.description}
)}
+
+ {authorAccountId && }
);
};
+
+const LinkCardAuthor: React.FC<{ authorId: string }> = ({ authorId }) => {
+ const author = useAccount(authorId);
+
+ if (!author) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+};
diff --git a/app/javascript/mastodon/features/status/components/card.tsx b/app/javascript/mastodon/features/status/components/card.tsx
index d9ec5b1d782..e6ded64bac8 100644
--- a/app/javascript/mastodon/features/status/components/card.tsx
+++ b/app/javascript/mastodon/features/status/components/card.tsx
@@ -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;
diff --git a/app/javascript/mastodon/utils/links.ts b/app/javascript/mastodon/utils/links.ts
index b1c53af6b33..96dfbb0d328 100644
--- a/app/javascript/mastodon/utils/links.ts
+++ b/app/javascript/mastodon/utils/links.ts
@@ -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 (