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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/listing-launch:mason/96-copy-an-approved-social-post-with-its-pr → main 1 commit fork

  • Merged. mason/96-copy-an-approved-social-post-with-its-pr was merged into main as 3277646.

Review

Sign in to review this change.
4 files changed +103 −2
README.md +5
@@ -78,6 +78,11 @@ to make a listing of your own.
7878 in "0 of 9 approved", keep versions, are Fair Housing checked, are rewritten
7979 (and unapproved) when the facts change, and go when the open house does or
8080 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".
8186 9. **Mark it sold.** On your listing, pick **Sold** in the status box beside
8287 the tone, add the price it sold at if you want it said, and press **Set
8388 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("&lt;script&gt;");
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
169169 </details>`;
170170 }
171171
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 {
173190 const rows = Math.min(16, Math.max(4, Math.ceil(a.text.length / 70) + a.text.split("\n").length));
174191 return `<section class="asset" id="${a.kind}" aria-labelledby="${a.kind}-title">
175192 <div class="asset-edit">
@@ -183,6 +200,7 @@ function editor(l: Launched, a: Asset, editable: boolean, versions: readonly Ver
183200 <small class="muted" data-count>${isPost(a.kind) ? escape(lengthLine(a.text)) : `${a.text.length} characters`}</small>
184201 </div>
185202 </form>
203 ${copyPost(l, a, origin)}
186204 ${versionList(l, a, versions, editable)}
187205 </div>
188206 <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: {
277295 ${t.id === "update" ? draftBand(l, "email_update") : ""}
278296 ${l.assets
279297 .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))
281299 .join("")}
282300 </div>`,
283301 ).join("")}
static/listing.js +15
@@ -54,6 +54,21 @@ for (const button of document.querySelectorAll("[data-copy]")) {
5454 });
5555 }
5656
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
5772 for (const button of document.querySelectorAll("[data-copy-link]")) {
5873 if (!navigator.clipboard) continue;
5974 button.hidden = false;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs