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 (