Public: anyone can read this; the crew can change it.
Copy an approved social post, with its property page link, in one tap from the listing #100
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/96-copy-an-approved-social-post-with-its-pr → main 1 commit fork
4 files
changed +103
−2
README.md +5
| @@ -78,6 +78,11 @@ to make a listing of your own. | ||
| 78 | 78 | in "0 of 9 approved", keep versions, are Fair Housing checked, are rewritten |
| 79 | 79 | (and unapproved) when the facts change, and go when the open house does or |
| 80 | 80 | when the listing is sold or leaves the market. |
| 81 | **Copy a post.** Once you approve a social post, a **Copy post** button | |
| 82 | appears under it: one tap copies the approved words followed by the | |
| 83 | listing's property page link (`/homes/:id`, absolute), ready to paste into | |
| 84 | Instagram or Facebook. A draft has none. Without JavaScript the same text | |
| 85 | sits in a selectable read-only box under "Copy post". | |
| 81 | 86 | 9. **Mark it sold.** On your listing, pick **Sold** in the status box beside |
| 82 | 87 | the tone, add the price it sold at if you want it said, and press **Set |
| 83 | 88 | status**. Two more assets appear, a status post under Social and a status |
src/copy.test.ts added +63
| @@ -0,0 +1,63 @@ | ||
| 1 | /** Copy post: an approved social post offers its words plus the property page link; a draft offers nothing. */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync, readFileSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { approveAsset, editAsset, getListing, launchListing } from "./actions.ts"; | |
| 7 | import { openDb } from "./db.ts"; | |
| 8 | import { listingPage } from "./pages/listing.ts"; | |
| 9 | import { SAMPLE } from "./sample.ts"; | |
| 10 | import type { Who } from "./who.ts"; | |
| 11 | ||
| 12 | const me: Who = { role: "user", id: "u1", name: "me" }; | |
| 13 | const setup = () => { | |
| 14 | const db = openDb(mkdtempSync(join(tmpdir(), "app-copy-"))); | |
| 15 | const made = launchListing(db, me, { fields: { ...SAMPLE } }); | |
| 16 | if (!made.ok) throw new Error(made.message); | |
| 17 | return { db, id: made.value.id }; | |
| 18 | }; | |
| 19 | const load = (db: ReturnType<typeof openDb>, id: number) => { | |
| 20 | const found = getListing(db, me, { id }); | |
| 21 | if (!found.ok) throw new Error(found.message); | |
| 22 | return found.value; | |
| 23 | }; | |
| 24 | const page = (db: ReturnType<typeof openDb>, id: number) => | |
| 25 | listingPage({ who: me, listing: load(db, id), editable: true, origin: "https://x.example" }); | |
| 26 | const box = (html: string) => /data-copy-source>([^<]*)<\/textarea>/.exec(html)?.[1]; | |
| 27 | const approve = (db: ReturnType<typeof openDb>, id: number) => { | |
| 28 | const post = load(db, id).assets.find((a) => a.kind === "social_short")!; | |
| 29 | expect(approveAsset(db, me, { id, kind: "social_short", door: "page", seen: post.updated_at }).ok).toBe(true); | |
| 30 | }; | |
| 31 | ||
| 32 | test("a draft post has no Copy post", () => { | |
| 33 | const { db, id } = setup(); | |
| 34 | expect(page(db, id)).not.toContain("data-copy-post"); | |
| 35 | }); | |
| 36 | ||
| 37 | test("an approved post renders its words and the absolute property page link", () => { | |
| 38 | const { db, id } = setup(); | |
| 39 | approve(db, id); | |
| 40 | const html = page(db, id); | |
| 41 | expect(html.match(/data-copy-post/g)?.length).toBe(1); | |
| 42 | expect(html).toContain("Copy post"); | |
| 43 | expect(box(html)!.trimEnd().endsWith(`https://x.example/homes/${id}`)).toBe(true); | |
| 44 | }); | |
| 45 | ||
| 46 | test("hostile words stay escaped in the copy text", () => { | |
| 47 | const { db, id } = setup(); | |
| 48 | const edited = editAsset(db, me, { id, kind: "social_short", text: `<script>alert(1)</script> "hi"` } as never); | |
| 49 | if (!edited.ok) throw new Error(edited.message); | |
| 50 | approve(db, id); | |
| 51 | const html = page(db, id); | |
| 52 | expect(html).not.toContain("<script>alert(1)"); | |
| 53 | expect(box(html)).toContain("<script>"); | |
| 54 | }); | |
| 55 | ||
| 56 | test("the static script copies from the box; the page adds no inline script", () => { | |
| 57 | const js = readFileSync(join(import.meta.dir, "../static/listing.js"), "utf8"); | |
| 58 | expect(js).toContain("data-copy-post"); | |
| 59 | expect(js).toContain("navigator.clipboard.writeText"); | |
| 60 | const { db, id } = setup(); | |
| 61 | approve(db, id); | |
| 62 | expect(page(db, id)).not.toMatch(/<script(?![^>]*\bsrc=)/); | |
| 63 | }); |
src/pages/listing.ts +20 −2
| @@ -169,7 +169,24 @@ function versionList(l: Launched, a: Asset, versions: readonly Version[], editab | ||
| 169 | 169 | </details>`; |
| 170 | 170 | } |
| 171 | 171 | |
| 172 | function editor(l: Launched, a: Asset, editable: boolean, versions: readonly Version[]): string { | |
| 172 | /** The words of an approved social post and the property page link that follows them (once: not again when the words already carry it). */ | |
| 173 | export const postCopy = (l: Launched, a: Asset, origin: string): string => { | |
| 174 | const url = propertyPath(l, origin); | |
| 175 | return a.text.includes(url) ? a.text : `${a.text.replace(/\s+$/, "")}\n\n${url}`; | |
| 176 | }; | |
| 177 | ||
| 178 | /** Copy post: only for an approved social post (only a person-approved message leaves). A button with JavaScript; without it, the same text in a selectable read-only box. */ | |
| 179 | function copyPost(l: Launched, a: Asset, origin: string): string { | |
| 180 | if (!a.kind.startsWith("social_") || a.approval === null) return ""; | |
| 181 | return `<div data-copy-post> | |
| 182 | <button type="button" class="button" data-copy-text data-asset="${escape(a.label)}" hidden aria-label="Copy post: the ${escape(a.label)} with its property page link">Copy post</button> | |
| 183 | <details data-copy-fallback><summary>Copy post</summary> | |
| 184 | <textarea class="input textarea" rows="5" readonly aria-label="The ${escape(a.label)} with its property page link, ready to copy" data-copy-source>${escape(postCopy(l, a, origin))}</textarea> | |
| 185 | </details> | |
| 186 | </div>`; | |
| 187 | } | |
| 188 | ||
| 189 | function editor(l: Launched, a: Asset, editable: boolean, versions: readonly Version[], origin: string): string { | |
| 173 | 190 | const rows = Math.min(16, Math.max(4, Math.ceil(a.text.length / 70) + a.text.split("\n").length)); |
| 174 | 191 | return `<section class="asset" id="${a.kind}" aria-labelledby="${a.kind}-title"> |
| 175 | 192 | <div class="asset-edit"> |
| @@ -183,6 +200,7 @@ function editor(l: Launched, a: Asset, editable: boolean, versions: readonly Ver | ||
| 183 | 200 | <small class="muted" data-count>${isPost(a.kind) ? escape(lengthLine(a.text)) : `${a.text.length} characters`}</small> |
| 184 | 201 | </div> |
| 185 | 202 | </form> |
| 203 | ${copyPost(l, a, origin)} | |
| 186 | 204 | ${versionList(l, a, versions, editable)} |
| 187 | 205 | </div> |
| 188 | 206 | <aside class="fh" data-fh="${a.kind}" aria-live="polite">${asideOf(a.kind, a.text, a.check, editable ? suggestionHref(l.id, a.kind) : undefined)}</aside> |
| @@ -277,7 +295,7 @@ export function listingPage(input: { | ||
| 277 | 295 | ${t.id === "update" ? draftBand(l, "email_update") : ""} |
| 278 | 296 | ${l.assets |
| 279 | 297 | .filter((a) => t.kinds.includes(a.kind)) |
| 280 | .map((a) => editor(l, a, editable, input.versions?.[a.kind] ?? [])) | |
| 298 | .map((a) => editor(l, a, editable, input.versions?.[a.kind] ?? [], origin)) | |
| 281 | 299 | .join("")} |
| 282 | 300 | </div>`, |
| 283 | 301 | ).join("")} |
static/listing.js +15
| @@ -54,6 +54,21 @@ for (const button of document.querySelectorAll("[data-copy]")) { | ||
| 54 | 54 | }); |
| 55 | 55 | } |
| 56 | 56 | |
| 57 | // An approved post with its property page link: the button replaces the | |
| 58 | // read-only box that stands in for it without JavaScript. | |
| 59 | for (const box of document.querySelectorAll("[data-copy-post]")) { | |
| 60 | const button = box.querySelector("[data-copy-text]"); | |
| 61 | if (!navigator.clipboard) continue; | |
| 62 | button.hidden = false; | |
| 63 | box.querySelector("[data-copy-fallback]").hidden = true; | |
| 64 | button.addEventListener("click", async () => { | |
| 65 | await navigator.clipboard.writeText(box.querySelector("[data-copy-source]").value); | |
| 66 | button.textContent = "Copied"; | |
| 67 | announce(`Copied the ${button.dataset.asset} with its link`); | |
| 68 | setTimeout(() => (button.textContent = "Copy post"), 1500); | |
| 69 | }); | |
| 70 | } | |
| 71 | ||
| 57 | 72 | for (const button of document.querySelectorAll("[data-copy-link]")) { |
| 58 | 73 | if (!navigator.clipboard) continue; |
| 59 | 74 | button.hidden = false; |