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

Merged opened this

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

Merged

scout/forge:scout/1058-tell-from-a-phone-s-share-sheet-the-forg → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/1058-tell-from-a-phone-s-share-sheet-the-forg was merged into main as f5d3b5b.

Review

Sign in to review this change.
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
386386 droplets and forgotten DNS records need metrics or resolution and are not
387387 attempted; Backblaze audits find nothing yet. No dependency was added.
388388
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
389397 ## Develop
390398
391399 ```sh
src/web/standards.ts +7
@@ -149,6 +149,13 @@ export function webManifest(ctx: Ctx): Response {
149149 scope: "/",
150150 id: "/",
151151 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: "/",

Fine. Add the README line for this share_target (how to use it, and that it needs the installed app).

156 method: "GET",
157 params: { title: "title", text: "text", url: "url" },
158 },
152159 background_color: "#fafafa",
153160 theme_color: "#fafafa",
154161 icons: [
src/web/studio/feed.ts +20 −1
@@ -92,6 +92,25 @@ export function feedCard(
9292 </article>`;
9393 }
9494
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
95114 /**
96115 * The composer: say something to an app's crew, which opens a tell — the
97116 * 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 {
112131 const slug = (r: { ownerName: string; name: string }) => `${r.ownerName}/${r.name}`;
113132 const first = slug(repos[0]!);
114133 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>
116135 <div class="composer-bar">
117136 ${repos.length === 1
118137 ? 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 {
403403 export function studioDoor(ctx: Ctx): Response {
404404 const front = frontPage(ctx.app);
405405 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)}`;
406410 const tiles = front.shelf.map(tile);
407411 const body = html`<section class="hero">
408412 <h1>Apps, live.</h1>
@@ -412,13 +416,13 @@ export function studioDoor(ctx: Ctx): Response {
412416 ? null

The edge-login branch now passes back but has no test; add one so the shared query is proven to survive that door too.

413417 : ctx.app.config.edgeToken === undefined
414418 ? html`${btn("Create an account", {
415 href: "/signup",
419 href: `/signup${nextQuery}`,
416420 variant: "primary",
417421 size: "lg",
418422 })}
419 ${btn("Sign in", { href: "/signin", size: "lg" })}`
423 ${btn("Sign in", { href: `/signin${nextQuery}`, size: "lg" })}`
420424 : btn("Sign in", {
421 href: edgeLoginUrl("/"),
425 href: edgeLoginUrl(back),
422426 variant: "primary",
423427 size: "lg",
424428 })}
test/home-feed.test.ts +40
@@ -179,6 +179,46 @@ describe("home is the feed", () => {
179179 expect(owners.map((i) => i.kind).sort()).toEqual(["look", "run"]);
180180 });
181181
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 &lt;/textarea&gt;&lt;script&gt;");
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("&", "&amp;");
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
182222 test("the composer posts a tell without a script, to an app the person may read, and only then", async () => {
183223 const before = listIssues(app.db, garden.id, { state: "open" }).length;
184224 const posted = await fetch(`${servers.url}/tell`, {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs