diff --git a/app/javascript/mastodon/components/card/card.stories.tsx b/app/javascript/mastodon/components/card/card.stories.tsx
index 9ff326254d9..c8dc7983bdd 100644
--- a/app/javascript/mastodon/components/card/card.stories.tsx
+++ b/app/javascript/mastodon/components/card/card.stories.tsx
@@ -1,9 +1,7 @@
-import { useMemo } from 'react';
-
import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test';
-import { Card, CardBody, CardImage, CardTitle } from './index';
+import { Card, CardBody, CardTitle } from './index';
interface StoryProps {
avatar: boolean;
@@ -42,14 +40,6 @@ const meta = {
timestamp,
titleText,
}) {
- const tsString = useMemo(
- () =>
- timestamp
- ? new Date(new Date().setMonth(0, 1)).toDateString()
- : undefined,
- [timestamp],
- );
-
const body = bodyText.includes('\n')
? bodyText.split('\n').map((line, index) =>
{line}
)
: bodyText;
@@ -63,12 +53,18 @@ const meta = {
: {};
return (
-
-
+ }
+ >
+ }
+ afterContent={timestamp && '1 Jan'}
+ >
{titleText}
{body}
- {image && }
);
},
diff --git a/app/javascript/mastodon/components/card/index.tsx b/app/javascript/mastodon/components/card/index.tsx
index 0219c3860c9..ddb87a449d7 100644
--- a/app/javascript/mastodon/components/card/index.tsx
+++ b/app/javascript/mastodon/components/card/index.tsx
@@ -1,5 +1,5 @@
import type React from 'react';
-import { createContext, use, useId, useState } from 'react';
+import { createContext, use, useId } from 'react';
import { FormattedMessage } from 'react-intl';
@@ -10,7 +10,6 @@ import { TrashIcon } from '@phosphor-icons/react';
import type { PolymorphicProps } from '@/types/polymorphic';
import { IconButton } from '../button/redesign';
-import { RelativeTimestamp } from '../relative_timestamp';
import classes from './styles.module.scss';
@@ -18,6 +17,7 @@ type CardProps = PolymorphicProps<
{
children: React.ReactNode;
className?: string;
+ image?: React.ReactNode;
onDelete?: React.MouseEventHandler;
},
As
@@ -25,43 +25,49 @@ type CardProps = PolymorphicProps<
interface CardContext {
id: string;
- registerImage?: (ref: HTMLImageElement) => void;
}
const CardContext = createContext({ id: '' });
-export const Card = ({
+export const Card = ({
as: asComp,
children,
className,
+ image,
onDelete,
...props
}: CardProps) => {
const Comp = asComp ?? 'div';
const id = useId();
- const [hasImage, registerImage] = useState(null);
// Disable the button if this is an interactive element, including a Link component.
const hideButton =
!onDelete || asComp === 'a' || asComp === 'button' || 'to' in props;
+ const imageComp =
+ typeof image === 'string' ? (
+
+ ) : (
+ image && {image}
+ );
+
return (
-
- {children}
-
+ {children}
+
+ {imageComp}
{!hideButton && (
@@ -72,32 +78,26 @@ export const Card = ({
interface CardTitleProps {
children: React.ReactNode;
- imageSrc?: string;
- imageAlt?: string;
- timestamp?: string;
+ image?: React.ReactNode;
+ afterContent?: React.ReactNode;
}
-export const CardTitle: React.FC = ({
- children,
- imageSrc,
- imageAlt = '',
- timestamp,
-}) => {
+export const CardTitle: React.FC<
+ CardTitleProps & React.ComponentPropsWithRef<'div'>
+> = ({ children, image, afterContent, className, ...props }) => {
const { id } = use(CardContext);
return (
-
- {imageSrc && (
-

- )}
+
+ {image && {image}}
{children}
- {timestamp && (
+ {afterContent && (
// eslint-disable-next-line no-restricted-syntax -- Allow •
•
-
+ {afterContent}
)}
@@ -130,16 +130,3 @@ export const CardBody =
({
);
};
-
-export const CardImage: React.FC> = ({
- alt,
- ...props
-}) => {
- const { registerImage } = use(CardContext);
-
- return (
-
-
![{alt}]()
-
- );
-};
diff --git a/app/javascript/mastodon/components/card/styles.module.scss b/app/javascript/mastodon/components/card/styles.module.scss
index 3259907641d..d22032836b9 100644
--- a/app/javascript/mastodon/components/card/styles.module.scss
+++ b/app/javascript/mastodon/components/card/styles.module.scss
@@ -3,10 +3,14 @@
.root {
border: 0.5px solid var(--color-border-primary);
border-radius: var(--radius-md);
- display: block;
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ min-height: 90px;
overflow: hidden;
padding: var(--space-sm);
position: relative;
+ transition: background 200ms ease-in-out;
&:has(.image) {
column-gap: var(--space-sm);
@@ -40,11 +44,17 @@ a.root {
a {
color: inherit;
text-decoration: none;
+
+ &:hover,
+ &:active {
+ text-decoration: underline;
+ }
}
}
.titleImage {
border-radius: var(--radius-round);
+ display: block;
height: var(--space-lg);
margin-inline-end: var(--space-xs);
overflow: hidden;
@@ -55,6 +65,7 @@ a.root {
@include mixins.type-body-compact;
overflow-wrap: break-word;
+ flex-grow: 1;
a {
color: var(--color-text-brand);
@@ -67,13 +78,25 @@ a.root {
}
}
+// Linked body
+a.body {
+ padding: 0 var(--space-sm) var(--space-sm);
+ margin: calc(-1 * var(--space-sm));
+ margin-top: 0;
+
+ .root:has(&:hover) {
+ background-color: var(--color-bg-highlight);
+ }
+}
+
.image {
contain: size;
grid-column: 2;
grid-row: 1 / span 2;
margin-block: calc(-1 * var(--space-sm));
- img {
+ &,
+ & > img {
height: 100%;
object-fit: cover;
width: 100%;
diff --git a/app/javascript/mastodon/features/compose/redesign/attachments.module.scss b/app/javascript/mastodon/features/compose/redesign/attachments.module.scss
index 30d413cebee..cb512e0d3f4 100644
--- a/app/javascript/mastodon/features/compose/redesign/attachments.module.scss
+++ b/app/javascript/mastodon/features/compose/redesign/attachments.module.scss
@@ -165,3 +165,24 @@
.pollDurationSelect {
text-align: center;
}
+
+// Quotes
+
+.quoteBody {
+ color: inherit;
+ text-decoration: none;
+}
+
+// If hovering over an account link, underline all account links.
+.quoteTitle:has(.quoteAccountLink:hover) {
+ .quoteAccountLink {
+ text-decoration: underline;
+ }
+}
+
+.quoteSpoiler {
+ background: var(--color-bg-highlight);
+ border-radius: var(--radius-sm);
+ margin: var(--space-2xs) 0;
+ padding: var(--space-xs);
+}
diff --git a/app/javascript/mastodon/features/compose/redesign/attachments.tsx b/app/javascript/mastodon/features/compose/redesign/attachments.tsx
index 72fce9bbb70..89d1771e933 100644
--- a/app/javascript/mastodon/features/compose/redesign/attachments.tsx
+++ b/app/javascript/mastodon/features/compose/redesign/attachments.tsx
@@ -4,6 +4,7 @@ import { useAppSelector } from '@/mastodon/store';
import classes from './attachments.module.scss';
import { ComposePoll } from './poll';
+import { ComposeQuote } from './quote';
import {
selectComposeAttachments,
selectComposeHasAttachments,
@@ -25,7 +26,7 @@ export const ComposeAttachments: React.FC<{ className?: string }> = ({
{hasPoll && }
{hasAttachments && }
- {quotedStatusId && }
+ {quotedStatusId && }
);
};
@@ -56,7 +57,3 @@ const ComposeMediaAttachments: React.FC = () => {
);
};
-
-const ComposeQuotedStatus: React.FC<{ id: string }> = ({ id }) => {
- return Quoting status {id}
;
-};
diff --git a/app/javascript/mastodon/features/compose/redesign/quote.tsx b/app/javascript/mastodon/features/compose/redesign/quote.tsx
new file mode 100644
index 00000000000..bc4f6065e96
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/redesign/quote.tsx
@@ -0,0 +1,106 @@
+import type React from 'react';
+import { useCallback } from 'react';
+
+import { FormattedMessage } from 'react-intl';
+
+import { Link } from 'react-router-dom';
+
+import { quoteComposeCancel } from '@/mastodon/actions/compose_typed';
+import { Avatar } from '@/mastodon/components/avatar';
+import { Blurhash } from '@/mastodon/components/blurhash';
+import { Card, CardBody, CardTitle } from '@/mastodon/components/card';
+import { LinkedDisplayName } from '@/mastodon/components/display_name';
+import { EmojiHTML } from '@/mastodon/components/emoji/html';
+import { RelativeTimestamp } from '@/mastodon/components/relative_timestamp';
+import type { AccountStatusShape } from '@/mastodon/models/status';
+import { selectAccountStatus } from '@/mastodon/selectors/statuses';
+import { useAppDispatch, useAppSelector } from '@/mastodon/store';
+import type { OnElementHandler } from '@/mastodon/utils/html';
+
+import classes from './attachments.module.scss';
+
+export const ComposeQuote: React.FC<{ id: string }> = ({ id }) => {
+ const status = useAppSelector((state) => selectAccountStatus(state, id));
+
+ const dispatch = useAppDispatch();
+ const handleDelete = useCallback(() => {
+ dispatch(quoteComposeCancel());
+ }, [dispatch]);
+
+ if (!status) {
+ return null;
+ }
+
+ let imageEle: React.ReactNode = null;
+ const image = status.media_attachments.find(({ type }) => type !== 'unknown');
+ if (image) {
+ imageEle = !status.sensitive ? (
+
+ ) : (
+
+ );
+ }
+
+ const statusTo = `/@${status.account.acct}/${status.id}`;
+
+ return (
+
+
+ }
+ afterContent={
+
+
+
+ }
+ >
+
+
+
+
+ {!status.spoiler_text ? (
+
+ ) : (
+
+
+
+ {status.spoiler_text}
+
+ )}
+
+
+ );
+};
+
+const ComposeQuoteBody: React.FC<{
+ status: AccountStatusShape;
+}> = ({ status }) => {
+ return (
+
+ );
+};
+
+const onStatusLinks: OnElementHandler = (element, { key }, children) => {
+ if (element instanceof HTMLAnchorElement) {
+ return {children};
+ }
+ return undefined;
+};
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 84535e117d0..8f0f8664015 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -526,6 +526,7 @@
"compose.published.body": "Post published.",
"compose.published.open": "Open",
"compose.quotable": "Allow others to quote",
+ "compose.quote.spoiler": "Content:",
"compose.rearrange_modal.cancel": "Cancel",
"compose.rearrange_modal.drag_cancel": "Dragging was cancelled. Attachment index {item, number} was dropped.",
"compose.rearrange_modal.drag_end": "Attachment index {item, number} was moved to index {over, number}.",
diff --git a/app/javascript/mastodon/models/status.ts b/app/javascript/mastodon/models/status.ts
index 3cafde1d0d7..fd6154424b2 100644
--- a/app/javascript/mastodon/models/status.ts
+++ b/app/javascript/mastodon/models/status.ts
@@ -86,8 +86,10 @@ export interface StatusShape {
replies_count: number;
visibility: StatusVisibility;
}
-export type ExpandedStatusShape = Omit & {
+export type AccountStatusShape = Omit & {
account: AccountShapeFull;
+};
+export type ExpandedStatusShape = Omit & {
reblog?: Omit;
};