Public: anyone can read this; the crew can change it.
Each social post shows its length against the network's limit, and a post over it cannot be approved without saying so #42
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/35-each-social-post-shows-its-length-agains → main 2 commits fork
10 files
changed +312
−19
README.md +15
| @@ -66,6 +66,20 @@ list cannot drift from the app. | ||
| 66 | 66 | clear, amber with the risky words underlined and a one-line reason — |
| 67 | 67 | *"'family-friendly' implies familial status; say 'near parks and |
| 68 | 68 | schools'"*. The check runs again as you type and on every save. |
| 69 | Under each social post's box is its length and the limits it fits (X 280, | |
| 70 | Instagram 2,200, Facebook 63,206). A short post over 280 reads "Too long | |
| 71 | for X by 34" in the same aside, and its Approve button says "Approve — | |
| 72 | too long for X". The medium post and carousel are for Instagram and | |
| 73 | Facebook, so only the short post is held to X. Counting follows the | |
| 74 | stated rules only: on X a link is 23 and an emoji is 2; elsewhere a | |
| 75 | character is a character. X's other weights (wide CJK characters) are not | |
| 76 | modelled, so a post close to the line deserves a look in the network. | |
| 77 | Approving a short post over X is refused unless the request says it knows | |
| 78 | (`tooLong: true`; the page's button sends it, since it says so on its | |
| 79 | face), and the audit trail records such an approval as | |
| 80 | `approved_too_long_for_x`. The terminal shows the same line and note under | |
| 81 | each social post; `a` on a short post over X asks once, and a second `a` | |
| 82 | approves it. | |
| 69 | 83 | Where a finding's rewrite is a phrase that can stand in its place, the |
| 70 | 84 | aside has a **Use "near parks and schools"** button beside it (only for |
| 71 | 85 | whoever may edit the listing; advice such as "describe the home, not the |
| @@ -163,6 +177,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 163 | 177 | | `src/actions.ts` | listings (launch, edit an asset, retone, change facts, remove), notes, the audit trail | |
| 164 | 178 | | `src/listing.ts` | what a listing is, and the check at the boundary that one arrived | |
| 165 | 179 | | `src/generate.ts` | `generate(listing)`: the five assets from the facts, in three tones | |
| 180 | | `src/postlength.ts` | a social post's length by what X, Instagram and Facebook count | | |
| 166 | 181 | | `src/fairhousing.ts` | the Fair Housing rules: phrase, context, class, reason, rewrite | |
| 167 | 182 | | `src/photos.ts` | photos as files beside the database, known by their bytes | |
| 168 | 183 | | `src/sample.ts` | the sample listing, planted once | |
src/actions.ts +23 −5
| @@ -29,6 +29,7 @@ import { | ||
| 29 | 29 | type ListingRow, |
| 30 | 30 | } from "./listing.ts"; |
| 31 | 31 | import { emit } from "./live.ts"; |
| 32 | import { meantForX, overXNote, postLength } from "./postlength.ts"; | |
| 32 | 33 | import { |
| 33 | 34 | accept as acceptPhoto, |
| 34 | 35 | type Kind as PhotoKind, |
| @@ -644,6 +645,12 @@ function suggest( | ||
| 644 | 645 | }); |
| 645 | 646 | } |
| 646 | 647 | |
| 648 | /** How far a short post is over X's limit; 0 for any other asset or one that fits. */ | |
| 649 | const overX = (l: Launched, kind: string): number => { | |
| 650 | const a = l.assets.find((x) => x.kind === kind); | |
| 651 | return a !== undefined && meantForX(a.kind) ? postLength(a.text).overX : 0; | |
| 652 | }; | |
| 653 | ||
| 647 | 654 | /** |
| 648 | 655 | * A person signs off one asset's words (or takes it back): who and when go |
| 649 | 656 | * on the row, and any change to the words clears it. Whoever may update the |
| @@ -654,13 +661,15 @@ function suggest( | ||
| 654 | 661 | * is approved (`conflict`). `door` is where the call came through; a |
| 655 | 662 | * person's door (page, tty) is stored with an approval, and a caller an |
| 656 | 663 | * assistant can reach (api, mcp) is refused (`person_required`) when it |
| 657 | * approves, and may withdraw. Both directions are audit rows, named in | |
| 658 | * the outcome. | |
| 664 | * approves, and may withdraw. A short post over X's limit is approved only | |
| 665 | * with `tooLong: true` (the page's button and the terminal's second `a` say | |
| 666 | * so), and the audit row then reads `approved_too_long_for_x`. Both | |
| 667 | * directions are audit rows, named in the outcome. | |
| 659 | 668 | */ |
| 660 | 669 | export function approveAsset( |
| 661 | 670 | db: Database, |
| 662 | 671 | who: Who, |
| 663 | input: { id: number; kind: string; door: Caller; approved?: unknown; seen?: unknown; shown?: unknown }, | |
| 672 | input: { id: number; kind: string; door: Caller; approved?: unknown; seen?: unknown; shown?: unknown; tooLong?: unknown }, | |
| 664 | 673 | ): Result<Launched> { |
| 665 | 674 | const approving = input.approved === undefined || input.approved === true; |
| 666 | 675 | const done = approve(db, who, input, approving); |
| @@ -669,7 +678,11 @@ export function approveAsset( | ||
| 669 | 678 | resource: "listing", |
| 670 | 679 | verb: "update", |
| 671 | 680 | target: done.ok ? done.value.id : input.id, |
| 672 | outcome: done.ok ? (approving ? "approved" : "unapproved") : done.error, | |
| 681 | outcome: done.ok | |
| 682 | ? approving | |
| 683 | ? overX(done.value, input.kind) > 0 ? "approved_too_long_for_x" : "approved" | |
| 684 | : "unapproved" | |
| 685 | : done.error, | |
| 673 | 686 | }); |
| 674 | 687 | return done; |
| 675 | 688 | } |
| @@ -677,7 +690,7 @@ export function approveAsset( | ||
| 677 | 690 | function approve( |
| 678 | 691 | db: Database, |
| 679 | 692 | who: Who, |
| 680 | input: { id: number; kind: string; door: Caller; approved?: unknown; seen?: unknown; shown?: unknown }, | |
| 693 | input: { id: number; kind: string; door: Caller; approved?: unknown; seen?: unknown; shown?: unknown; tooLong?: unknown }, | |
| 681 | 694 | approving: boolean, |
| 682 | 695 | ): Result<Launched> { |
| 683 | 696 | const found = listingFor(db, who, "update", input.id, { |
| @@ -706,6 +719,11 @@ function approve( | ||
| 706 | 719 | "conflict", |
| 707 | 720 | "These words changed since you read them. Save your changes and read them again before approving.", |
| 708 | 721 | ); |
| 722 | if (meantForX(asset.kind) && overXNote(asset.text) !== "" && input.tooLong !== true) | |
| 723 | return fail( | |
| 724 | "invalid", | |
| 725 | `${overXNote(asset.text)}. Approve it knowing so: send tooLong: true.`, | |
| 726 | ); | |
| 709 | 727 | db.query( |
| 710 | 728 | "UPDATE asset SET approved_by_id = ?1, approved_by_name = ?2, approved_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), approved_via = ?5 WHERE listing_id = ?3 AND kind = ?4", |
| 711 | 729 | ).run(who.id, who.name, found.value.id, input.kind, input.door); |
src/listings.test.ts +58
| @@ -33,6 +33,7 @@ const as = (id: string, role: "user" | "admin" = "user") => { | ||
| 33 | 33 | }; |
| 34 | 34 | const alice = as("alice"); |
| 35 | 35 | const bob = as("bob"); |
| 36 | const carol = as("carol"); // her own write budget: the door rations writes per person | |
| 36 | 37 | |
| 37 | 38 | const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]); |
| 38 | 39 | const JPEG = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 9, 9, 9]); |
| @@ -859,3 +860,60 @@ test("update_photos over MCP reorders and removes, and refuses a stranger and a | ||
| 859 | 860 | expect(getListing(db, me, { id })).toMatchObject({ value: { photos: [a] } }); |
| 860 | 861 | expect(existsSync(join(data, "photos", b))).toBe(false); |
| 861 | 862 | }); |
| 863 | ||
| 864 | test("each social post shows its length; a short post over X says so and its Approve button too", async () => { | |
| 865 | const { to } = await launch(carol); | |
| 866 | const id = Number(to.split("/").at(-1)); | |
| 867 | let page = await (await fetch(`${base}/listings/${id}?tab=social`, { headers: carol })).text(); | |
| 868 | expect(page).toMatch(/\d+ characters · X 280 ✓ · Instagram 2,200 ✓ · Facebook 63,206 ✓/); | |
| 869 | expect(page).not.toContain("Too long for X"); | |
| 870 | const edit = new FormData(); | |
| 871 | edit.set("text", "a".repeat(314)); | |
| 872 | await fetch(`${base}/listings/${id}/assets/social_short`, { method: "POST", body: edit, headers: carol, redirect: "manual" }); | |
| 873 | page = await (await fetch(`${base}/listings/${id}?tab=social`, { headers: carol })).text(); | |
| 874 | expect(page).toContain("Too long for X by 34"); | |
| 875 | expect(page).toContain("314 characters · X 280 ✗ · Instagram 2,200 ✓"); | |
| 876 | expect(page).toContain("Approve — too long for X</button>"); | |
| 877 | expect(page).toContain('name="too_long" value="1"'); | |
| 878 | const form = new FormData(); | |
| 879 | form.set("approved", "1"); | |
| 880 | form.set("seen", page.match(/name="seen" value="([^"]+)">\s*<input type="hidden" name="too_long"/)![1]!); | |
| 881 | const bare = await fetch(`${base}/listings/${id}/assets/social_short/approve`, { method: "POST", body: form, headers: carol, redirect: "manual" }); | |
| 882 | expect(decodeURIComponent(bare.headers.get("set-cookie") ?? "")).toContain("Too long for X by 34"); | |
| 883 | // The live check carries the same words and the line for the box. | |
| 884 | const live = await fetch(`${base}/fair-housing`, { | |
| 885 | method: "POST", | |
| 886 | headers: { "content-type": "application/json" }, | |
| 887 | body: JSON.stringify({ text: "a".repeat(314), kind: "social_short" }), | |
| 888 | }); | |
| 889 | const body = (await live.json()) as { html: string; length: string | null }; | |
| 890 | expect(body.html).toContain("Too long for X by 34"); | |
| 891 | expect(body.length).toContain("314 characters"); | |
| 892 | const mls = await fetch(`${base}/fair-housing`, { | |
| 893 | method: "POST", | |
| 894 | headers: { "content-type": "application/json" }, | |
| 895 | body: JSON.stringify({ text: "a".repeat(314), kind: "mls" }), | |
| 896 | }); | |
| 897 | expect(((await mls.json()) as { length: string | null }).length).toBeNull(); | |
| 898 | }); | |
| 899 | ||
| 900 | test("approving a short post over X needs tooLong, from any door, and the audit row says so", () => { | |
| 901 | const db = openDb(mkdtempSync(join(tmpdir(), "app-toolong-"))); | |
| 902 | const me: Who = { role: "user", id: "dana", name: "Dana" }; | |
| 903 | const id = insertListing(db, me, SAMPLE, [], null).id; | |
| 904 | editAsset(db, me, { id, kind: "social_short", text: "a".repeat(314) }); | |
| 905 | const seen = () => getListing(db, me, { id }); | |
| 906 | const stamp = (kind: string) => { | |
| 907 | const l = seen(); | |
| 908 | return l.ok ? l.value.assets.find((a) => a.kind === kind)!.updated_at : ""; | |
| 909 | }; | |
| 910 | const refused = approveAsset(db, me, { id, kind: "social_short", door: "page", seen: stamp("social_short") }); | |
| 911 | expect(refused.ok).toBe(false); | |
| 912 | expect(refused.ok ? "" : refused.message).toContain("Too long for X by 34"); | |
| 913 | expect(approveAsset(db, me, { id, kind: "social_short", door: "tty", seen: stamp("social_short"), tooLong: "yes" }).ok).toBe(false); | |
| 914 | expect(approveAsset(db, me, { id, kind: "social_short", door: "page", seen: stamp("social_short"), tooLong: true }).ok).toBe(true); | |
| 915 | // A post that fits, or another asset, needs no acknowledgement. | |
| 916 | expect(approveAsset(db, me, { id, kind: "mls", door: "page", seen: stamp("mls") }).ok).toBe(true); | |
| 917 | const outcomes = db.query<{ outcome: string }, []>("SELECT outcome FROM audit WHERE outcome LIKE 'approved%' ORDER BY id").all(); | |
| 918 | expect(outcomes.map((r) => r.outcome)).toEqual(["approved_too_long_for_x", "approved"]); | |
| 919 | }); |
src/pages/listing.ts +14 −4
| @@ -15,6 +15,7 @@ import { | ||
| 15 | 15 | } from "../generate.ts"; |
| 16 | 16 | import { asset as assetHref, escape } from "../http.ts"; |
| 17 | 17 | import { approvalLabel, ASSET_MAX, type Asset, type Launched, type Listing } from "../listing.ts"; |
| 18 | import { isPost, lengthLine, meantForX, overXNote } from "../postlength.ts"; | |
| 18 | 19 | import { isSample } from "../photos.ts"; |
| 19 | 20 | import type { Who } from "../who.ts"; |
| 20 | 21 | import { ago, layout, toast } from "./layout.ts"; |
| @@ -72,6 +73,12 @@ export function fairHousing(text: string, check: Check, fix?: string): string { | ||
| 72 | 73 | <div class="fh-text" role="group" aria-label="The words, with what was flagged underlined">${annotate(text, check)}</div>`; |
| 73 | 74 | } |
| 74 | 75 | |
| 76 | /** The aside's whole content for an asset: the length warning (a short post over X) above the Fair Housing check. */ | |
| 77 | export function asideOf(kind: string, text: string, check: Check, fix?: string): string { | |
| 78 | const over = meantForX(kind) ? overXNote(text) : ""; | |
| 79 | return `${over === "" ? "" : `<p class="fh-status fh-warn" role="status" data-too-long>${over}</p>`}${fairHousing(text, check, fix)}`; | |
| 80 | } | |
| 81 | ||
| 75 | 82 | const flags = (l: Launched): number => |
| 76 | 83 | l.assets.reduce((n, a) => n + a.check.findings.length, 0); |
| 77 | 84 | |
| @@ -101,8 +108,11 @@ export function approval(l: Launched, a: Asset): string { | ||
| 101 | 108 | return `<span class="approved" data-approved>✓ Approved by ${escape(a.approval.by_name)} ${ago(a.approval.at)}${via(a.approval.via)}</span> |
| 102 | 109 | <button class="button button-quiet" formaction="${to}" name="approved" value="0" aria-label="Withdraw approval of the ${escape(a.label)}">Withdraw</button>`; |
| 103 | 110 | const n = a.check.findings.length; |
| 104 | return `<input type="hidden" name="seen" value="${escape(a.updated_at)}"> | |
| 105 | <button class="button" data-approve aria-label="${n === 0 ? "Approve" : `Approve with ${n} ${n === 1 ? "flag" : "flags"}`}: the ${escape(a.label)}" formaction="${to}" name="approved" value="1">${n === 0 ? "Approve" : `Approve with ${n} ${n === 1 ? "flag" : "flags"}`}</button>`; | |
| 111 | const long = meantForX(a.kind) && overXNote(a.text) !== ""; | |
| 112 | const label = n === 0 ? "Approve" : `Approve with ${n} ${n === 1 ? "flag" : "flags"}`; | |
| 113 | const words = long ? `${label} — too long for X` : label; | |
| 114 | return `<input type="hidden" name="seen" value="${escape(a.updated_at)}">${long ? `\n <input type="hidden" name="too_long" value="1">` : ""} | |
| 115 | <button class="button" data-approve aria-label="${words}: the ${escape(a.label)}" formaction="${to}" name="approved" value="1">${words}</button>`; | |
| 106 | 116 | } |
| 107 | 117 | |
| 108 | 118 | function editor(l: Launched, a: Asset, editable: boolean): string { |
| @@ -116,11 +126,11 @@ function editor(l: Launched, a: Asset, editable: boolean): string { | ||
| 116 | 126 | ${editable ? `<button class="button" aria-label="Save the ${escape(a.label)}">Save</button>` : ""} |
| 117 | 127 | <button type="button" class="button button-quiet" data-copy data-asset="${escape(a.label)}" hidden aria-label="Copy the ${escape(a.label)}">Copy</button> |
| 118 | 128 | ${editable ? `<span data-approval>${approval(l, a)}</span>` : a.approval === null ? "" : `<span class="approved">✓ Approved by ${escape(a.approval.by_name)} ${ago(a.approval.at)}${via(a.approval.via)}</span>`} |
| 119 | <small class="muted" data-count>${a.text.length} characters</small> | |
| 129 | <small class="muted" data-count>${isPost(a.kind) ? escape(lengthLine(a.text)) : `${a.text.length} characters`}</small> | |
| 120 | 130 | </div> |
| 121 | 131 | </form> |
| 122 | 132 | </div> |
| 123 | <aside class="fh" data-fh="${a.kind}" aria-live="polite">${fairHousing(a.text, a.check, editable ? suggestionHref(l.id, a.kind) : undefined)}</aside> | |
| 133 | <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> | |
| 124 | 134 | </section>`; |
| 125 | 135 | } |
| 126 | 136 |
src/postlength.test.ts added +55
| @@ -0,0 +1,55 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { generate, TONES } from "./generate.ts"; | |
| 3 | import { agentOf } from "./listing.ts"; | |
| 4 | import { lengthLine, LIMITS, overXNote, postLength, xLength } from "./postlength.ts"; | |
| 5 | import { SAMPLE, SAMPLE_CONDO, SAMPLE_HOUSE } from "./sample.ts"; | |
| 6 | ||
| 7 | const link = "https://example.com/a/very/long/path/that/goes/on"; | |
| 8 | ||
| 9 | test.each([ | |
| 10 | ["", 0], | |
| 11 | ["hello", 5], | |
| 12 | ["a link " + link, 7 + 23], | |
| 13 | ["see " + link + ".", 4 + 23 + 1], | |
| 14 | ["two " + link + " " + link, 4 + 23 + 1 + 23], | |
| 15 | ["house 🏡", 6 + 2], | |
| 16 | ["🏡🏡", 4], | |
| 17 | ["👨👩👧 family", 2 + 7], | |
| 18 | ["👍🏽", 2], | |
| 19 | ["🇺🇸", 2], | |
| 20 | ["© 2027", 6], | |
| 21 | ["#JustListed", 11], | |
| 22 | ])("X counts %j as %d", (text, n) => { | |
| 23 | expect(xLength(text)).toBe(n); | |
| 24 | }); | |
| 25 | ||
| 26 | test("Instagram and Facebook count characters, links and emoji as they are", () => { | |
| 27 | const text = `hi 🏡 ${link}`; | |
| 28 | const n = postLength(text); | |
| 29 | expect(n.counts.instagram).toBe([...text].length); | |
| 30 | expect(n.counts.facebook).toBe([...text].length); | |
| 31 | expect(n.counts.x).toBe(3 + 2 + 1 + 23); | |
| 32 | }); | |
| 33 | ||
| 34 | test("what a post fits, and how far over X it is", () => { | |
| 35 | expect(postLength("a".repeat(280))).toMatchObject({ fits: ["x", "instagram", "facebook"], overX: 0 }); | |
| 36 | expect(postLength("a".repeat(314))).toMatchObject({ fits: ["instagram", "facebook"], overX: 34 }); | |
| 37 | expect(postLength("a".repeat(2201)).fits).toEqual(["facebook"]); | |
| 38 | expect(postLength("a".repeat(LIMITS.facebook + 1)).fits).toEqual([]); | |
| 39 | }); | |
| 40 | ||
| 41 | test("the line names the count and each limit", () => { | |
| 42 | expect(lengthLine("a".repeat(300))).toBe( | |
| 43 | "300 characters · X 280 ✗ · Instagram 2,200 ✓ · Facebook 63,206 ✓", | |
| 44 | ); | |
| 45 | expect(overXNote("a".repeat(314))).toBe("Too long for X by 34"); | |
| 46 | expect(overXNote("short")).toBe(""); | |
| 47 | }); | |
| 48 | ||
| 49 | test("the generated short post fits X in every tone for every sample listing", () => { | |
| 50 | for (const sample of [SAMPLE, SAMPLE_CONDO, SAMPLE_HOUSE]) | |
| 51 | for (const tone of TONES) { | |
| 52 | const { social_short } = generate({ facts: { ...sample, tone }, agent: agentOf(sample) }); | |
| 53 | expect([sample.address, tone, xLength(social_short) <= LIMITS.x]).toEqual([sample.address, tone, true]); | |
| 54 | } | |
| 55 | }); |
src/postlength.ts added +85
| @@ -0,0 +1,85 @@ | ||
| 1 | /** | |
| 2 | * How long a social post is by what each network counts. Plain text in, plain | |
| 3 | * data out. X counts a link as 23 and an emoji as two; Instagram and Facebook | |
| 4 | * count characters (code points). These are the stated rules only: X's other | |
| 5 | * weights (wide CJK characters, for one) are not modelled. | |
| 6 | */ | |
| 7 | ||
| 8 | export const LIMITS = { x: 280, instagram: 2200, facebook: 63206 } as const; | |
| 9 | export type Network = keyof typeof LIMITS; | |
| 10 | ||
| 11 | export const NETWORK_NAMES: Record<Network, string> = { | |
| 12 | x: "X", | |
| 13 | instagram: "Instagram", | |
| 14 | facebook: "Facebook", | |
| 15 | }; | |
| 16 | ||
| 17 | export const X_LINK = 23; | |
| 18 | export const X_EMOJI = 2; | |
| 19 | ||
| 20 | const LINK = /https?:\/\/[^\s]+/gu; | |
| 21 | const TRAILING = /[.,;:!?)\]]+$/u; | |
| 22 | const ONE = String.raw`(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}\uFE0F)\p{Emoji_Modifier}?`; | |
| 23 | const EMOJI = new RegExp(String.raw`[\u{1F1E6}-\u{1F1FF}]{2}|${ONE}(?:\u200D${ONE})*`, "gu"); | |
| 24 | ||
| 25 | const points = (s: string): number => [...s].length; | |
| 26 | ||
| 27 | /** Characters as X counts them: each link 23, each emoji 2, the rest one apiece. */ | |
| 28 | export function xLength(text: string): number { | |
| 29 | let n = 0; | |
| 30 | let rest = text; | |
| 31 | rest = rest.replace(LINK, (url) => { | |
| 32 | const tail = TRAILING.exec(url)?.[0] ?? ""; | |
| 33 | n += X_LINK + points(tail); | |
| 34 | return ""; | |
| 35 | }); | |
| 36 | rest = rest.replace(EMOJI, () => { | |
| 37 | n += X_EMOJI; | |
| 38 | return ""; | |
| 39 | }); | |
| 40 | return n + points(rest); | |
| 41 | } | |
| 42 | ||
| 43 | export type Length = { | |
| 44 | counts: Record<Network, number>; | |
| 45 | /** The networks the post fits, in the order of LIMITS. */ | |
| 46 | fits: Network[]; | |
| 47 | /** How many characters over X's limit; 0 when it fits. */ | |
| 48 | overX: number; | |
| 49 | }; | |
| 50 | ||
| 51 | export function postLength(text: string): Length { | |
| 52 | const counts: Record<Network, number> = { | |
| 53 | x: xLength(text), | |
| 54 | instagram: points(text), | |
| 55 | facebook: points(text), | |
| 56 | }; | |
| 57 | const networks = Object.keys(LIMITS) as Network[]; | |
| 58 | return { | |
| 59 | counts, | |
| 60 | fits: networks.filter((k) => counts[k] <= LIMITS[k]), | |
| 61 | overX: Math.max(0, counts.x - LIMITS.x), | |
| 62 | }; | |
| 63 | } | |
| 64 | ||
| 65 | export const isPost = (kind: string): boolean => kind.startsWith("social_"); | |
| 66 | ||
| 67 | /** Only the short post is written for X; the longer ones are for Instagram and Facebook. */ | |
| 68 | export const meantForX = (kind: string): boolean => kind === "social_short"; | |
| 69 | ||
| 70 | /** The line under a post's box: its count, and which limits it fits. */ | |
| 71 | export function lengthLine(text: string): string { | |
| 72 | const { counts, fits } = postLength(text); | |
| 73 | const fmt = (n: number) => n.toLocaleString("en-US"); | |
| 74 | const limits = (Object.keys(LIMITS) as Network[]) | |
| 75 | .map((k) => `${NETWORK_NAMES[k]} ${fmt(LIMITS[k])} ${fits.includes(k) ? "✓" : "✗"}`) | |
| 76 | .join(" · "); | |
| 77 | const onX = counts.x === counts.instagram ? "" : ` (${fmt(counts.x)} on X)`; | |
| 78 | return `${fmt(counts.instagram)} characters${onX} · ${limits}`; | |
| 79 | } | |
| 80 | ||
| 81 | /** What the aside says when a post is over X's limit; nothing when it fits. */ | |
| 82 | export const overXNote = (text: string): string => { | |
| 83 | const over = postLength(text).overX; | |
| 84 | return over > 0 ? `Too long for X by ${over}` : ""; | |
| 85 | }; |
src/routes.ts +12 −3
| @@ -62,7 +62,8 @@ import { draftOf, home, type Draft } from "./pages/home.ts"; | ||
| 62 | 62 | import { layout } from "./pages/layout.ts"; |
| 63 | 63 | import { factsPage } from "./pages/facts.ts"; |
| 64 | 64 | import { emailPage } from "./pages/email.ts"; |
| 65 | import { approval, fairHousing, flyerPage, listingPage, suggestionHref, tabOf } from "./pages/listing.ts"; | |
| 65 | import { isPost, lengthLine } from "./postlength.ts"; | |
| 66 | import { approval, asideOf, flyerPage, listingPage, suggestionHref, tabOf } from "./pages/listing.ts"; | |
| 66 | 67 | import { notesPage } from "./pages/notes.ts"; |
| 67 | 68 | import { pathOf, PHOTOS_MAX, PHOTOS_TOTAL, typeOf } from "./photos.ts"; |
| 68 | 69 | import { tokensCss } from "./tokens.ts"; |
| @@ -584,7 +585,8 @@ export const routes: readonly Route[] = [ | ||
| 584 | 585 | const a = done.value.assets.find((x) => x.kind === kind)!; |
| 585 | 586 | return json({ |
| 586 | 587 | text: a.text, |
| 587 | html: fairHousing(a.text, a.check, suggestionHref(id, kind)), | |
| 588 | html: asideOf(kind, a.text, a.check, suggestionHref(id, kind)), | |
| 589 | length: isPost(kind) ? lengthLine(a.text) : null, | |
| 588 | 590 | approval: approval(done.value, a), |
| 589 | 591 | }); |
| 590 | 592 | } |
| @@ -607,6 +609,7 @@ export const routes: readonly Route[] = [ | ||
| 607 | 609 | approved: approving, |
| 608 | 610 | seen: form.get("seen"), |
| 609 | 611 | door: "page", |
| 612 | tooLong: form.get("too_long") === "1", | |
| 610 | 613 | ...(form.has("text") ? { shown: form.get("text") } : {}), |
| 611 | 614 | }); |
| 612 | 615 | const to = `/listings/${id}?tab=${tabOf(kind)}#${encodeURIComponent(kind)}`; |
| @@ -643,7 +646,13 @@ export const routes: readonly Route[] = [ | ||
| 643 | 646 | if (body === undefined) return json({ error: "too_large" }, 413); |
| 644 | 647 | const words = String(fields(body).text ?? ""); |
| 645 | 648 | const checked = checkWords(words); |
| 646 | return json({ ...checked, html: fairHousing(words.slice(0, ASSET_MAX), checked) }); | |
| 649 | const kind = String(fields(body).kind ?? ""); | |
| 650 | const shown = words.slice(0, ASSET_MAX); | |
| 651 | return json({ | |
| 652 | ...checked, | |
| 653 | html: asideOf(kind, shown, checked), | |
| 654 | length: isPost(kind) ? lengthLine(shown) : null, | |
| 655 | }); | |
| 647 | 656 | }, |
| 648 | 657 | }, |
| 649 | 658 | { |
src/tty.test.ts +27
| @@ -196,3 +196,30 @@ test("the email preview reads like the page: singular, and no lot that is not a | ||
| 196 | 196 | expect(shown).not.toContain("Condo, no lot"); |
| 197 | 197 | expect(shown).not.toContain("1 beds"); |
| 198 | 198 | }); |
| 199 | ||
| 200 | test("the terminal shows a social post's length, and a short post over X needs a second a", () => { | |
| 201 | const { db, id } = fresh(); | |
| 202 | expect(editAsset(db, me, { id, kind: "social_short", text: "a".repeat(314) }).ok).toBe(true); | |
| 203 | const tty = session(db, me); | |
| 204 | tty.press("\r"); | |
| 205 | tty.press("j"); // the short post | |
| 206 | const seen = plain(tty.screen()); | |
| 207 | expect(seen).toContain("314 characters · X 280 ✗ · Instagram 2,200 ✓ · Facebook 63,206 ✓"); | |
| 208 | expect(seen).toContain("! Too long for X by 34"); | |
| 209 | ||
| 210 | tty.press("a"); | |
| 211 | expect(plain(tty.screen())).toContain("Too long for X by 34. Press a again to approve it anyway."); | |
| 212 | expect(plain(tty.screen())).toContain("0 of 6 approved"); | |
| 213 | tty.press("a"); | |
| 214 | expect(plain(tty.screen())).toContain("Approved, too long for X."); | |
| 215 | expect(plain(tty.screen())).toContain("1 of 6 approved"); | |
| 216 | // Another key between the two presses starts the question again. | |
| 217 | tty.press("a"); // withdraw | |
| 218 | tty.press("a"); | |
| 219 | tty.press("k"); | |
| 220 | tty.press("j"); | |
| 221 | tty.press("a"); | |
| 222 | expect(plain(tty.screen())).toContain("Press a again"); | |
| 223 | const outcomes = db.query<{ outcome: string }, []>("SELECT outcome FROM audit WHERE outcome LIKE 'approved%'").all(); | |
| 224 | expect(outcomes.map((r) => r.outcome)).toEqual(["approved_too_long_for_x"]); | |
| 225 | }); |
src/tty.ts +18 −4
| @@ -34,6 +34,7 @@ import { | ||
| 34 | 34 | } from "./actions.ts"; |
| 35 | 35 | import { ssh, title, where } from "./env.ts"; |
| 36 | 36 | import { approvalLabel } from "./listing.ts"; |
| 37 | import { isPost, lengthLine, meantForX, overXNote } from "./postlength.ts"; | |
| 37 | 38 | import { emailParts } from "./pages/email.ts"; |
| 38 | 39 | import { factItems, money, openHouseLine, TONES, type Kind, type OpenHouse } from "./generate.ts"; |
| 39 | 40 | import type { Who } from "./who.ts"; |
| @@ -82,6 +83,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 82 | 83 | let typing: { for: "note" } | { for: "asset"; kind: Kind } | { for: "open" } | undefined; |
| 83 | 84 | let typed = ""; |
| 84 | 85 | let said = ""; |
| 86 | /** The short post over X that `a` was pressed on once: a second `a` approves it knowing so. */ | |
| 87 | let longAsked: string | undefined; | |
| 85 | 88 | |
| 86 | 89 | const notes = () => { |
| 87 | 90 | const listed = listNotes(db, me); |
| @@ -121,6 +124,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 121 | 124 | const body = l.assets.slice(cursor).flatMap((a, i) => [ |
| 122 | 125 | `${i === 0 ? "▸" : " "} ${i === 0 ? bold(a.label) : a.label} ${a.check.clear ? green("Fair Housing: clear") : amber(`Fair Housing: ${a.check.findings.length} to review`)}${a.edited ? dim(" edited") : ""}${a.approval === null ? "" : green(` approved by ${a.approval.by_name}`)}`, |
| 123 | 126 | ...a.text.split("\n").map((line) => ` ${line}`), |
| 127 | ...(isPost(a.kind) ? [dim(` ${lengthLine(a.text)}`)] : []), | |
| 128 | ...(meantForX(a.kind) && overXNote(a.text) !== "" ? [amber(` ! ${overXNote(a.text)}`)] : []), | |
| 124 | 129 | ...a.check.findings.map((f) => amber(` ! ${f.line}`)), |
| 125 | 130 | "", |
| 126 | 131 | ]); |
| @@ -210,6 +215,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 210 | 215 | |
| 211 | 216 | function press(key: string): "quit" | undefined { |
| 212 | 217 | said = ""; |
| 218 | const asked = longAsked; | |
| 219 | longAsked = undefined; | |
| 213 | 220 | if (typing !== undefined) { |
| 214 | 221 | type(key); |
| 215 | 222 | return undefined; |
| @@ -252,10 +259,17 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 252 | 259 | const asset = assets[cursor]; |
| 253 | 260 | if (asset !== undefined) { |
| 254 | 261 | const withdrawing = asset.approval !== null; |
| 255 | const done = approveAsset(db, me, { id: found.value.id, kind: asset.kind, approved: !withdrawing, seen: asset.updated_at, door: "tty" }); | |
| 256 | said = done.ok | |
| 257 | ? withdrawing ? "Approval withdrawn." : green("Approved.") | |
| 258 | : done.message; | |
| 262 | const stamp = `${asset.kind}@${asset.updated_at}`; | |
| 263 | const long = !withdrawing && meantForX(asset.kind) && overXNote(asset.text) !== ""; | |
| 264 | if (long && asked !== stamp) { | |
| 265 | longAsked = stamp; | |
| 266 | said = amber(`${overXNote(asset.text)}. Press a again to approve it anyway.`); | |
| 267 | } else { | |
| 268 | const done = approveAsset(db, me, { id: found.value.id, kind: asset.kind, approved: !withdrawing, seen: asset.updated_at, door: "tty", tooLong: long }); | |
| 269 | said = done.ok | |
| 270 | ? withdrawing ? "Approval withdrawn." : green(long ? "Approved, too long for X." : "Approved.") | |
| 271 | : done.message; | |
| 272 | } | |
| 259 | 273 | } |
| 260 | 274 | } else if (key === "o") { |
| 261 | 275 | typing = { for: "open" }; |
static/listing.js +5 −3
| @@ -85,7 +85,7 @@ for (const area of document.querySelectorAll("textarea[data-check]")) { | ||
| 85 | 85 | aside.innerHTML = body.html; |
| 86 | 86 | announce(`Used the suggested wording. ${aside.querySelector(".fh-status")?.textContent ?? ""}`); |
| 87 | 87 | if (approval) approval.innerHTML = body.approval; |
| 88 | if (counter) counter.textContent = `${area.value.length} characters`; | |
| 88 | if (counter) counter.textContent = body.length ?? `${area.value.length} characters`; | |
| 89 | 89 | }); |
| 90 | 90 | area.addEventListener("input", () => { |
| 91 | 91 | if (approve) approve.disabled = true; |
| @@ -96,10 +96,12 @@ for (const area of document.querySelectorAll("textarea[data-check]")) { | ||
| 96 | 96 | const response = await fetch("/fair-housing", { |
| 97 | 97 | method: "POST", |
| 98 | 98 | headers: { "content-type": "application/json" }, |
| 99 | body: JSON.stringify({ text: area.value }), | |
| 99 | body: JSON.stringify({ text: area.value, kind }), | |
| 100 | 100 | }); |
| 101 | 101 | if (!response.ok || mine !== asked) return; |
| 102 | aside.innerHTML = (await response.json()).html; | |
| 102 | const checked = await response.json(); | |
| 103 | aside.innerHTML = checked.html; | |
| 104 | if (counter && checked.length) counter.textContent = `${checked.length} · not saved`; | |
| 103 | 105 | announce(aside.querySelector(".fh-status")?.textContent ?? ""); |
| 104 | 106 | }, 600); |
| 105 | 107 | }); |