diff --git a/src/app/Components/Artist/Biography.tsx b/src/app/Components/Artist/Biography.tsx index 033e25f3434..48364160733 100644 --- a/src/app/Components/Artist/Biography.tsx +++ b/src/app/Components/Artist/Biography.tsx @@ -1,6 +1,7 @@ import { Text } from "@artsy/palette-mobile" import { Biography_artist$key } from "__generated__/Biography_artist.graphql" import { HTML } from "app/Components/HTML" +import { truncateHtml } from "app/utils/truncateHtml" import { useState } from "react" import { graphql, useFragment } from "react-relay" @@ -22,15 +23,12 @@ export const Biography: React.FC = ({ artist, variant = "sm" }) const credit = data.biographyBlurb.credit const text = !!credit ? `${data.biographyBlurb.text} ${credit}` : data.biographyBlurb.text - const truncatedText = text.slice(0, MAX_CHARS) - const canExpand = text.length > MAX_CHARS + const { text: truncatedText, wasTruncated: canExpand } = truncateHtml(text, MAX_CHARS) return ( <> MAX_CHARS && !expanded ? "... " : " " - }`} + html={`${expanded ? text : truncatedText}${canExpand && !expanded ? "... " : " "}`} variant={variant} /> diff --git a/src/app/Components/HTML.tsx b/src/app/Components/HTML.tsx index 6fabe2920c4..8ad7a6e42a1 100644 --- a/src/app/Components/HTML.tsx +++ b/src/app/Components/HTML.tsx @@ -1,4 +1,5 @@ import { + Color, Flex, FlexProps, TextProps, @@ -15,6 +16,7 @@ import RenderHtml, { CustomBlockRenderer, MixedStyleRecord } from "react-native- interface HTMLProps extends FlexProps { html: string + color?: Color onLinkPress?: (href: string) => void tagStyles?: MixedStyleRecord variant?: TextProps["variant"] @@ -29,6 +31,7 @@ export const FONTS = { export const HTML: React.FC = ({ html, + color: textColor = "mono100", onLinkPress, tagStyles = {}, variant = "sm", @@ -47,6 +50,7 @@ export const HTML: React.FC = ({ = ({ { a: { textDecorationLine: "underline", - textDecorationColor: color("mono100"), - color: color("mono100"), + textDecorationColor: color(textColor), + color: color(textColor), textDecorationStyle: "solid", }, p: { fontFamily: FONTS.regular, - color: color("mono100"), + color: color(textColor), marginBottom: "1em", ...variantStyles, }, li: { fontFamily: FONTS.regular, - color: color("mono100"), + color: color(textColor), marginBottom: "1em", ...omit(variantStyles, "lineHeight"), // to prevent mis-aligned markers }, em: { fontFamily: FONTS.italic, - color: color("mono100"), + color: color(textColor), }, h1: { fontFamily: FONTS.medium, @@ -98,7 +102,7 @@ export const HTML: React.FC = ({ letterSpacing: theme.textTreatments["xl"].letterSpacing, marginBottom: "1em", fontWeight: "normal", - color: color("mono100"), + color: color(textColor), }, h2: { fontFamily: FONTS.medium, @@ -107,7 +111,7 @@ export const HTML: React.FC = ({ letterSpacing: theme.textTreatments["lg-display"].letterSpacing, marginBottom: "1em", fontWeight: "normal", - color: color("mono100"), + color: color(textColor), }, h3: { fontFamily: FONTS.regular, @@ -116,7 +120,7 @@ export const HTML: React.FC = ({ letterSpacing: theme.textTreatments["md"].letterSpacing, marginBottom: "1em", fontWeight: "normal", - color: color("mono100"), + color: color(textColor), }, }, tagStyles diff --git a/src/app/Components/__tests__/HTML.tests.tsx b/src/app/Components/__tests__/HTML.tests.tsx new file mode 100644 index 00000000000..e99434f6204 --- /dev/null +++ b/src/app/Components/__tests__/HTML.tests.tsx @@ -0,0 +1,19 @@ +import { screen } from "@testing-library/react-native" +import { HTML } from "app/Components/HTML" +import { renderWithWrappers } from "app/utils/tests/renderWithWrappers" + +describe("HTML", () => { + it("applies the color prop to bare text with no wrapping tag", () => { + renderWithWrappers() + + const node = screen.getByText("Bare text with no tag") + expect(node).toHaveStyle({ color: "#FFFFFF" }) + }) + + it("applies the color prop to tags without an explicit tagsStyles entry", () => { + renderWithWrappers() + + const node = screen.getByText("Bold text") + expect(node).toHaveStyle({ color: "#FFFFFF" }) + }) +}) diff --git a/src/app/Scenes/Artwork/Components/AboutArtist.tsx b/src/app/Scenes/Artwork/Components/AboutArtist.tsx index 0834508c2c9..66e7621bdf6 100644 --- a/src/app/Scenes/Artwork/Components/AboutArtist.tsx +++ b/src/app/Scenes/Artwork/Components/AboutArtist.tsx @@ -1,16 +1,22 @@ -import { Spacer, Flex, Box, Text, Join, Screen } from "@artsy/palette-mobile" +import { Spacer, Flex, Box, Text, LinkText, Join, Screen } from "@artsy/palette-mobile" import { AboutArtist_artwork$data } from "__generated__/AboutArtist_artwork.graphql" import { ArtistListItemContainer as ArtistListItem } from "app/Components/ArtistListItem" -import { ReadMore } from "app/Components/ReadMore" +import { HTML } from "app/Components/HTML" import { truncatedTextLimit } from "app/utils/hardware" import { Schema } from "app/utils/track" +import { truncateHtml } from "app/utils/truncateHtml" +import { useState } from "react" import { createFragmentContainer, graphql } from "react-relay" +import { useTracking } from "react-tracking" interface AboutArtistProps { artwork: AboutArtist_artwork$data } export const AboutArtist: React.FC = ({ artwork }) => { + const tracking = useTracking() + const [expanded, setExpanded] = useState(false) + const artists = artwork.artists || [] const hasSingleArtist = artists && artists.length === 1 @@ -29,6 +35,22 @@ export const AboutArtist: React.FC = ({ artwork }) => { const backgroundColor = artwork.isUnlisted ? "mono100" : "mono0" const textColor = artwork.isUnlisted ? "mono0" : "mono100" + const { text: truncatedText, wasTruncated: canExpand } = text + ? truncateHtml(text, textLimit) + : { text: undefined, wasTruncated: false } + + const handleExpandPress = () => { + if (!expanded) { + tracking.trackEvent({ + action_name: Schema.ActionNames.ReadMore, + action_type: Schema.ActionTypes.Tap, + context_module: Schema.ContextModules.ArtistBiography, + flow: Schema.Flow.AboutTheArtist, + }) + } + setExpanded((prev) => !prev) + } + return ( @@ -51,16 +73,21 @@ export const AboutArtist: React.FC = ({ artwork }) => { {!!hasSingleArtist && !!text && !!artwork.displayArtistBio && ( - + {!!canExpand && ( + + {expanded ? "Read Less" : "Read More"} + + )} )} @@ -73,7 +100,7 @@ export const AboutArtistFragmentContainer = createFragmentContainer(AboutArtist, displayArtistBio artists(shallow: true) { id - biographyBlurb(partnerBio: false) { + biographyBlurb(format: HTML, partnerBio: false) { text } diff --git a/src/app/Scenes/Artwork/Components/__tests__/AboutArtist.tests.tsx b/src/app/Scenes/Artwork/Components/__tests__/AboutArtist.tests.tsx index af5893f0873..7a59872488c 100644 --- a/src/app/Scenes/Artwork/Components/__tests__/AboutArtist.tests.tsx +++ b/src/app/Scenes/Artwork/Components/__tests__/AboutArtist.tests.tsx @@ -85,7 +85,7 @@ describe("AboutArtist", () => { }), }) - const readMoreLink = screen.getByText("Read more") + const readMoreLink = screen.getByText("Read More") fireEvent.press(readMoreLink) expect(mockTrackEvent).toBeCalledWith({ action_name: "readMore", @@ -94,4 +94,26 @@ describe("AboutArtist", () => { flow: "AboutTheArtist", }) }) + + it("renders HTML-formatted bio content without raw markdown markers", () => { + renderWithRelay({ + Artwork: () => ({ + isUnlisted: false, + displayArtistBio: true, + artists: [ + { + biographyBlurb: { + text: "

Key Exhibitions

  • David Hockney, The Metropolitan Museum of Art, 2017
", + }, + }, + ], + }), + }) + + expect(screen.getByText("Key Exhibitions")).toBeOnTheScreen() + expect( + screen.getByText("David Hockney, The Metropolitan Museum of Art, 2017") + ).toBeOnTheScreen() + expect(screen.queryByText(/

/)).not.toBeOnTheScreen() + }) }) diff --git a/src/app/utils/__tests__/truncateHtml.tests.ts b/src/app/utils/__tests__/truncateHtml.tests.ts new file mode 100644 index 00000000000..c4cb10ec906 --- /dev/null +++ b/src/app/utils/__tests__/truncateHtml.tests.ts @@ -0,0 +1,80 @@ +import { truncateHtml } from "app/utils/truncateHtml" + +describe("truncateHtml", () => { + it("behaves like a plain slice when there is no markup", () => { + const { text, wasTruncated } = truncateHtml("Hello world", 5) + + expect(text).toEqual("Hello") + expect(wasTruncated).toBe(true) + }) + + it("returns the entire string, untruncated, when the budget exceeds the visible text length", () => { + const html = "

Hi

" + const { text, wasTruncated } = truncateHtml(html, 100) + + expect(text).toEqual(html) + expect(wasTruncated).toBe(false) + }) + + it("does not count tag markup toward the visible character budget", () => { + const html = "

Hello

" + const { text, wasTruncated } = truncateHtml(html, 5) + + expect(text).toEqual("

Hello

") + expect(wasTruncated).toBe(false) + }) + + it("stops as soon as the visible character budget is reached, dropping trailing markup", () => { + const html = "

Hello world

" + const { text, wasTruncated } = truncateHtml(html, 5) + + expect(text).toEqual("

Hello") + expect(wasTruncated).toBe(true) + }) + + it("never cuts in the middle of a tag or attribute", () => { + const html = + 'Hockney retrospective' + + const { text, wasTruncated } = truncateHtml(html, 3) + + expect(text).toEqual('Hoc') + expect(wasTruncated).toBe(true) + }) + + it("does not cut in the middle of an entity reference", () => { + const { text } = truncateHtml("Arts & Crafts", 7) + + expect(text).toEqual("Arts & ") + }) + + it("treats a bare ampersand that is not part of an entity as a normal character", () => { + const { text } = truncateHtml("Fish & Chips", 6) + + expect(text).toEqual("Fish &") + }) + + it("stops before a new tag opens once the budget is spent, without leaving it dangling open", () => { + const html = "

abc

Key Exhibitions

" + const { text } = truncateHtml(html, 3) + + expect(text).toEqual("

abc

") + }) + + it("still emits closing tags for elements opened before the budget was spent", () => { + const html = "

abc

" + const { text } = truncateHtml(html, 3) + + expect(text).toEqual(html) + }) + + it("keeps the returned text and wasTruncated flag consistent even on malformed HTML", () => { + // An unterminated "") { + inTag = false + } + continue + } + + if (char === "<") { + const isClosingTag = html[i + 1] === "/" + if (visibleCount >= maxVisibleChars && !isClosingTag) { + wasTruncated = true + break + } + inTag = true + result += char + continue + } + + if (char === "&") { + const entityMatch = ENTITY_REGEX.exec(html.slice(i)) + if (entityMatch) { + if (visibleCount >= maxVisibleChars) { + wasTruncated = true + break + } + result += entityMatch[0] + visibleCount++ + i += entityMatch[0].length - 1 + continue + } + } + + if (visibleCount >= maxVisibleChars) { + wasTruncated = true + break + } + + result += char + visibleCount++ + } + + return { text: result, wasTruncated } +}