diff --git a/js/components/src/components/chat/chat-message.tsx b/js/components/src/components/chat/chat-message.tsx index 82410ff4b..a1f255967 100644 --- a/js/components/src/components/chat/chat-message.tsx +++ b/js/components/src/components/chat/chat-message.tsx @@ -7,7 +7,15 @@ import { Facet, RichtextSegment, segmentize } from "@streamplace/core"; import { memo, useCallback, useMemo } from "react"; import { Linking, Platform, Pressable, View } from "react-native"; import { ChatMessageViewHydrated } from "streamplace"; -import { flex, gap, ml, mr, opacity, pl } from "../../lib/theme/atoms"; +import { + flex, + gap, + ml, + mr, + opacity, + pl, + selectableText, +} from "../../lib/theme/atoms"; import { tabularNums, textAlphas } from "../../lib/theme/tokens"; import { formatHandleWithAt } from "../../utils/format-handle"; import { @@ -74,6 +82,7 @@ const LinkSegment = ({ return ( on web href={Platform.OS === "web" ? linkFtr.uri : undefined} @@ -101,7 +110,11 @@ const renderSegment = ( const ftr = seg.features?.[0]; if (!ftr) { - return {seg.text}; + return ( + + {seg.text} + + ); } if (ftr.$type === "app.bsky.richtext.facet#link") { @@ -114,6 +127,7 @@ const renderSegment = ( return ( Linking.openURL( @@ -127,7 +141,11 @@ const renderSegment = ( ); } - return {seg.text}; + return ( + + {seg.text} + + ); }; export const RichTextMessage = ({ @@ -139,7 +157,8 @@ export const RichTextMessage = ({ }) => { const profileUrl = useNetworkProfileUrl(); const userCache = useLivestreamStore((state) => state.authors); - if (!facets?.length) return {text}; + if (!facets?.length) + return {text}; let segs = segmentize(text, facets as Facet[]); @@ -182,7 +201,17 @@ const MessageBodyWeb = ({ item }: { item: ChatMessageViewHydrated }) => { const dids = useMemo(() => [item.author.did], [item.author.did]); const profile = useAvatars(dids)[item.author.did]; return ( - + { text={item.record.text} facets={item.record.facets || []} /> - + ); }; diff --git a/js/components/src/components/chat/chat.tsx b/js/components/src/components/chat/chat.tsx index a82744939..9b994870e 100644 --- a/js/components/src/components/chat/chat.tsx +++ b/js/components/src/components/chat/chat.tsx @@ -37,7 +37,15 @@ import { useTheme, View, } from "../../"; -import { flex, gap, layout, mr, px, py } from "../../lib/theme/atoms"; +import { + flex, + gap, + layout, + mr, + px, + py, + selectableText, +} from "../../lib/theme/atoms"; import { borderRadius, colors, motion, spacing } from "../../lib/theme/tokens"; import { RenderChatMessage } from "./chat-message"; import { ModMenuContent, ModView } from "./mod-view"; @@ -305,9 +313,12 @@ const ChatLine = memo(function ChatLine({ onPointerEnter={handleHoverIn} onPointerLeave={handleHoverOut} > - + {/* A plain View, not a Pressable: the row has no onPress, and the + Pressable's DOM node and press handler made the whole message + non-selectable. userSelect restores web text selection. */} + - + . Both opt their DOM subtree out of native text selection, so a +// drag over a message produced no selection at all. The row is now a plain +// View marked userSelect: text, and the body is a wrapping row, so a drag +// selects the message like any other text on the page. +// +// Native is deliberately left alone: a selectable message engages text +// selection during the row's swipe-to-reply gesture and steals focus from the +// chat input, breaking `.maestro/logged-in/chat-reply.yaml`. +// +// Chat is filled by writing records straight to the account's PDS, so this +// flow needs no browser login. +const SERVER_URL = process.env.SERVER_URL; +const PDS_URL = process.env.PDS_HTTPS_URL; +const HANDLE = process.env.ACCOUNT_HANDLE!; +const DID = process.env.ACCOUNT_DID!; +const PASSWORD = process.env.ACCOUNT_PASSWORD!; + +test.skip(!PDS_URL, "harness started without its HTTPS hostnames"); + +async function seedChat(texts: string[]) { + // the PDS's throwaway CA is trusted by the harness's browsers, not by Node + const pds = await request.newContext({ ignoreHTTPSErrors: true }); + try { + const session = await pds.post( + `${PDS_URL}/xrpc/com.atproto.server.createSession`, + { data: { identifier: HANDLE, password: PASSWORD } }, + ); + expect(session.ok(), `createSession: ${session.status()}`).toBe(true); + const { accessJwt } = await session.json(); + for (const text of texts) { + const res = await pds.post( + `${PDS_URL}/xrpc/com.atproto.repo.createRecord`, + { + headers: { Authorization: `Bearer ${accessJwt}` }, + data: { + repo: DID, + collection: "place.stream.chat.message", + record: { + $type: "place.stream.chat.message", + text, + createdAt: new Date().toISOString(), + streamer: DID, + }, + }, + }, + ); + expect(res.ok(), `createRecord: ${res.status()}`).toBe(true); + } + } finally { + await pds.dispose(); + } +} + +// Drag across a text element's first line and return the browser selection. +async function dragSelect(page: Page, target: Locator): Promise { + await target.scrollIntoViewIfNeeded(); + const box = await target.boundingBox(); + expect(box, "selection target has a layout box").not.toBeNull(); + const y = box!.y + box!.height / 2; + await page.mouse.move(box!.x + 1, y); + await page.mouse.down(); + await page.mouse.move(box!.x + box!.width - 1, y, { steps: 8 }); + await page.mouse.up(); + return page.evaluate(() => window.getSelection()?.toString() ?? ""); +} + +test("chat-select: a message can be drag-selected into the browser selection", async ({ + page, +}) => { + const run = Date.now(); + const message = `select me ${run}`; + const linkText = `selectlink${run}`; + await seedChat([message, `${linkText} https://example.com/${run}`]); + + await page.goto(`${SERVER_URL}/`); + await page.getByTestId("home-stream-card").first().click(); + + const body = page.getByText(message, { exact: true }).first(); + await expect(body).toBeVisible({ timeout: 30_000 }); + + // Dragging across the body selects the message text. + const selected = await dragSelect(page, body); + expect(selected).toContain(message); + + // Regression for the body wrapper: it used to be a single inline . + // Replacing it with a default (column) View stacked the handle, colon and + // message vertically; the row keeps them on one line. + const list = page.getByTestId("chat-list").first(); + const handle = list.getByText(`@${HANDLE}`, { exact: true }).last(); + const bodyBox = await body.boundingBox(); + const handleBox = await handle.boundingBox(); + expect(bodyBox).not.toBeNull(); + expect(handleBox).not.toBeNull(); + expect( + Math.abs(bodyBox!.y - handleBox!.y), + "handle and body share one line", + ).toBeLessThan(bodyBox!.height + 2); + + // A message with a link facet renders as several segments (the link text and + // its URI); the whole line stays selectable, the way the old single- + // body was. Select the row's contents through the browser Selection API and + // check both the link text and its URI come back together. + const acrossSegments = await page.evaluate( + ({ m, h }) => { + const chatList = document.querySelector('[data-testid="chat-list"]')!; + const scope = Array.from(chatList.querySelectorAll("*")).find( + (e) => + e.children.length > 0 && + (e.textContent ?? "").includes(m) && + (e.textContent ?? "").includes(h), + ) as HTMLElement | undefined; + if (!scope) return ""; + const range = document.createRange(); + range.selectNodeContents(scope); + const sel = window.getSelection()!; + sel.removeAllRanges(); + sel.addRange(range); + return sel.toString(); + }, + { m: linkText, h: `https://example.com/${run}` }, + ); + expect(acrossSegments).toContain(linkText); + expect(acrossSegments).toContain("example.com"); + + // The selection lives inside the chat list, so the surrounding page chrome + // is not swept into it. + const inChat = await page.evaluate(() => { + const sel = window.getSelection(); + if (!sel || sel.isCollapsed || sel.rangeCount === 0) return false; + const chatList = document.querySelector('[data-testid="chat-list"]'); + return !!chatList && chatList.contains(sel.getRangeAt(0).startContainer); + }); + expect(inChat).toBe(true); +});