v4: a description shows as text where a list quotes it — a playlist's card printed its description as written, so one holding an image showed "![](/api/attachments/…)", and so did a user's line in the search results. Both now go through markdownToPlainText (shared/markdown.ts), as the chat's reply previews do: the markup goes, a link keeps its text, an image is left out, and a description of nothing but an image leaves no line. The playlist's page and the profile render the Markdown as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-10 08:12:44 +00:00
parent 1d56c323a4
commit ffc071af4c
4 changed files with 60 additions and 19 deletions

View File

@@ -1,7 +1,8 @@
/**
* Flattens the Markdown users write into one line of plain text, where a
* quote of it can't render markup: the chat's reply previews and the digest
* email's quotes of a comment or a note.
* quote of it can't render markup: the chat's reply previews, the digest
* email's quotes of a comment or a note, and the line of a description on a
* playlist's card and on a user in the search results.
*
* @module
*/

View File

@@ -20,6 +20,7 @@ import {
import { Tooltip } from "../shared/Tooltip.tsx";
import { CategoryChips } from "../category/CategoryChips.tsx";
import { userPath } from "../../../shared/paths.ts";
import { markdownToPlainText } from "../../../shared/markdown.ts";
/** Passed to {@link PlaylistCard} by each list of playlists. */
export interface PlaylistCardProps {
@@ -53,6 +54,10 @@ export function PlaylistCard(
const unread = !isOwner && isRecent(playlist.createdAt) &&
!isPlaylistVisited(playlist.id);
const showBadge = !privateBadgeOnly || (!!isOwner && !playlist.isPublic);
// The playlist's page renders the description's Markdown; the card has
// room for its text only, and none at all when it is a lone image.
const description = playlist.description &&
markdownToPlainText(playlist.description, "");
function handleNavigate() {
markPlaylistVisited(playlist.id);
@@ -91,8 +96,8 @@ export function PlaylistCard(
{unread && <span className="unread-dot" aria-hidden="true" />}
{playlist.title}
</Link>
{playlist.description && (
<p className="playlist-card-description">{playlist.description}</p>
{description && (
<p className="playlist-card-description">{description}</p>
)}
<div className="playlist-card-meta">
{showBadge && (

View File

@@ -53,6 +53,7 @@ import {
userPath,
} from "../../shared/paths.ts";
import { NotFound } from "./NotFound.tsx";
import { markdownToPlainText } from "../../shared/markdown.ts";
/**
* Held by `Search` for a query, from the first page of `SearchResults`. The
@@ -297,21 +298,27 @@ export function Search(): React.JSX.Element {
? <p className="search-page-empty">{t`No users match "${q}".`}</p>
: (
<ul className="user-results">
{results.users.map((u) => (
<li key={u.id}>
<Link
to={userPath(u.username)}
className="user-result-item"
>
<span className="user-result-name">@{u.username}</span>
{u.description && (
<span className="user-result-description">
{u.description}
</span>
)}
</Link>
</li>
))}
{results.users.map((u) => {
// The profile renders the description's Markdown; this
// line has room for its text only.
const description = u.description &&
markdownToPlainText(u.description, "");
return (
<li key={u.id}>
<Link
to={userPath(u.username)}
className="user-result-item"
>
<span className="user-result-name">@{u.username}</span>
{description && (
<span className="user-result-description">
{description}
</span>
)}
</Link>
</li>
);
})}
</ul>
)
)}

View File

@@ -38,6 +38,34 @@ describe("PlaylistCard", () => {
expect(currentPath()).toBe("/@ann/playlists/mix");
});
test("the description shows as text, without its Markdown or its images", () => {
const withText = renderWithContexts(
<ul>
<PlaylistCard
playlist={makePlaylist({
description:
"**Late** [tunes](https://x.y) ![](/api/attachments/a1)",
})}
/>
</ul>,
);
expect(
withText.container.querySelector(".playlist-card-description"),
).toHaveTextContent(/^Late tunes$/);
withText.unmount();
const imageOnly = renderWithContexts(
<ul>
<PlaylistCard
playlist={makePlaylist({ description: "![](/api/attachments/a1)" })}
/>
</ul>,
);
expect(
imageOnly.container.querySelector(".playlist-card-description"),
).toBeNull();
});
test("the owner doesn't see their own name, or an unread dot", () => {
const p = makePlaylist({ ownerUsername: "me", createdAt: new Date() });
const { container } = renderWithContexts(