@simon-says-build/demo-player 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +5 -1
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/DemoHost.js +73 -8
- package/src/api.js +5 -0
- package/src/core.js +54 -2
package/index.d.ts
CHANGED
|
@@ -30,7 +30,11 @@ export const PLAYER_VERSION: string;
|
|
|
30
30
|
/** The changes a demo's last card offers to approve or discuss: one per `job:<id>` feature. */
|
|
31
31
|
export function reviewChangesOf(demo: unknown): { jobId: string; title: string }[];
|
|
32
32
|
/** A release's demo (0.4.0): the session merge its review card releases, or null. */
|
|
33
|
-
export function releaseOf(demo: unknown): { jobId: string; label: string; note: string } | null;
|
|
33
|
+
export function releaseOf(demo: unknown): { jobId: string; label: string; note: string; reviews: ReleaseReviews } | null;
|
|
34
|
+
/** A release's review requests (0.11.0), read for the demo's person. */
|
|
35
|
+
export type ReleaseReviewRequest = { to: string; by: string; status: 'pending' | 'approved' | 'discussed' | 'withdrawn'; at?: string; note?: string; decisionNote?: string; revisedAt?: string; release?: boolean };
|
|
36
|
+
export type ReleaseReviews = { mode: 'off' | 'optional' | 'required'; requests: ReleaseReviewRequest[]; mine: ReleaseReviewRequest | null; waiting: string[]; candidates: string[]; canAsk: boolean; mustAsk: boolean; last: boolean };
|
|
37
|
+
export function releaseReviewsOf(demo: unknown): ReleaseReviews;
|
|
34
38
|
|
|
35
39
|
/**
|
|
36
40
|
* React Navigation (an app without expo-router): a path is the chain of route
|
package/index.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { DemoHost } from './src/DemoHost.js';
|
|
2
|
-
export { demoConfig, findTarget, handlerFor, phoneSteps, scalePoint, playDemo, currentDemo, onDemo, reviewChangesOf, releaseOf, PLAYER_VERSION } from './src/core.js';
|
|
2
|
+
export { demoConfig, findTarget, handlerFor, phoneSteps, scalePoint, playDemo, currentDemo, onDemo, reviewChangesOf, releaseOf, releaseReviewsOf, PLAYER_VERSION } from './src/core.js';
|
|
3
3
|
export { navigationRoute, navigationRouter, navigateArgs, pathOfState } from './src/navigation.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simon-says-build/demo-player",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "SimonSays mobile demo player: walks a preview's viewer through a job's change, inside the app. Renders nothing unless the bundle carries a demo.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
package/src/DemoHost.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
16
16
|
import { Linking, Pressable, ScrollView, StyleSheet, Text, TextInput, View, useWindowDimensions } from 'react-native';
|
|
17
17
|
import { pageName } from './page-name.js';
|
|
18
|
-
import { OVERLAY_ID, cardBox, currentDemo, demoConfig, findTarget, handlerFor, onDemo, phoneSteps, playDemo, releaseOf, reviewChangesOf, scalePoint, rescueNote, screenCandidates, stepTitle, targetOfKey } from './core.js';
|
|
18
|
+
import { OVERLAY_ID, cardBox, currentDemo, demoConfig, findTarget, handlerFor, introBody, introOf, onDemo, phoneSteps, playDemo, releaseOf, reviewChangesOf, scalePoint, rescueNote, screenCandidates, stepTitle, targetOfKey } from './core.js';
|
|
19
19
|
import { demoApi, tourOf } from './api.js';
|
|
20
20
|
import { signInFirst, openInApp } from './signin.js';
|
|
21
21
|
import { runEvent, fillStep } from './events.js';
|
|
@@ -76,6 +76,7 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
76
76
|
const [changes, setChanges] = useState([]); // what the last card reviews (reviewChangesOf)
|
|
77
77
|
const [release, setRelease] = useState(null); // a release's demo ends on releasing it (releaseOf)
|
|
78
78
|
const [reviewing, setReviewing] = useState(false);
|
|
79
|
+
const [intro, setIntro] = useState(null); // the first screen: who asked, what, signed in as (introOf)
|
|
79
80
|
const [signIn, setSignIn] = useState(null); // a `signin` step: null while it runs, else { ok, error }
|
|
80
81
|
const [event, setEvent] = useState(null); // an `event` step: null while it runs, else { ok, error }
|
|
81
82
|
const [vars, setVars] = useState({}); // per person: what their events set up ({walkId} in later steps)
|
|
@@ -86,7 +87,7 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
86
87
|
useEffect(() => {
|
|
87
88
|
let live = true;
|
|
88
89
|
api.load()
|
|
89
|
-
.then((d) => { if (live) { setSteps(phoneSteps(tourOf(d, { here: !!config.here })).concat()); setChanges(reviewChangesOf(d)); setRelease(releaseOf(d)); } })
|
|
90
|
+
.then((d) => { if (live) { setSteps(phoneSteps(tourOf(d, { here: !!config.here })).concat()); setChanges(reviewChangesOf(d)); setRelease(releaseOf(d)); setIntro(introOf(d)); } })
|
|
90
91
|
.catch((e) => { if (live) setError(e.message); });
|
|
91
92
|
return () => { live = false; };
|
|
92
93
|
}, [api]);
|
|
@@ -221,6 +222,12 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
221
222
|
const overlay = (() => {
|
|
222
223
|
if (error) return h(Card, { at: null, screen, insets, card, setCard, title: 'Demo', body: `The demo didn't load: ${error}` });
|
|
223
224
|
if (!steps) return null;
|
|
225
|
+
if (intro && !tucked && !done) {
|
|
226
|
+
return h(Card, {
|
|
227
|
+
at: null, screen, insets, card, setCard, title: 'Guided Review', body: introBody(intro),
|
|
228
|
+
actions: [{ label: 'Hide', onPress: () => setTucked(true) }, { label: 'Start the review', onPress: () => setIntro(null), primary: true }],
|
|
229
|
+
});
|
|
230
|
+
}
|
|
224
231
|
if (reviewing && !tucked) return h(Review, { api, changes, release, screen, insets, onBack: steps.length ? () => setReviewing(false) : null, onDone: finish, onHide: () => setTucked(true) });
|
|
225
232
|
if (flash) return h(View, { pointerEvents: 'none', style: [s.flash, { top: insets.top + screen.height * 0.4 }], testID: 'ssb-demo-flash' },
|
|
226
233
|
h(Text, { style: s.flashText, accessibilityLiveRegion: 'polite' }, flash));
|
|
@@ -294,11 +301,66 @@ function Review({ api, changes, release, screen, insets, onBack, onDone, onHide
|
|
|
294
301
|
api.discuss(id, text).then(() => set(id, { state: 'sent' })).catch((e) => set(id, { state: 'discussing', error: e.message }));
|
|
295
302
|
};
|
|
296
303
|
const left = release ? 0 : changes.filter((c) => !['approved', 'sent'].includes(rows[c.jobId]?.state)).length;
|
|
304
|
+
// REVIEW REQUESTS (0.11.0, docs/REVIEW-REQUESTS.md): asked to review it, the
|
|
305
|
+
// person answers here — Approve, or Discuss with a note, which holds the
|
|
306
|
+
// release until it is revised; otherwise they may approve and ask someone
|
|
307
|
+
// else first. `rq.state`: idle | picking | asking | discussing | sending | done.
|
|
308
|
+
const rv = release?.reviews || null;
|
|
309
|
+
const [rq, setRq] = useState({ state: 'idle', picked: [], note: '', error: null, said: '' });
|
|
310
|
+
const rqSet = (patch) => setRq((r) => ({ ...r, ...patch }));
|
|
311
|
+
const answer = (decision) => {
|
|
312
|
+
rqSet({ state: 'sending', error: null });
|
|
313
|
+
api.answerReview(decision, decision === 'discuss' ? rq.note.trim() : '')
|
|
314
|
+
.then((r) => rqSet({ state: 'done', said: decision === 'discuss' ? 'Sent — it waits for the revision and your approval.' : r?.released ? 'Approved — it is releasing now.' : r?.waiting ? `Approved. ${r.waiting}` : 'Approved.' }))
|
|
315
|
+
.catch((e) => rqSet({ state: decision === 'discuss' ? 'discussing' : 'idle', error: e.message }));
|
|
316
|
+
};
|
|
317
|
+
const askFor = () => {
|
|
318
|
+
if (!rq.picked.length) return;
|
|
319
|
+
rqSet({ state: 'sending', error: null });
|
|
320
|
+
api.approveAndAsk(rq.picked, rq.note.trim())
|
|
321
|
+
.then(() => rqSet({ state: 'done', said: `Approved — it goes live once ${rq.picked.join(', ')} ${rq.picked.length === 1 ? 'approves' : 'approve'}.` }))
|
|
322
|
+
.catch((e) => rqSet({ state: 'picking', error: e.message }));
|
|
323
|
+
};
|
|
324
|
+
const toggle = (e) => rqSet({ picked: rq.picked.includes(e) ? rq.picked.filter((x) => x !== e) : [...rq.picked, e] });
|
|
325
|
+
const reviewsBlock = !rv ? null : rq.state === 'done' ? h(Text, { style: s.changeState, testID: 'ssb-review-request-done' }, rq.said)
|
|
326
|
+
: rv.mine ? h(View, { style: s.change, testID: 'ssb-review-mine' },
|
|
327
|
+
h(Text, { style: s.changeTitle }, `${rv.mine.by} asked you to review this release.`),
|
|
328
|
+
rv.mine.note ? h(Text, { style: s.body }, `“${rv.mine.note}”`) : null,
|
|
329
|
+
rv.mine.status === 'discussed' && rv.mine.revisedAt ? h(Text, { style: s.body }, 'It was revised since you asked for changes.') : null,
|
|
330
|
+
rq.state === 'discussing' || (rq.state === 'sending' && rq.note) ? h(View, null,
|
|
331
|
+
h(TextInput, { style: s.input, value: rq.note, onChangeText: (note) => rqSet({ note }), placeholder: 'What should change before it goes live?', placeholderTextColor: '#8E8E93', multiline: true, autoFocus: true, accessibilityLabel: 'What should change before it goes live', testID: 'ssb-review-discuss-note' }),
|
|
332
|
+
h(View, { style: s.actions }, button('Cancel', () => rqSet({ state: 'idle', note: '' })), button(rq.state === 'sending' ? 'Sending…' : 'Send', rq.note.trim() && rq.state !== 'sending' ? () => answer('discuss') : () => {}, true))) : null)
|
|
333
|
+
: rv.waiting.length ? h(Text, { style: s.body, testID: 'ssb-review-waiting' }, `Waiting on ${rv.waiting.join(', ')}'s review before it goes live.`)
|
|
334
|
+
: rq.state === 'picking' || (rq.state === 'sending' && rq.picked.length) ? h(View, { style: s.change, testID: 'ssb-review-ask' },
|
|
335
|
+
h(Text, { style: s.changeTitle }, 'Request a review from…'),
|
|
336
|
+
...rv.candidates.map((e) => h(Pressable, { key: e, onPress: () => toggle(e), accessibilityRole: 'checkbox', accessibilityState: { checked: rq.picked.includes(e) }, accessibilityLabel: e, style: s.pick },
|
|
337
|
+
h(Text, { style: s.body }, `${rq.picked.includes(e) ? '☑' : '☐'} ${e}`))),
|
|
338
|
+
h(TextInput, { style: s.input, value: rq.note, onChangeText: (note) => rqSet({ note }), placeholder: 'A note for them (optional)', placeholderTextColor: '#8E8E93', multiline: true, accessibilityLabel: 'A note for the reviewer' }),
|
|
339
|
+
h(View, { style: s.actions }, button('Cancel', () => rqSet({ state: 'idle', picked: [] })), button(rq.state === 'sending' ? 'Asking…' : 'Approve & ask', rq.picked.length && rq.state !== 'sending' ? askFor : () => {}, true)))
|
|
340
|
+
: rv.mustAsk ? h(Text, { style: s.body }, 'This project needs a review before every release — request one.') : null;
|
|
341
|
+
// What the foot's main button does on a release.
|
|
342
|
+
// What the foot's buttons are on a release, beside Hide (flat: one row).
|
|
343
|
+
const releaseMain = !release || rel.state === 'released' ? []
|
|
344
|
+
: rq.state === 'done' ? [button(left ? `Done (${left} undecided)` : 'Done', onDone, true)]
|
|
345
|
+
: rv?.mine ? (rq.state === 'discussing' ? [] : [
|
|
346
|
+
button('💬 Discuss', () => rqSet({ state: 'discussing' })),
|
|
347
|
+
button(rq.state === 'sending' ? 'Approving…' : rv.last ? release.label : 'Approve', rq.state === 'sending' ? () => {} : () => answer('approve'), true)])
|
|
348
|
+
: rv?.waiting.length ? [button('Done', onDone, true)]
|
|
349
|
+
: rq.state === 'picking' ? []
|
|
350
|
+
: [
|
|
351
|
+
rv?.canAsk ? button('Request a review', () => rqSet({ state: 'picking' }), !!rv?.mustAsk) : null,
|
|
352
|
+
rv?.mustAsk ? null : button(rel.state === 'releasing' ? 'Approving…' : release.label, rel.state === 'releasing' ? () => {} : doRelease, true),
|
|
353
|
+
].filter(Boolean);
|
|
354
|
+
// The card's lead line, for where the release stands.
|
|
355
|
+
const lead = !release ? 'Approve what looks right. Anything that should be different, say so — SSB revises it.'
|
|
356
|
+
: rv?.mine ? 'Approve it, or say what should change — the release waits for your answer.'
|
|
357
|
+
: rv?.waiting.length ? 'It goes out once the review is in. Anything that should be different, say so — SSB revises it.'
|
|
358
|
+
: rv?.mustAsk ? 'This project needs a review before it goes out. Anything that should be different, say so — SSB revises it.'
|
|
359
|
+
: `Happy with it? ${release.label} sends them out together. Anything that should be different, say so — SSB revises it.`;
|
|
297
360
|
return h(View, { style: [s.card, s.review, { top: insets.top + 12, maxHeight: screen.height - insets.top - insets.bottom - 24, maxWidth: screen.width - 32, left: Math.max(16, (screen.width - Math.min(360, screen.width - 32)) / 2) }], testID: 'ssb-demo-review' },
|
|
298
361
|
h(Text, { style: s.title }, release ? (changes.length === 1 ? 'The next release: 1 change' : `The next release: ${changes.length} changes`)
|
|
299
362
|
: changes.length === 1 ? 'Review the change' : `Review ${changes.length} changes`),
|
|
300
|
-
h(Text, { style: s.body },
|
|
301
|
-
: 'Approve what looks right. Anything that should be different, say so — SSB revises it.'),
|
|
363
|
+
h(Text, { style: s.body }, lead),
|
|
302
364
|
h(ScrollView, { style: s.changes, keyboardShouldPersistTaps: 'handled' }, ...changes.map((c) => {
|
|
303
365
|
const r = rows[c.jobId] || {};
|
|
304
366
|
const state = r.state || 'idle';
|
|
@@ -311,18 +373,20 @@ function Review({ api, changes, release, screen, insets, onBack, onDone, onHide
|
|
|
311
373
|
h(View, { style: s.actions },
|
|
312
374
|
button('Cancel', () => set(c.jobId, { state: 'idle', error: null })),
|
|
313
375
|
button('Send', () => send(c.jobId), true))) : null,
|
|
314
|
-
state === 'idle' ? h(View, { style: s.actions },
|
|
376
|
+
state === 'idle' && !rv?.mine ? h(View, { style: s.actions },
|
|
315
377
|
button('💬 Discuss', () => set(c.jobId, { state: 'discussing' })),
|
|
316
378
|
release ? null : button('✓ Approve', () => approve(c.jobId), true)) : null);
|
|
317
379
|
})),
|
|
380
|
+
reviewsBlock,
|
|
381
|
+
rq.error ? h(Text, { style: s.error }, rq.error) : null,
|
|
318
382
|
release && rel.state === 'released' ? h(Text, { style: s.changeState, testID: 'ssb-release-done' }, 'Approved — it goes out in 5 minutes. Undo it on the release page in SimonSays if you need to.') : null,
|
|
319
383
|
release && rel.error ? h(Text, { style: s.error }, rel.error) : null,
|
|
320
384
|
h(View, { style: [s.actions, s.reviewFoot] },
|
|
321
385
|
button('Hide', onHide),
|
|
322
386
|
onBack ? button('Back', onBack) : null,
|
|
323
|
-
release && rel.state !== 'released'
|
|
324
|
-
?
|
|
325
|
-
: button(left ? `Done (${left} undecided)` : 'Done', onDone, !left)));
|
|
387
|
+
...(release && rel.state !== 'released'
|
|
388
|
+
? releaseMain
|
|
389
|
+
: [button(left ? `Done (${left} undecided)` : 'Done', onDone, !left)])));
|
|
326
390
|
}
|
|
327
391
|
|
|
328
392
|
const button = (label, onPress, primary = false) => h(Pressable, {
|
|
@@ -366,6 +430,7 @@ const s = StyleSheet.create({
|
|
|
366
430
|
review: { width: 360 },
|
|
367
431
|
changes: { flexGrow: 0 },
|
|
368
432
|
reviewFoot: { marginTop: 16 },
|
|
433
|
+
pick: { flexDirection: 'row', paddingVertical: 6 },
|
|
369
434
|
change: { marginTop: 14, paddingTop: 12, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: '#3A3A3C' },
|
|
370
435
|
changeTitle: { color: '#FFFFFF', fontSize: 15, fontWeight: '600' },
|
|
371
436
|
changeState: { color: '#AEAEB2', fontSize: 13, marginTop: 4 },
|
package/src/api.js
CHANGED
|
@@ -23,6 +23,11 @@ export function demoApi({ api, id, token }, fetchImpl = globalThis.fetch) {
|
|
|
23
23
|
// A release's demo (0.4.0): approve and release the session, as the person
|
|
24
24
|
// the demo was made for, after the five-minute cool-off.
|
|
25
25
|
release: () => call('/release', { method: 'POST', body: '{}' }),
|
|
26
|
+
// REVIEW REQUESTS (0.11.0, docs/REVIEW-REQUESTS.md): approve and ask others
|
|
27
|
+
// to review it first (the last approval releases it); or, asked yourself,
|
|
28
|
+
// answer — approve, or discuss with a note, which holds it until revised.
|
|
29
|
+
approveAndAsk: (to, note) => call('/approve', { method: 'POST', body: JSON.stringify({ requestReview: { to, ...(note ? { note } : {}), release: true } }) }),
|
|
30
|
+
answerReview: (decision, note) => call('/review-requests/decide', { method: 'POST', body: JSON.stringify({ decision, ...(note ? { note } : {}) }) }),
|
|
26
31
|
// A phone's private copy (0.6.0): the link that signs this person in.
|
|
27
32
|
signin: (actorIndex) => call('/signin', { method: 'POST', body: JSON.stringify({ actorIndex }) }),
|
|
28
33
|
// A planned review (0.8.0): run a helper on the private copy, then read how it went.
|
package/src/core.js
CHANGED
|
@@ -10,7 +10,7 @@ import { bubbleBox } from './step-place.js';
|
|
|
10
10
|
// What this player is, said when the app registers for "Ready to see"
|
|
11
11
|
// (packages/ssb-overlay sends it; functions/in-app-notify.mjs clientOf). Kept
|
|
12
12
|
// equal to package.json by test/core.test.mjs.
|
|
13
|
-
export const PLAYER_VERSION = '0.
|
|
13
|
+
export const PLAYER_VERSION = '0.11.0';
|
|
14
14
|
|
|
15
15
|
export const HOST = 5;
|
|
16
16
|
export const TEXT = 6;
|
|
@@ -217,5 +217,57 @@ export function reviewChangesOf(demo) {
|
|
|
217
217
|
export function releaseOf(demo) {
|
|
218
218
|
const sm = demo?.sessionMerge;
|
|
219
219
|
if (!sm || typeof sm.jobId !== 'string' || !sm.jobId) return null;
|
|
220
|
-
return { jobId: sm.jobId, label: String(sm.label || 'Approve & release'), note: String(sm.note || '') };
|
|
220
|
+
return { jobId: sm.jobId, label: String(sm.label || 'Approve & release'), note: String(sm.note || ''), reviews: releaseReviewsOf(demo) };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// REVIEW REQUESTS ON A RELEASE (0.11.0, docs/REVIEW-REQUESTS.md): the demo's
|
|
224
|
+
// `sessionMerge.reviews` (functions/review-requests.mjs releaseReviewsForDemo),
|
|
225
|
+
// read for the person the demo was made for (`createdBy`). Pure.
|
|
226
|
+
// mine the request addressed to them that is still theirs to answer, or null
|
|
227
|
+
// waiting others whose review it still waits on
|
|
228
|
+
// canAsk they may request a review (the setting is not off, someone to ask)
|
|
229
|
+
// mustAsk the project requires a review and none is approved: no release without one
|
|
230
|
+
// last their approval would be the last one, and a release was asked for
|
|
231
|
+
export function releaseReviewsOf(demo) {
|
|
232
|
+
const rv = demo?.sessionMerge?.reviews;
|
|
233
|
+
const me = String(demo?.createdBy || '').trim().toLowerCase();
|
|
234
|
+
const requests = Array.isArray(rv?.requests) ? rv.requests.filter((r) => r && typeof r.to === 'string') : [];
|
|
235
|
+
const open = requests.filter((r) => r.status === 'pending' || r.status === 'discussed');
|
|
236
|
+
const mine = open.find((r) => r.to === me) || null;
|
|
237
|
+
const waiting = open.filter((r) => r.to !== me).map((r) => r.to);
|
|
238
|
+
const candidates = (Array.isArray(rv?.candidates) ? rv.candidates : []).filter((e) => typeof e === 'string' && e !== me && !open.some((r) => r.to === e));
|
|
239
|
+
const mode = rv?.mode === 'off' || rv?.mode === 'required' ? rv.mode : 'optional';
|
|
240
|
+
return {
|
|
241
|
+
mode, requests, mine, waiting, candidates,
|
|
242
|
+
canAsk: mode !== 'off' && candidates.length > 0,
|
|
243
|
+
mustAsk: mode === 'required' && !requests.some((r) => r.status === 'approved') && !open.length,
|
|
244
|
+
last: !!mine && waiting.length === 0 && requests.some((r) => r.release),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// THE FIRST SCREEN OF A REVIEW (0.10.0, Michael 2026-11-03): a very brief
|
|
249
|
+
// outline before anything plays — who asked for it, what they asked for, and
|
|
250
|
+
// who the review signs in as — then a button to start. Pure.
|
|
251
|
+
// → { requestedBy: [string], asks: [{ title, asked }], as: [string] } | null
|
|
252
|
+
const firstSentence = (t) => { const x = String(t || '').replace(/\s+/g, ' ').trim(); const m = /^(.{20,160}?[.!?])(\s|$)/.exec(x); return (m ? m[1] : x.slice(0, 140) + (x.length > 140 ? '…' : '')); };
|
|
253
|
+
export function introOf(demo) {
|
|
254
|
+
const asks = [], by = new Set(), as = [];
|
|
255
|
+
for (const a of demo?.actors || []) {
|
|
256
|
+
if (a?.label && !as.includes(a.label)) as.push(String(a.label));
|
|
257
|
+
for (const sec of a.sections || []) for (const f of sec.features || []) {
|
|
258
|
+
if (f?.requestedBy) by.add(String(f.requestedBy).trim());
|
|
259
|
+
if (!asks.some((x) => x.title === f.title)) asks.push({ title: String(f.title || 'This change'), ...(f.tryIt?.steps ? { asked: firstSentence(f.tryIt.steps) } : {}) });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return asks.length || as.length ? { requestedBy: [...by], asks, as } : null;
|
|
263
|
+
}
|
|
264
|
+
export function introBody(intro) {
|
|
265
|
+
if (!intro) return '';
|
|
266
|
+
const lines = [];
|
|
267
|
+
if (intro.requestedBy.length) lines.push(`Requested by ${intro.requestedBy.join(', ')}`);
|
|
268
|
+
const shown = intro.asks.slice(0, 3);
|
|
269
|
+
for (const a of shown) lines.push(`• ${a.title}`);
|
|
270
|
+
if (intro.asks.length > shown.length) lines.push(`…and ${intro.asks.length - shown.length} more`);
|
|
271
|
+
if (intro.as.length) lines.push(`Signed in as ${intro.as.join(', then ')}`);
|
|
272
|
+
return lines.join('\n');
|
|
221
273
|
}
|