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

Merged opened this

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

Merged

mason/listing-launch:mason/35-each-social-post-shows-its-length-agains → main 2 commits fork

  • Merged. mason/35-each-social-post-shows-its-length-agains was merged into main as 0b90ca0.

Review

Sign in to review this change.
10 files changed +312 −19
README.md +15
@@ -66,6 +66,20 @@ list cannot drift from the app.
6666 clear, amber with the risky words underlined and a one-line reason —
6767 *"'family-friendly' implies familial status; say 'near parks and
6868 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.
6983 Where a finding's rewrite is a phrase that can stand in its place, the
7084 aside has a **Use "near parks and schools"** button beside it (only for
7185 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.
163177 | `src/actions.ts` | listings (launch, edit an asset, retone, change facts, remove), notes, the audit trail |
164178 | `src/listing.ts` | what a listing is, and the check at the boundary that one arrived |
165179 | `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 |
166181 | `src/fairhousing.ts` | the Fair Housing rules: phrase, context, class, reason, rewrite |
167182 | `src/photos.ts` | photos as files beside the database, known by their bytes |
168183 | `src/sample.ts` | the sample listing, planted once |
src/actions.ts +23 −5
@@ -29,6 +29,7 @@ import {
2929 type ListingRow,
3030 } from "./listing.ts";
3131 import { emit } from "./live.ts";
32 import { meantForX, overXNote, postLength } from "./postlength.ts";
3233 import {
3334 accept as acceptPhoto,
3435 type Kind as PhotoKind,
@@ -644,6 +645,12 @@ function suggest(
644645 });
645646 }
646647
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
647654 /**
648655 * A person signs off one asset's words (or takes it back): who and when go
649656 * on the row, and any change to the words clears it. Whoever may update the
@@ -654,13 +661,15 @@ function suggest(
654661 * is approved (`conflict`). `door` is where the call came through; a
655662 * person's door (page, tty) is stored with an approval, and a caller an
656663 * 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.
659668 */
660669 export function approveAsset(
661670 db: Database,
662671 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 },
664673 ): Result<Launched> {
665674 const approving = input.approved === undefined || input.approved === true;
666675 const done = approve(db, who, input, approving);
@@ -669,7 +678,11 @@ export function approveAsset(
669678 resource: "listing",
670679 verb: "update",
671680 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,
673686 });
674687 return done;
675688 }
@@ -677,7 +690,7 @@ export function approveAsset(
677690 function approve(
678691 db: Database,
679692 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 },
681694 approving: boolean,
682695 ): Result<Launched> {
683696 const found = listingFor(db, who, "update", input.id, {
@@ -706,6 +719,11 @@ function approve(
706719 "conflict",
707720 "These words changed since you read them. Save your changes and read them again before approving.",
708721 );
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 );
709727 db.query(
710728 "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",
711729 ).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") => {
3333 };
3434 const alice = as("alice");
3535 const bob = as("bob");
36 const carol = as("carol"); // her own write budget: the door rations writes per person
3637
3738 const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);
3839 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
859860 expect(getListing(db, me, { id })).toMatchObject({ value: { photos: [a] } });
860861 expect(existsSync(join(data, "photos", b))).toBe(false);
861862 });
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 {
1515 } from "../generate.ts";
1616 import { asset as assetHref, escape } from "../http.ts";
1717 import { approvalLabel, ASSET_MAX, type Asset, type Launched, type Listing } from "../listing.ts";
18 import { isPost, lengthLine, meantForX, overXNote } from "../postlength.ts";
1819 import { isSample } from "../photos.ts";
1920 import type { Who } from "../who.ts";
2021 import { ago, layout, toast } from "./layout.ts";
@@ -72,6 +73,12 @@ export function fairHousing(text: string, check: Check, fix?: string): string {
7273 <div class="fh-text" role="group" aria-label="The words, with what was flagged underlined">${annotate(text, check)}</div>`;
7374 }
7475
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
7582 const flags = (l: Launched): number =>
7683 l.assets.reduce((n, a) => n + a.check.findings.length, 0);
7784
@@ -101,8 +108,11 @@ export function approval(l: Launched, a: Asset): string {
101108 return `<span class="approved" data-approved>✓ Approved by ${escape(a.approval.by_name)} ${ago(a.approval.at)}${via(a.approval.via)}</span>
102109 <button class="button button-quiet" formaction="${to}" name="approved" value="0" aria-label="Withdraw approval of the ${escape(a.label)}">Withdraw</button>`;
103110 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>`;
106116 }
107117
108118 function editor(l: Launched, a: Asset, editable: boolean): string {
@@ -116,11 +126,11 @@ function editor(l: Launched, a: Asset, editable: boolean): string {
116126 ${editable ? `<button class="button" aria-label="Save the ${escape(a.label)}">Save</button>` : ""}
117127 <button type="button" class="button button-quiet" data-copy data-asset="${escape(a.label)}" hidden aria-label="Copy the ${escape(a.label)}">Copy</button>
118128 ${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>
120130 </div>
121131 </form>
122132 </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>
124134 </section>`;
125135 }
126136
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";
6262 import { layout } from "./pages/layout.ts";
6363 import { factsPage } from "./pages/facts.ts";
6464 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";
6667 import { notesPage } from "./pages/notes.ts";
6768 import { pathOf, PHOTOS_MAX, PHOTOS_TOTAL, typeOf } from "./photos.ts";
6869 import { tokensCss } from "./tokens.ts";
@@ -584,7 +585,8 @@ export const routes: readonly Route[] = [
584585 const a = done.value.assets.find((x) => x.kind === kind)!;
585586 return json({
586587 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,
588590 approval: approval(done.value, a),
589591 });
590592 }
@@ -607,6 +609,7 @@ export const routes: readonly Route[] = [
607609 approved: approving,
608610 seen: form.get("seen"),
609611 door: "page",
612 tooLong: form.get("too_long") === "1",
610613 ...(form.has("text") ? { shown: form.get("text") } : {}),
611614 });
612615 const to = `/listings/${id}?tab=${tabOf(kind)}#${encodeURIComponent(kind)}`;
@@ -643,7 +646,13 @@ export const routes: readonly Route[] = [
643646 if (body === undefined) return json({ error: "too_large" }, 413);
644647 const words = String(fields(body).text ?? "");
645648 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 });
647656 },
648657 },
649658 {
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
196196 expect(shown).not.toContain("Condo, no lot");
197197 expect(shown).not.toContain("1 beds");
198198 });
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 {
3434 } from "./actions.ts";
3535 import { ssh, title, where } from "./env.ts";
3636 import { approvalLabel } from "./listing.ts";
37 import { isPost, lengthLine, meantForX, overXNote } from "./postlength.ts";
3738 import { emailParts } from "./pages/email.ts";
3839 import { factItems, money, openHouseLine, TONES, type Kind, type OpenHouse } from "./generate.ts";
3940 import type { Who } from "./who.ts";
@@ -82,6 +83,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
8283 let typing: { for: "note" } | { for: "asset"; kind: Kind } | { for: "open" } | undefined;
8384 let typed = "";
8485 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;
8588
8689 const notes = () => {
8790 const listed = listNotes(db, me);
@@ -121,6 +124,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
121124 const body = l.assets.slice(cursor).flatMap((a, i) => [
122125 `${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}`)}`,
123126 ...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)}`)] : []),
124129 ...a.check.findings.map((f) => amber(` ! ${f.line}`)),
125130 "",
126131 ]);
@@ -210,6 +215,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
210215
211216 function press(key: string): "quit" | undefined {
212217 said = "";
218 const asked = longAsked;
219 longAsked = undefined;
213220 if (typing !== undefined) {
214221 type(key);
215222 return undefined;
@@ -252,10 +259,17 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
252259 const asset = assets[cursor];
253260 if (asset !== undefined) {
254261 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 }
259273 }
260274 } else if (key === "o") {
261275 typing = { for: "open" };
static/listing.js +5 −3
@@ -85,7 +85,7 @@ for (const area of document.querySelectorAll("textarea[data-check]")) {
8585 aside.innerHTML = body.html;
8686 announce(`Used the suggested wording. ${aside.querySelector(".fh-status")?.textContent ?? ""}`);
8787 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`;
8989 });
9090 area.addEventListener("input", () => {
9191 if (approve) approve.disabled = true;
@@ -96,10 +96,12 @@ for (const area of document.querySelectorAll("textarea[data-check]")) {
9696 const response = await fetch("/fair-housing", {
9797 method: "POST",
9898 headers: { "content-type": "application/json" },
99 body: JSON.stringify({ text: area.value }),
99 body: JSON.stringify({ text: area.value, kind }),
100100 });
101101 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`;
103105 announce(aside.querySelector(".fh-status")?.textContent ?? "");
104106 }, 600);
105107 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs