Public: anyone can read this; the crew can change it.
Tell from a phone's share sheet: the forge takes shared text and links into the composer #1374
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1058-tell-from-a-phone-s-share-sheet-the-forg → main 2 commits fork 3 threads · 3 unresolved
5 files
changed +82
−4
README.md +8
| @@ -386,6 +386,14 @@ not as a crew run; AWS audits look at us-east-1 only; over-provisioned | ||
| 386 | 386 | droplets and forgotten DNS records need metrics or resolution and are not |
| 387 | 387 | attempted; Backblaze audits find nothing yet. No dependency was added. |
| 388 | 388 | |
| 389 | ## Share to the forge | |
| 390 | ||
| 391 | Install the forge to your home screen, then Share a link or text to it from | |
| 392 | any app: the home composer opens with it as a draft, and it is only sent when | |
| 393 | you tap Post. Signed out, you sign in (or up) first and land on the same | |
| 394 | draft. The share target is the web manifest's `share_target`, which only an | |
| 395 | **installed PWA on Android and Chrome** offers; iOS Safari does not. | |
| 396 | ||
| 389 | 397 | ## Develop |
| 390 | 398 | |
| 391 | 399 | sh |
src/web/standards.ts +7
| @@ -149,6 +149,13 @@ export function webManifest(ctx: Ctx): Response { | ||
| 149 | 149 | scope: "/", |
| 150 | 150 | id: "/", |
| 151 | 151 | display: "standalone", |
| 152 | // Sharing a link or text to the installed forge opens the home's | |
| 153 | // composer with it filled in (`sharedDraft`, studio/feed.ts). | |
| 154 | share_target: { | |
| 155 | action: "/", | |
| 156 | method: "GET", | |
| 157 | params: { title: "title", text: "text", url: "url" }, | |
| 158 | }, | |
| 152 | 159 | background_color: "#fafafa", |
| 153 | 160 | theme_color: "#fafafa", |
| 154 | 161 | icons: [ |
src/web/studio/feed.ts +20 −1
| @@ -92,6 +92,25 @@ export function feedCard( | ||
| 92 | 92 | </article>`; |
| 93 | 93 | } |
| 94 | 94 | |
| 95 | /** The longest tell the composer takes (its textarea's `maxlength`). */ | |
| 96 | const TELL_MAX = 2000; | |
| 97 | ||
| 98 | /** | |
| 99 | * What a phone's share sheet handed the composer (`share_target` in the web | |
| 100 | * manifest, standards.ts): the title, text and link it sent as query | |
| 101 | * parameters, as one draft. A page's title often repeats in the text, and | |
| 102 | * some apps put the link in the text as well, so nothing is said twice. It | |
| 103 | * is only ever a draft — nothing is sent until the person taps Post. | |
| 104 | */ | |
| 105 | export function sharedDraft(params: URLSearchParams): string { | |
| 106 | const parts: string[] = []; | |
| 107 | for (const key of ["title", "text", "url"]) { | |
| 108 | const part = (params.get(key) ?? "").trim(); | |
| 109 | if (part !== "" && !parts.some((p) => p.includes(part))) parts.push(part); | |
| 110 | } | |
| 111 | return parts.join("\n").slice(0, TELL_MAX); | |
| 112 | } | |
Truncating at 2000 drops the tail of a long share (often the link, since it is joined last) without telling the person. Keep the link and trim the text instead, or say so next to the box. |
||
| 113 | ||
| 95 | 114 | /** |
| 96 | 115 | * The composer: say something to an app's crew, which opens a tell — the |
| 97 | 116 | * home's one primary action. Without a script it posts to the chosen app's |
| @@ -112,7 +131,7 @@ export function composer(ctx: Ctx, user: User): Html { | ||
| 112 | 131 | const slug = (r: { ownerName: string; name: string }) => `${r.ownerName}/${r.name}`; |
| 113 | 132 | const first = slug(repos[0]!); |
| 114 | 133 | return html`<form id="composer" class="composer" method="post" action="/tell"> |
| 115 | <textarea class="input" name="text" rows="2" maxlength="2000" required placeholder="Tell the crew what should change" aria-label="Tell the crew"></textarea> | |
| 134 | <textarea class="input" name="text" rows="2" maxlength="2000" required placeholder="Tell the crew what should change" aria-label="Tell the crew">${sharedDraft(ctx.url.searchParams)}</textarea> | |
| 116 | 135 | <div class="composer-bar"> |
| 117 | 136 | ${repos.length === 1 |
| 118 | 137 | ? html`<input type="hidden" name="app" value="${first}" /><span class="muted small">${first}</span>` |
src/web/studio/home.ts +7 −3
| @@ -403,6 +403,10 @@ function frontControls(ctx: Ctx, front: FrontPage): Html { | ||
| 403 | 403 | export function studioDoor(ctx: Ctx): Response { |
| 404 | 404 | const front = frontPage(ctx.app); |
| 405 | 405 | const admin = ctx.auth.user?.isAdmin === true && ctx.url.pathname === "/front"; |
| 406 | // A share sheet lands here with the shared words in the query; signing in | |
| 407 | // comes back to them rather than to a bare home, so they are not lost. | |
| 408 | const back = ctx.url.pathname === "/" ? "/" + ctx.url.search : "/"; | |
| 409 | const nextQuery = back === "/" ? "" : `?next=${encodeURIComponent(back)}`; | |
| 406 | 410 | const tiles = front.shelf.map(tile); |
| 407 | 411 | const body = html`<section class="hero"> |
| 408 | 412 | <h1>Apps, live.</h1> |
| @@ -412,13 +416,13 @@ export function studioDoor(ctx: Ctx): Response { | ||
| 412 | 416 | ? null |
The edge-login branch now passes |
||
| 413 | 417 | : ctx.app.config.edgeToken === undefined |
| 414 | 418 | ? html`${btn("Create an account", { |
| 415 | href: "/signup", | |
| 419 | href: `/signup${nextQuery}`, | |
| 416 | 420 | variant: "primary", |
| 417 | 421 | size: "lg", |
| 418 | 422 | })} |
| 419 | ${btn("Sign in", { href: "/signin", size: "lg" })}` | |
| 423 | ${btn("Sign in", { href: `/signin${nextQuery}`, size: "lg" })}` | |
| 420 | 424 | : btn("Sign in", { |
| 421 | href: edgeLoginUrl("/"), | |
| 425 | href: edgeLoginUrl(back), | |
| 422 | 426 | variant: "primary", |
| 423 | 427 | size: "lg", |
| 424 | 428 | })} |
test/home-feed.test.ts +40
| @@ -179,6 +179,46 @@ describe("home is the feed", () => { | ||
| 179 | 179 | expect(owners.map((i) => i.kind).sort()).toEqual(["look", "run"]); |
| 180 | 180 | }); |
| 181 | 181 | |
| 182 | test("a phone's share sheet opens the composer with the shared words, escaped, and sends nothing", async () => { | |
| 183 | const manifest = (await fetch(`${servers.url}/manifest.webmanifest`).then((r) => | |
| 184 | r.json(), | |
| 185 | )) as { share_target: { action: string; method: string; params: Record<string, string> } }; | |
| 186 | expect(manifest.share_target).toEqual({ | |
| 187 | action: "/", | |
| 188 | method: "GET", | |
| 189 | params: { title: "title", text: "text", url: "url" }, | |
| 190 | }); | |
| 191 | const before = listIssues(app.db, garden.id, { state: "open" }).length; | |
| 192 | const q = new URLSearchParams({ | |
| 193 | title: "Rain gauges", | |
| 194 | text: 'Look at this </textarea><script>alert(1)</script>', | |
| 195 | url: "https://example.com/gauge", | |
| 196 | }); | |
| 197 | const res = await get(`/?${q}`, aliceCookie); | |
| 198 | const text = await res.text(); | |
| 199 | const box = /<textarea[^>]*name="text"[^>]*>([\s\S]*?)<\/textarea>/.exec(text); | |
| 200 | expect(box?.[1]).toContain("Rain gauges\nLook at this </textarea><script>"); | |
| 201 | expect(box?.[1]).toContain("https://example.com/gauge"); | |
| 202 | expect(text).not.toContain("<script>alert(1)"); | |
| 203 | expect(listIssues(app.db, garden.id, { state: "open" }).length).toBe(before); | |
| 204 | // A link the app put in the text as well is said once. | |
| 205 | const twice = await get( | |
| 206 | `/?${new URLSearchParams({ text: "see https://e.com/x", url: "https://e.com/x" })}`, | |
| 207 | aliceCookie, | |
| 208 | ).then((r) => r.text()); | |
| 209 | expect(twice.match(/https:\/\/e\.com\/x/g)?.length).toBe(1); | |
| 210 | }); | |
| 211 | ||
| 212 | test("a signed-out share keeps its words through sign-in and sign-up", async () => { | |
| 213 | const q = `?${new URLSearchParams({ text: "see this", url: "https://e.com/x" })}`; | |
| 214 | const page = await get(`/${q}`).then((r) => r.text()); | |
| 215 | const next = encodeURIComponent(`/${q}`).replaceAll("&", "&"); | |
| 216 | expect(page).toContain(`/signin?next=${next}`); | |
| 217 | expect(page).toContain(`/signup?next=${next}`); | |
| 218 | const bare = await get("/").then((r) => r.text()); | |
| 219 | expect(bare).toContain('href="/signin"'); | |
| 220 | }); | |
| 221 | ||
| 182 | 222 | test("the composer posts a tell without a script, to an app the person may read, and only then", async () => { |
| 183 | 223 | const before = listIssues(app.db, garden.id, { state: "open" }).length; |
| 184 | 224 | const posted = await fetch(`${servers.url}/tell`, { |
new above
offline
Fine. Add the README line for this share_target (how to use it, and that it needs the installed app).