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 * 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 * quote of it can't render markup: the chat's reply previews, the digest
* email's quotes of a comment or a note. * 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 * @module
*/ */

View File

@@ -20,6 +20,7 @@ import {
import { Tooltip } from "../shared/Tooltip.tsx"; import { Tooltip } from "../shared/Tooltip.tsx";
import { CategoryChips } from "../category/CategoryChips.tsx"; import { CategoryChips } from "../category/CategoryChips.tsx";
import { userPath } from "../../../shared/paths.ts"; import { userPath } from "../../../shared/paths.ts";
import { markdownToPlainText } from "../../../shared/markdown.ts";
/** Passed to {@link PlaylistCard} by each list of playlists. */ /** Passed to {@link PlaylistCard} by each list of playlists. */
export interface PlaylistCardProps { export interface PlaylistCardProps {
@@ -53,6 +54,10 @@ export function PlaylistCard(
const unread = !isOwner && isRecent(playlist.createdAt) && const unread = !isOwner && isRecent(playlist.createdAt) &&
!isPlaylistVisited(playlist.id); !isPlaylistVisited(playlist.id);
const showBadge = !privateBadgeOnly || (!!isOwner && !playlist.isPublic); 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() { function handleNavigate() {
markPlaylistVisited(playlist.id); markPlaylistVisited(playlist.id);
@@ -91,8 +96,8 @@ export function PlaylistCard(
{unread && <span className="unread-dot" aria-hidden="true" />} {unread && <span className="unread-dot" aria-hidden="true" />}
{playlist.title} {playlist.title}
</Link> </Link>
{playlist.description && ( {description && (
<p className="playlist-card-description">{playlist.description}</p> <p className="playlist-card-description">{description}</p>
)} )}
<div className="playlist-card-meta"> <div className="playlist-card-meta">
{showBadge && ( {showBadge && (

View File

@@ -53,6 +53,7 @@ import {
userPath, userPath,
} from "../../shared/paths.ts"; } from "../../shared/paths.ts";
import { NotFound } from "./NotFound.tsx"; import { NotFound } from "./NotFound.tsx";
import { markdownToPlainText } from "../../shared/markdown.ts";
/** /**
* Held by `Search` for a query, from the first page of `SearchResults`. The * 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> ? <p className="search-page-empty">{t`No users match "${q}".`}</p>
: ( : (
<ul className="user-results"> <ul className="user-results">
{results.users.map((u) => ( {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}> <li key={u.id}>
<Link <Link
to={userPath(u.username)} to={userPath(u.username)}
className="user-result-item" className="user-result-item"
> >
<span className="user-result-name">@{u.username}</span> <span className="user-result-name">@{u.username}</span>
{u.description && ( {description && (
<span className="user-result-description"> <span className="user-result-description">
{u.description} {description}
</span> </span>
)} )}
</Link> </Link>
</li> </li>
))} );
})}
</ul> </ul>
) )
)} )}

View File

@@ -38,6 +38,34 @@ describe("PlaylistCard", () => {
expect(currentPath()).toBe("/@ann/playlists/mix"); 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", () => { test("the owner doesn't see their own name, or an unread dot", () => {
const p = makePlaylist({ ownerUsername: "me", createdAt: new Date() }); const p = makePlaylist({ ownerUsername: "me", createdAt: new Date() });
const { container } = renderWithContexts( const { container } = renderWithContexts(