Public: anyone can read this; the crew can change it.
A phone share sheet reaches the crew in one tap: the share target is tested from a shared URL to a queued thought, and a signed-out sharer is brought back to it #10417
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10390-a-phone-share-sheet-reaches-the-crew-in → main 5 commits fork 5 threads · 5 unresolved
8 files
changed +290
−39
scripts/unreached-allow.json +1
| @@ -15,6 +15,7 @@ | ||
| 15 | 15 | "src/flow/review_rounds.ts", |
| 16 | 16 | "src/flow/shared_red.ts", |
| 17 | 17 | "src/flow/stale_fold.ts", |
| 18 | "src/kernel/why_now.ts", | |
| 18 | 19 | "src/spawn/pod.ts", |
| 19 | 20 | "src/tui/gallery.ts", |
| 20 | 21 | "src/tui/ui/qr.ts", |
src/web/standards.ts +3 −6
| @@ -37,6 +37,7 @@ import { canRead, getRepo, type Repo } from "../core/repos.ts"; | ||
| 37 | 37 | import { publicForgeRepo } from "../core/forge_repo.ts"; |
| 38 | 38 | import type { Ctx } from "../http/context.ts"; |
| 39 | 39 | import { notFound } from "../http/respond.ts"; |
| 40 | import { SHARE_TARGET } from "./share_target.ts"; | |
| 40 | 41 | import { SITE_LINE } from "./site_line.ts"; |
| 41 | 42 | import { mcpToolsSection } from "./llms_tools.ts"; |
| 42 | 43 | import { HARNESS_PLUGINS, mcpCard, mcpPointers, offeredPluginRepo, pluginSource } from "../core/mcp_card.ts"; |
| @@ -254,12 +255,8 @@ export function webManifest(ctx: Ctx): Response { | ||
| 254 | 255 | id: "/", |
| 255 | 256 | display: "standalone", |
| 256 | 257 | // Sharing a link or text to the installed forge opens the home's |
| 257 | // composer with it filled in (`sharedDraft`, studio/feed.ts). | |
| 258 | share_target: { | |
| 259 | action: "/", | |
| 260 | method: "GET", | |
| 261 | params: { title: "title", text: "text", url: "url" }, | |
| 262 | }, | |
| 258 | // composer with it filled in (`sharedDraft`, share_target.ts). | |
| 259 | share_target: SHARE_TARGET, | |
| 263 | 260 | // A long-press on the installed icon: what is on the owner, or a |
| 264 | 261 | // word with the forge's agents, one tap from the home screen. |
| 265 | 262 | shortcuts: [ |
src/web/studio/actions.ts +34 −7
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * page that sent them, or JSON when the asks panel asked in place. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { COMPOSER_FIELDS, claimShare, releaseShare } from "../share_target.ts"; | |
| 11 | 12 | import { mayView } from "../../core/sealed.ts"; |
| 12 | 13 | import { holdPull, releasePull } from "../../actions/holds.ts"; |
| 13 | 14 | import { addComment } from "../../actions/issues.ts"; |
| @@ -29,6 +30,7 @@ import { teamHoldRefusal } from "../../core/teams_words.ts"; | ||
| 29 | 30 | import { guardedRefusal } from "../../crew/stranger.ts"; |
| 30 | 31 | import type { Guarded } from "../../core/pulls.ts"; |
| 31 | 32 | import { backTo, field, readForm, type Form } from "../form.ts"; |
| 33 | import { isLocalPath } from "../flash.ts"; | |
| 32 | 34 | import { html } from "../html.ts"; |
| 33 | 35 | import { flashOrJson, flashTo, page } from "../layout.ts"; |
| 34 | 36 | import { FORGE_TARGET } from "./feed.ts"; |
| @@ -58,8 +60,9 @@ async function tellFrom( | ||
| 58 | 60 | repo: Repo, |
| 59 | 61 | form: Form, |
| 60 | 62 | back: string, |
| 63 | onTold: () => void = () => {}, | |
| 61 | 64 | ): Promise<Response> { |
| 62 | const text = field(form, "text").trim(); | |
| 65 | const text = field(form, COMPOSER_FIELDS.text).trim(); | |
| 63 | 66 | // The page the tell was said on; a path that is not ours is no `from`, |
| 64 | 67 | // and so is never somewhere to send the person back to. |
| 65 | 68 | const from = parseFrom(field(form, "from") || undefined) ?? null; |
| @@ -85,6 +88,7 @@ async function tellFrom( | ||
| 85 | 88 | ? "Verify your email first." |
| 86 | 89 | : NOT_TOLD_WORDS, |
| 87 | 90 | }); |
| 91 | onTold(); | |
| 88 | 92 | const { routed } = told.value; |
| 89 | 93 | // A client's "Ask for something" (named by the form, not guessed from `/`): |
| 90 | 94 | // they asked for something and want nothing more than to know it went. |
| @@ -177,14 +181,38 @@ export async function composerSubmit(ctx: Ctx): Promise<Response> { | ||
| 177 | 181 | if (!ctx.auth.user) return signInFirst(ctx); |
| 178 | 182 | const form = await readForm(ctx, "rw"); |
| 179 | 183 | if (form instanceof Response) return form; |
| 180 | const target = field(form, "app"); | |
| 184 | const target = field(form, COMPOSER_FIELDS.app); | |
outdated
The token is claimed before the text and app are validated and before the tell exists. If the post then fails, the retry is told 'Already sent.' and nothing is queued. Claim after the tell is created, and release on failure. |
||
| 181 | 185 | // The feed's composer asks to come back to the feed; any other box, to home. |
| 182 | const home = backTo(form, "/"); | |
| 186 | const asked = field(form, COMPOSER_FIELDS.back); | |
| 187 | const home = isLocalPath(asked) ? asked : "/"; | |
| 188 | // A shared draft carries a token: the same share sent twice tells once. No | |
| 189 | // token is an ordinary tell; a token we did not issue is refused. The token is | |
| 190 | // held while the tell is made (so two at once cannot both go through) and | |
| 191 | // given back unless the tell was made (so a failed post can be retried). | |
| 192 | const once = field(form, COMPOSER_FIELDS.once); | |
| 193 | const userId = ctx.auth.user.id; | |
| 194 | if (once !== "") { | |
| 195 | const claim = claimShare(userId, once); | |
| 196 | if (claim === "bad") return flashOrJson(ctx, home, { tone: "danger", message: "That share could not be sent." }, { href: home }); | |
| 197 | if (claim === "seen") return flashOrJson(ctx, home, { message: "Already sent." }, { href: home }); | |
| 198 | } | |
| 199 | let told = false; | |
| 200 | try { | |
| 201 | const res = await composerTell(ctx, form, target, home, () => (told = true)); | |
| 202 | return res; | |
| 203 | } finally { | |
| 204 | if (once !== "" && !told) releaseShare(userId, once); | |
| 205 | } | |
| 206 | } | |
| 207 | ||
| 208 | async function composerTell(ctx: Ctx, form: Form, target: string, home: string, onTold: () => void): Promise<Response> { | |
| 209 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 183 | 210 | // "the forge" is no app: the words are kept as a thought, exactly as typed. |
| 184 | 211 | if (target === FORGE_TARGET) { |
| 185 | if (field(form, "text").trim() === "") | |
| 212 | if (field(form, COMPOSER_FIELDS.text).trim() === "") | |
| 186 | 213 | return flashOrJson(ctx, home, { tone: "danger", message: SAY_SOMETHING_WORDS }); |
| 187 | const kept = keepThought(ctx.app, ctx.auth.user, field(form, "text"), "composer"); | |
| 214 | const kept = keepThought(ctx.app, ctx.auth.user, field(form, COMPOSER_FIELDS.text), "composer"); | |
| 215 | if (kept.ok) onTold(); | |
| 188 | 216 | return flashOrJson( |
| 189 | 217 | ctx, |
| 190 | 218 | home, |
| @@ -201,10 +229,9 @@ export async function composerSubmit(ctx: Ctx): Promise<Response> { | ||
| 201 | 229 | const [owner = "", name = "", ...extra] = target.split("/"); |
| 202 | 230 | const repo = extra.length === 0 ? getRepo(ctx.app.db, owner, name) : undefined; |
| 203 | 231 | if (!repo || !canRead(ctx.app.db, repo, ctx.auth.user)) return notFoundPage(ctx); |
| 204 | return tellFrom(ctx, repo, form, home); | |
| 232 | return tellFrom(ctx, repo, form, home, onTold); | |
| 205 | 233 | } |
| 206 | 234 | |
| 207 | ||
| 208 | 235 | /** POST /:owner/:name/pulls/:number/ship — land a green change. */ |
| 209 | 236 | export async function shipSubmit(ctx: Ctx): Promise<Response> { |
| 210 | 237 | const repo = loadRepo(ctx); |
src/web/studio/feed.ts +7 −22
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * Every string on a card is plain text and is escaped here, once. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { COMPOSER_FIELDS, TELL_MAX, sharedDraft, shareToken } from "../share_target.ts"; | |
| 11 | 12 | import { chipCounts } from "../../feed/chips.ts"; |
| 12 | 13 | import { chipsOf, KIND_CHIPS, type KindChip } from "../../core/feed_chips.ts"; |
| 13 | 14 | import { marksByName, type Mark } from "../../core/marks.ts"; |
| @@ -223,24 +224,6 @@ export function replyUnder(placement: Placement): Html { | ||
| 223 | 224 | return html`<p class="small break" data-placement="${placement.kind}"><span class="muted">Planner · ${placement.kind}</span> ${parts}</p>`; |
| 224 | 225 | } |
| 225 | 226 | |
outdated
|
||
| 226 | /** The longest tell the composer takes (its textarea's `maxlength`). */ | |
| 227 | const TELL_MAX = 2000; | |
| 228 | ||
| 229 | /** | |
| 230 | * What a phone's share sheet handed the composer (`share_target` in the web | |
| 231 | * manifest, standards.ts): the title, text and link it sent as query | |
| 232 | * parameters, as one draft. A page's title often repeats in the text, and | |
| 233 | * some apps put the link in the text as well, so nothing is said twice. It | |
| 234 | * is only ever a draft — nothing is sent until the person taps Post. | |
| 235 | */ | |
| 236 | export function sharedDraft(params: URLSearchParams): string { | |
| 237 | const parts: string[] = []; | |
| 238 | for (const key of ["title", "text", "url"]) { | |
| 239 | const part = (params.get(key) ?? "").trim(); | |
| 240 | if (part !== "" && !parts.some((p) => p.includes(part))) parts.push(part); | |
| 241 | } | |
| 242 | return parts.join("\n").slice(0, TELL_MAX); | |
| 243 | } | |
| 244 | 227 | |
| 245 | 228 | /** |
| 246 | 229 | * The composer: say something to an app's crew, which opens a tell — the |
| @@ -259,13 +242,15 @@ export function composer(ctx: Ctx, user: User): Html { | ||
| 259 | 242 | action: html`${btn("New app", { href: "/apps/new", variant: "primary" })} |
| 260 | 243 | ${btn("Repositories", { href: `/${user.username}` })}`, |
| 261 | 244 | })}`; |
| 245 | const shared = sharedDraft(ctx.url.searchParams); | |
| 262 | 246 | const form = html`<form id="composer" class="stack" method="post" action="/tell"> |
| 263 | <input type="hidden" name="back" value="/feed" /> | |
| 264 | <textarea class="input" name="text" rows="2" maxlength="2000" required placeholder="Tell the crew what should change" aria-label="Tell the crew" data-draft="${user.id}">${sharedDraft(ctx.url.searchParams)}</textarea> | |
| 247 | <input type="hidden" name="${COMPOSER_FIELDS.back}" value="/feed" /> | |
| 248 | ${shared === "" ? "" : html`<input type="hidden" name="${COMPOSER_FIELDS.once}" value="${shareToken(user.id, shared)}" />`} | |
| 249 | <textarea class="input" name="${COMPOSER_FIELDS.text}" rows="2" maxlength="${String(TELL_MAX)}" required placeholder="Tell the crew what should change" aria-label="Tell the crew" data-draft="${user.id}">${shared}</textarea> | |
| 265 | 250 | <div class="bar"> |
| 266 | 251 | ${repos.length === 0 |
| 267 | ? html`<input type="hidden" name="app" value="${FORGE_TARGET}" /><span class="muted small">the forge</span>` | |
| 268 | : html`<select name="app" class="input" aria-label="Send to"> | |
| 252 | ? html`<input type="hidden" name="${COMPOSER_FIELDS.app}" value="${FORGE_TARGET}" /><span class="muted small">the forge</span>` | |
| 253 | : html`<select name="${COMPOSER_FIELDS.app}" class="input" aria-label="Send to"> | |
| 269 | 254 | ${repos.map((r) => html`<option value="${slug(r)}">${slug(r)}</option>`)} |
| 270 | 255 | <option value="${FORGE_TARGET}">the forge</option> |
| 271 | 256 | </select>`} |
src/web/studio/living.ts +7 −4
| @@ -19,7 +19,8 @@ import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.t | ||
| 19 | 19 | import type { NowStage } from "../../flow/now_rows.ts"; |
| 20 | 20 | import { nowHref } from "../pages/now.ts"; |
| 21 | 21 | import { writableAppsOf } from "./asks.ts"; |
| 22 | import { FORGE_TARGET, sharedDraft } from "./feed.ts"; | |
| 22 | import { FORGE_TARGET } from "./feed.ts"; | |
| 23 | import { COMPOSER_FIELDS, TELL_MAX, sharedDraft, shareToken } from "../share_target.ts"; | |
| 23 | 24 | import { repoBase } from "../chrome.ts"; |
| 24 | 25 | |
| 25 | 26 | /** The events that move a build or a shipment. */ |
| @@ -106,11 +107,13 @@ export function liveMovingFragment(ctx: Ctx): Response { | ||
| 106 | 107 | export function tellBox(ctx: Ctx, user: User): Html { |
| 107 | 108 | const { repos } = writableAppsOf(ctx, user); |
| 108 | 109 | const slug = (r: { ownerName: string; name: string }) => `${r.ownerName}/${r.name}`; |
| 110 | const shared = sharedDraft(ctx.url.searchParams); | |
| 109 | 111 | return html`<form id="tell-box" class="bar tell-box" method="post" action="/tell"> |
| 110 | <input class="input" name="text" maxlength="2000" required placeholder="Tell the crew…" aria-label="Tell the crew" data-draft="${user.id}" value="${sharedDraft(ctx.url.searchParams).replace(/\s+/g, " ")}" /> | |
| 112 | ${shared === "" ? "" : html`<input type="hidden" name="${COMPOSER_FIELDS.once}" value="${shareToken(user.id, shared)}" />`} | |
| 113 | <input class="input" name="${COMPOSER_FIELDS.text}" maxlength="${String(TELL_MAX)}" required placeholder="Tell the crew…" aria-label="Tell the crew" data-draft="${user.id}" value="${shared.replace(/\s+/g, " ")}" /> | |
| 111 | 114 | ${repos.length === 0 |
| 112 | ? html`<input type="hidden" name="app" value="${FORGE_TARGET}" />` | |
| 113 | : html`<select name="app" class="input" aria-label="Send to"> | |
| 115 | ? html`<input type="hidden" name="${COMPOSER_FIELDS.app}" value="${FORGE_TARGET}" />` | |
| 116 | : html`<select name="${COMPOSER_FIELDS.app}" class="input" aria-label="Send to"> | |
| 114 | 117 | ${repos.map((r) => html`<option value="${slug(r)}">${slug(r)}</option>`)} |
| 115 | 118 | <option value="${FORGE_TARGET}">the forge</option> |
| 116 | 119 | </select>`} |
backis declared here, but feed.ts still writes name="back" literally and actions reads it by string, so the field names are not defined in one place (box 4). Use COMPOSER_FIELDS.back in both.