Public: anyone can read this; the crew can change it.

The MLS description shows its length against the MLS public remarks limit, and one over it cannot be approved without saying so #109

Merged opened this

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

Merged

mason/listing-launch:mason/106-the-mls-description-shows-its-length-aga → main 2 commits fork

  • Merged. mason/106-the-mls-description-shows-its-length-aga was merged into main as 914185a.

Review

Sign in to review this change.
12 files changed +154 −32
README.md +6
@@ -248,6 +248,12 @@ list cannot drift from the app.
248248 `approved_too_long_for_x`. The terminal shows the same line and note under
249249 each social post; `a` on a short post over X asks once, and a second `a`
250250 approves it.
251 The MLS description shows `N / 1,000 characters` beside its box, counted in
252 code points and live as you type; over 1,000 (the common MLS public-remarks
253 cap, `MLS_LIMIT` in `src/postlength.ts`) it needs the same `tooLong`
254 acknowledgement, on the page, the API, MCP and the terminal, and the audit
255 trail records it as `approved_too_long_for_mls`. (The audit trail is where
256 it is recorded; no seller report reads audit outcomes, so none shows it.)
251257 Where a finding's rewrite is a phrase that can stand in its place, the
252258 aside has a **Use "near parks and schools"** button beside it (only for
253259 whoever may edit the listing; advice such as "describe the home, not the
contract/openapi.json +2 −2
@@ -618,7 +618,7 @@
618618 }
619619 },
620620 "patch": {
621 "summary": "Edit one asset's words, put an earlier version back ({ kind, restore }), withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), write every asset again in a tone, mark it coming soon, active, under contract, sold, withdrawn or expired ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.",
621 "summary": "Edit one asset's words, put an earlier version back ({ kind, restore }), withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page, and says so with tooLong when a short post is over X's 280 or the MLS description is over its 1,000-character remarks limit), write every asset again in a tone, mark it coming soon, active, under contract, sold, withdrawn or expired ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.",
622622 "operationId": "patchApiListingsId",
623623 "parameters": [
624624 {
@@ -845,7 +845,7 @@
845845 },
846846 "responses": {
847847 "200": {
848 "description": "Edit one asset's words, put an earlier version back ({ kind, restore }), withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), write every asset again in a tone, mark it coming soon, active, under contract, sold, withdrawn or expired ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.",
848 "description": "Edit one asset's words, put an earlier version back ({ kind, restore }), withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page, and says so with tooLong when a short post is over X's 280 or the MLS description is over its 1,000-character remarks limit), write every asset again in a tone, mark it coming soon, active, under contract, sold, withdrawn or expired ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.",
849849 "content": {
850850 "application/json": {
851851 "schema": {
src/actions.ts +12 −11
@@ -36,7 +36,7 @@ import { publicOrigin } from "./env.ts";
3636 import { matchesStatus, matchesText, parseFind } from "./find.ts";
3737 import { kit } from "./kit.ts";
3838 import { emit } from "./live.ts";
39 import { meantForX, overXNote, postLength } from "./postlength.ts";
39 import { overNote, overOutcome } from "./postlength.ts";
4040 import {
4141 accept as acceptPhoto,
4242 type Kind as PhotoKind,
@@ -968,10 +968,10 @@ function restore(
968968 return edit(db, who, { id: found.value.id, kind: input.kind, text: row.body }, "restored");
969969 }
970970
971 /** How far a short post is over X's limit; 0 for any other asset or one that fits. */
972 const overX = (l: Launched, kind: string): number => {
971 /** The audit outcome for an approval that went through: plain, or naming the limit it was over. */
972 const approvedOutcome = (l: Launched, kind: string): string => {
973973 const a = l.assets.find((x) => x.kind === kind);
974 return a !== undefined && meantForX(a.kind) ? postLength(a.text).overX : 0;
974 return (a !== undefined && overOutcome(a.kind, a.text)) || "approved";
975975 };
976976
977977 /**
@@ -984,10 +984,11 @@ const overX = (l: Launched, kind: string): number => {
984984 * is approved (`conflict`). `door` is where the call came through; a
985985 * person's door (page, tty) is stored with an approval, and a caller an
986986 * assistant can reach (api, mcp) is refused (`person_required`) when it
987 * approves, and may withdraw. A short post over X's limit is approved only
988 * with `tooLong: true` (the page's button and the terminal's second `a` say
989 * so), and the audit row then reads `approved_too_long_for_x`. Both
990 * directions are audit rows, named in the outcome.
987 * approves, and may withdraw. A short post over X's limit, or an MLS
988 * description over its 1,000-character cap, is approved only with
989 * `tooLong: true` (the page's button and the terminal's second `a` say so),
990 * and the audit row then reads `approved_too_long_for_x` (`_for_mls` for the
991 * MLS description). Both directions are audit rows, named in the outcome.
991992 */
992993 export function approveAsset(
993994 db: Database,
@@ -1003,7 +1004,7 @@ export function approveAsset(
10031004 target: done.ok ? done.value.id : input.id,
10041005 outcome: done.ok
10051006 ? approving
1006 ? overX(done.value, input.kind) > 0 ? "approved_too_long_for_x" : "approved"
1007 ? approvedOutcome(done.value, input.kind)
10071008 : "unapproved"
10081009 : done.error,
10091010 });
@@ -1042,10 +1043,10 @@ function approve(
10421043 "conflict",
10431044 "These words changed since you read them. Save your changes and read them again before approving.",
10441045 );
1045 if (meantForX(asset.kind) && overXNote(asset.text) !== "" && input.tooLong !== true)
1046 if (overNote(asset.kind, asset.text) !== "" && input.tooLong !== true)
10461047 return fail(
10471048 "invalid",
1048 `${overXNote(asset.text)}. Approve it knowing so: send tooLong: true.`,
1049 `${overNote(asset.kind, asset.text)}. Approve it knowing so: send tooLong: true.`,
10491050 );
10501051 db.query(
10511052 "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",
src/listings.test.ts +40 −1
@@ -422,6 +422,12 @@ test("only a person approves: MCP refuses approving and audits it, still withdra
422422 expect(audited.length).toBe(2);
423423 expect(approveAsset(db, me, { id, kind: "mls", door: "api", seen: seen() })).toMatchObject({ ok: false, error: "person_required" });
424424 expect(via().approved_at).toBeNull();
425 // Over the MLS limit with the acknowledgement, still never through the API or MCP.
426 editAsset(db, me, { id, kind: "mls", text: "a".repeat(1001) });
427 expect(approveAsset(db, me, { id, kind: "mls", door: "api", seen: seen(), tooLong: true })).toMatchObject({ ok: false, error: "person_required" });
428 expect(approveAsset(db, me, { id, kind: "mls", door: "mcp", seen: seen(), tooLong: true })).toMatchObject({ ok: false, error: "person_required" });
429 expect(via().approved_at).toBeNull();
430 editAsset(db, me, { id, kind: "mls", text: "a fair, short description" });
425431
426432 approveAsset(db, me, { id, kind: "mls", door: "page", seen: seen() });
427433 expect(via().approved_via).toBe("page");
@@ -900,7 +906,7 @@ test("each social post shows its length; a short post over X says so and its App
900906 const mls = await fetch(`${base}/fair-housing`, {
901907 method: "POST",
902908 headers: { "content-type": "application/json" },
903 body: JSON.stringify({ text: "a".repeat(314), kind: "mls" }),
909 body: JSON.stringify({ text: "a".repeat(314), kind: "email_update" }),
904910 });
905911 expect(((await mls.json()) as { length: string | null }).length).toBeNull();
906912 });
@@ -926,6 +932,39 @@ test("approving a short post over X needs tooLong, from any door, and the audit
926932 expect(outcomes.map((r) => r.outcome)).toEqual(["approved_too_long_for_x", "approved"]);
927933 });
928934
935 test("approving an MLS description over 1,000 characters needs tooLong, and the audit row says so", () => {
936 const db = openDb(mkdtempSync(join(tmpdir(), "app-mlslong-")));
937 const me: Who = { role: "user", id: "dana", name: "Dana" };
938 const id = insertListing(db, me, SAMPLE, [], null).id;
939 const stamp = () => {
940 const l = getListing(db, me, { id });
941 return l.ok ? l.value.assets.find((a) => a.kind === "mls")!.updated_at : "";
942 };
943 const approve = (extra: { tooLong?: unknown } = {}) => approveAsset(db, me, { id, kind: "mls", door: "page", seen: stamp(), ...extra });
944 editAsset(db, me, { id, kind: "mls", text: "a".repeat(1000) });
945 expect(approve().ok).toBe(true);
946 editAsset(db, me, { id, kind: "mls", text: "a".repeat(1001) });
947 const refused = approve();
948 expect(refused.ok).toBe(false);
949 expect(refused.ok ? "" : refused.message).toContain("Too long for the MLS remarks by 1");
950 expect(approve({ tooLong: "yes" }).ok).toBe(false);
951 expect(approve({ tooLong: true }).ok).toBe(true);
952 const outcomes = db.query<{ outcome: string }, []>("SELECT outcome FROM audit WHERE outcome LIKE 'approved%' ORDER BY id").all();
953 expect(outcomes.map((r) => r.outcome)).toEqual(["approved", "approved_too_long_for_mls"]);
954 });
955
956 test("the listing page renders the MLS count beside its box, and /fair-housing answers it", async () => {
957 const first = (await (await fetch(`${base}/api/listings`)).json()) as { id: number }[];
958 const html = await (await fetch(`${base}/listings/${first[0]!.id}`)).text();
959 expect(html).toMatch(/data-count data-limit="1000">[\d,]+ \/ 1,000 characters</);
960 const res = await fetch(`${base}/fair-housing`, {
961 method: "POST",
962 headers: { "content-type": "application/json" },
963 body: JSON.stringify({ text: "a".repeat(1001), kind: "mls" }),
964 });
965 expect(((await res.json()) as { length: string }).length).toBe("1,001 / 1,000 characters");
966 });
967
929968 test("the home list filters by status and needs-approval, searches the address, and escapes a hostile q", async () => {
930969 const get = async (qs: string) => (await fetch(`${base}/${qs}`)).text();
931970 const cards = (html: string) => (html.match(/class="listing-card"/g) ?? []).length;
src/pages/listing.ts +5 −5
@@ -21,7 +21,7 @@ import {
2121 } from "../generate.ts";
2222 import { asset as assetHref, escape } from "../http.ts";
2323 import { altOf, approvalLabel, ASSET_MAX, propertyPath, type Asset, type Launched, type Listing } from "../listing.ts";
24 import { isPost, lengthLine, meantForX, overXNote } from "../postlength.ts";
24 import { countLine, MLS_LIMIT, isMls, overNote } from "../postlength.ts";
2525 import { isSample } from "../photos.ts";
2626 import type { Who } from "../who.ts";
2727 import { dayOf } from "../showings.ts";
@@ -86,7 +86,7 @@ export function fairHousing(text: string, check: Check, fix?: string): string {
8686
8787 /** The aside's whole content for an asset: the length warning (a short post over X) above the Fair Housing check. */
8888 export function asideOf(kind: string, text: string, check: Check, fix?: string): string {
89 const over = meantForX(kind) ? overXNote(text) : "";
89 const over = overNote(kind, text);
9090 return `${over === "" ? "" : `<p class="fh-status fh-warn" role="status" data-too-long>${over}</p>`}${fairHousing(text, check, fix)}`;
9191 }
9292
@@ -135,9 +135,9 @@ export function approval(l: Launched, a: Asset): string {
135135 return `<span class="approved" data-approved>✓ Approved by ${escape(a.approval.by_name)} ${ago(a.approval.at)}${via(a.approval.via)}</span>
136136 <button class="button button-quiet" formaction="${to}" name="approved" value="0" aria-label="Withdraw approval of the ${escape(a.label)}">Withdraw</button>`;
137137 const n = a.check.findings.length;
138 const long = meantForX(a.kind) && overXNote(a.text) !== "";
138 const long = overNote(a.kind, a.text) !== "";
139139 const label = n === 0 ? "Approve" : `Approve with ${n} ${n === 1 ? "flag" : "flags"}`;
140 const words = long ? `${label} — too long for X` : label;
140 const words = long ? `${label} — too long for ${isMls(a.kind) ? "the MLS" : "X"}` : label;
141141 return `<input type="hidden" name="seen" value="${escape(a.updated_at)}">${long ? `\n <input type="hidden" name="too_long" value="1">` : ""}
142142 <button class="button" data-approve aria-label="${words}: the ${escape(a.label)}" formaction="${to}" name="approved" value="1">${words}</button>`;
143143 }
@@ -197,7 +197,7 @@ function editor(l: Launched, a: Asset, editable: boolean, versions: readonly Ver
197197 ${editable ? `<button class="button" aria-label="Save the ${escape(a.label)}">Save</button>` : ""}
198198 <button type="button" class="button button-quiet" data-copy data-asset="${escape(a.label)}" hidden aria-label="Copy the ${escape(a.label)}">Copy</button>
199199 ${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>`}
200 <small class="muted" data-count>${isPost(a.kind) ? escape(lengthLine(a.text)) : `${a.text.length} characters`}</small>
200 <small class="muted${isMls(a.kind) ? " count-mls" : ""}" data-count${isMls(a.kind) ? ` data-limit="${MLS_LIMIT}"` : ""}>${escape(countLine(a.kind, a.text) ?? `${a.text.length} characters`)}</small>
201201 </div>
202202 </form>
203203 ${copyPost(l, a, origin)}
src/postlength.test.ts +19 −1
@@ -1,7 +1,7 @@
11 import { expect, test } from "bun:test";
22 import { generate, TONES } from "./generate.ts";
33 import { agentOf } from "./listing.ts";
4 import { lengthLine, LIMITS, overXNote, postLength, xLength } from "./postlength.ts";
4 import { countLine, lengthLine, LIMITS, MLS_LIMIT, overMls, overXNote, postLength, xLength } from "./postlength.ts";
55 import { SAMPLE, SAMPLE_CONDO, SAMPLE_HOUSE } from "./sample.ts";
66
77 const link = "https://example.com/a/very/long/path/that/goes/on";
@@ -53,3 +53,21 @@ test("the generated short post fits X in every tone for every sample listing", (
5353 expect([sample.address, tone, xLength(social_short) <= LIMITS.x]).toEqual([sample.address, tone, true]);
5454 }
5555 });
56
57 test("the MLS description counts code points against one named limit", () => {
58 expect(MLS_LIMIT).toBe(1000);
59 expect(countLine("mls", "a".repeat(1000))).toBe("1,000 / 1,000 characters");
60 expect(countLine("mls", "😀".repeat(3))).toBe("3 / 1,000 characters");
61 expect(countLine("email_update", "x")).toBeNull();
62 expect(overMls("mls", "a".repeat(1000))).toBe(0);
63 expect(overMls("mls", "a".repeat(1001))).toBe(1);
64 expect(overMls("social_short", "a".repeat(1001))).toBe(0);
65 });
66
67 test("the three sample listings' MLS descriptions stay under the limit in every tone", () => {
68 for (const sample of [SAMPLE, SAMPLE_CONDO, SAMPLE_HOUSE])
69 for (const tone of TONES) {
70 const { mls } = generate({ facts: { ...sample, tone }, agent: agentOf(sample) });
71 expect([sample.address, tone, overMls("mls", mls)]).toEqual([sample.address, tone, 0]);
72 }
73 });
src/postlength.ts +27
@@ -6,6 +6,9 @@
66 */
77
88 export const LIMITS = { x: 280, instagram: 2200, facebook: 63206 } as const;
9 /** The common MLS public-remarks cap: what an MLS keeps of the description, counted in code points. */
10 export const MLS_LIMIT = 1000;
11
912 export type Network = keyof typeof LIMITS;
1013
1114 export const NETWORK_NAMES: Record<Network, string> = {
@@ -83,3 +86,27 @@ export const overXNote = (text: string): string => {
8386 const over = postLength(text).overX;
8487 return over > 0 ? `Too long for X by ${over}` : "";
8588 };
89
90 export const isMls = (kind: string): boolean => kind === "mls";
91
92 /** How many characters the MLS description is over the remarks limit; 0 when it fits or for another asset. */
93 export const overMls = (kind: string, text: string): number =>
94 isMls(kind) ? Math.max(0, points(text) - MLS_LIMIT) : 0;
95
96 /** What approving is refused for until the agent says so: a short post over X, or an MLS description over its cap; "" when it fits. */
97 export function overNote(kind: string, text: string): string {
98 if (meantForX(kind)) return overXNote(text);
99 const over = overMls(kind, text);
100 return over > 0 ? `Too long for the MLS remarks by ${over}` : "";
101 }
102
103 /** The audit outcome of an approval of words that are over a limit; "" when they fit. */
104 export const overOutcome = (kind: string, text: string): string =>
105 overNote(kind, text) === "" ? "" : isMls(kind) ? "approved_too_long_for_mls" : "approved_too_long_for_x";
106
107 /** The count shown beside an asset's box: a post's lengths, the MLS description's `N / 1,000`, else null. */
108 export function countLine(kind: string, text: string): string | null {
109 if (isPost(kind)) return lengthLine(text);
110 if (isMls(kind)) return `${points(text).toLocaleString("en-US")} / ${MLS_LIMIT.toLocaleString("en-US")} characters`;
111 return null;
112 }
src/routes.ts +4 −4
@@ -80,7 +80,7 @@ import { INTERESTS, SHOWING_AGENT_MAX, SHOWING_FEEDBACK_MAX } from "./showings.t
8080 import { emailPage } from "./pages/email.ts";
8181 import { publicOrigin } from "./env.ts";
8282 import { propertyPage } from "./pages/property.ts";
83 import { isPost, lengthLine } from "./postlength.ts";
83 import { countLine } from "./postlength.ts";
8484 import { reportPage } from "./pages/report.ts";
8585 import { schedulePage } from "./pages/schedule.ts";
8686 import { scheduleIcs, weekIcs } from "./schedule.ts";
@@ -849,7 +849,7 @@ export const routes: readonly Route[] = [
849849 return json({
850850 text: a.text,
851851 html: asideOf(kind, a.text, a.check, suggestionHref(id, kind)),
852 length: isPost(kind) ? lengthLine(a.text) : null,
852 length: countLine(kind, a.text),
853853 approval: approval(done.value, a),
854854 });
855855 }
@@ -939,7 +939,7 @@ export const routes: readonly Route[] = [
939939 return json({
940940 ...checked,
941941 html: asideOf(kind, shown, checked),
942 length: isPost(kind) ? lengthLine(shown) : null,
942 length: countLine(kind, shown),
943943 });
944944 },
945945 },
@@ -1148,7 +1148,7 @@ export const routes: readonly Route[] = [
11481148 : editAsset(c.db, c.who, { id, kind: String(body.kind ?? ""), text: body.text }),
11491149 );
11501150 },
1151 api: { summary: "Edit one asset's words, put an earlier version back ({ kind, restore }), withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), write every asset again in a tone, mark it coming soon, active, under contract, sold, withdrawn or expired ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.", body: LISTING_CHANGE, answer: LISTING },
1151 api: { summary: "Edit one asset's words, put an earlier version back ({ kind, restore }), withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page, and says so with tooLong when a short post is over X's 280 or the MLS description is over its 1,000-character remarks limit), write every asset again in a tone, mark it coming soon, active, under contract, sold, withdrawn or expired ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.", body: LISTING_CHANGE, answer: LISTING },
11521152 },
11531153 {
11541154 method: "DELETE",
src/tty.test.ts +19
@@ -226,6 +226,25 @@ test("the terminal shows a social post's length, and a short post over X needs a
226226 expect(outcomes.map((r) => r.outcome)).toEqual(["approved_too_long_for_x"]);
227227 });
228228
229 test("the terminal shows the MLS description's count, and one over 1,000 needs a second a", () => {
230 const { db, id } = fresh();
231 expect(editAsset(db, me, { id, kind: "mls", text: "a".repeat(1003) }).ok).toBe(true);
232 const tty = session(db, me);
233 tty.press("\r"); // the MLS description is first
234 const seen = plain(tty.screen());
235 expect(seen).toContain("1,003 / 1,000 characters");
236 expect(seen).toContain("! Too long for the MLS remarks by 3");
237
238 tty.press("a");
239 expect(plain(tty.screen())).toContain("Too long for the MLS remarks by 3. Press a again to approve it anyway.");
240 expect(plain(tty.screen())).toContain("0 of 7 approved");
241 tty.press("a");
242 expect(plain(tty.screen())).toContain("Approved, too long for the MLS.");
243 expect(plain(tty.screen())).toContain("1 of 7 approved");
244 const outcomes = db.query<{ outcome: string }, []>("SELECT outcome FROM audit WHERE outcome LIKE 'approved%'").all();
245 expect(outcomes.map((r) => r.outcome)).toEqual(["approved_too_long_for_mls"]);
246 });
247
229248 test("f cycles the listings filter and / searches by address, as the page does", () => {
230249 const { db } = fresh();
231250 const tty = session(db, me);
src/tty.ts +6 −6
@@ -61,7 +61,7 @@ import { reportText } from "./report.ts";
6161 import { scheduleText, weekText } from "./schedule.ts";
6262 import { now } from "./clock.ts";
6363 import { approvalLabel, propertyLine } from "./listing.ts";
64 import { isPost, lengthLine, meantForX, overXNote } from "./postlength.ts";
64 import { countLine, isMls, overNote } from "./postlength.ts";
6565 import { emailParts } from "./pages/email.ts";
6666 import { factItems, money, openHouseLine, TONES, type Kind, type OpenHouse } from "./generate.ts";
6767 import type { Who } from "./who.ts";
@@ -214,8 +214,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
214214 const body = l.assets.slice(cursor).flatMap((a, i) => [
215215 `${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}`)}`,
216216 ...a.text.split("\n").map((line) => ` ${line}`),
217 ...(isPost(a.kind) ? [dim(` ${lengthLine(a.text)}`)] : []),
218 ...(meantForX(a.kind) && overXNote(a.text) !== "" ? [amber(` ! ${overXNote(a.text)}`)] : []),
217 ...(countLine(a.kind, a.text) === null ? [] : [dim(` ${countLine(a.kind, a.text)}`)]),
218 ...(overNote(a.kind, a.text) !== "" ? [amber(` ! ${overNote(a.kind, a.text)}`)] : []),
219219 ...a.check.findings.map((f) => amber(` ! ${f.line}`)),
220220 "",
221221 ]);
@@ -474,14 +474,14 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
474474 if (asset !== undefined) {
475475 const withdrawing = asset.approval !== null;
476476 const stamp = `${asset.kind}@${asset.updated_at}`;
477 const long = !withdrawing && meantForX(asset.kind) && overXNote(asset.text) !== "";
477 const long = !withdrawing && overNote(asset.kind, asset.text) !== "";
478478 if (long && asked !== stamp) {
479479 longAsked = stamp;
480 said = amber(`${overXNote(asset.text)}. Press a again to approve it anyway.`);
480 said = amber(`${overNote(asset.kind, asset.text)}. Press a again to approve it anyway.`);
481481 } else {
482482 const done = approveAsset(db, me, { id: found.value.id, kind: asset.kind, approved: !withdrawing, seen: asset.updated_at, door: "tty", tooLong: long });
483483 said = done.ok
484 ? withdrawing ? "Approval withdrawn." : green(long ? "Approved, too long for X." : "Approved.")
484 ? withdrawing ? "Approval withdrawn." : green(long ? `Approved, too long for ${isMls(asset.kind) ? "the MLS" : "X"}.` : "Approved.")
485485 : done.message;
486486 }
487487 }
static/app.css +6
@@ -1428,3 +1428,9 @@ main:focus {
14281428 margin: 0;
14291429 }
14301430 }
1431
1432 /* The MLS count: wide enough for "1,000 / 1,000 characters" so typing never moves the row. */
1433 .count-mls {
1434 display: inline-block;
1435 min-width: 24ch;
1436 }
static/listing.js +8 −2
@@ -115,7 +115,13 @@ for (const area of document.querySelectorAll("textarea[data-check]")) {
115115 });
116116 area.addEventListener("input", () => {
117117 if (approve) approve.disabled = true;
118 if (counter) counter.textContent = `${area.value.length} characters · not saved`;
118 if (counter) {
119 const n = [...area.value].length;
120 const limit = Number(counter.dataset.limit);
121 counter.textContent = limit
122 ? `${n.toLocaleString("en-US")} / ${limit.toLocaleString("en-US")} characters`
123 : `${area.value.length} characters · not saved`;
124 }
119125 clearTimeout(timer);
120126 timer = setTimeout(async () => {
121127 const mine = ++asked;
@@ -127,7 +133,7 @@ for (const area of document.querySelectorAll("textarea[data-check]")) {
127133 if (!response.ok || mine !== asked) return;
128134 const checked = await response.json();
129135 aside.innerHTML = checked.html;
130 if (counter && checked.length) counter.textContent = `${checked.length} · not saved`;
136 if (counter && checked.length && !counter.dataset.limit) counter.textContent = `${checked.length} · not saved`;
131137 announce(aside.querySelector(".fh-status")?.textContent ?? "");
132138 }, 600);
133139 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs