@supersuit/suggest-a-change 0.1.12 → 0.2.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/CHANGELOG.md +22 -1
- package/README.md +163 -8
- package/dist/client/adapters.d.ts +27 -0
- package/dist/client/context.d.ts +61 -0
- package/dist/client/context.js +279 -0
- package/dist/client/detail.d.ts +2 -2
- package/dist/client/detail.js +12 -8
- package/dist/client/entry.js +10 -5
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +1 -0
- package/dist/client/mine.js +4 -2
- package/dist/client/runtime.js +3 -0
- package/dist/client/seen.d.ts +12 -0
- package/dist/client/seen.js +30 -7
- package/dist/client/sheet.js +64 -4
- package/dist/client/words.d.ts +10 -1
- package/dist/client/words.js +11 -2
- package/dist/contract.d.mts +32 -22
- package/dist/contract.mjs +127 -37
- package/dist/github/api.d.ts +100 -0
- package/dist/github/api.js +164 -0
- package/dist/github/compose.d.ts +152 -0
- package/dist/github/compose.js +309 -0
- package/dist/github/filer.d.ts +72 -0
- package/dist/github/filer.js +89 -0
- package/dist/github/hosts.d.ts +41 -0
- package/dist/github/hosts.js +154 -0
- package/dist/github/index.d.ts +12 -0
- package/dist/github/index.js +9 -0
- package/dist/github/privacy.d.ts +34 -0
- package/dist/github/privacy.js +142 -0
- package/dist/github/relay.d.ts +159 -0
- package/dist/github/relay.js +734 -0
- package/dist/server/geo.d.ts +17 -0
- package/dist/server/geo.js +35 -0
- package/dist/server/handlers.d.ts +54 -3
- package/dist/server/handlers.js +143 -41
- package/dist/server/index.d.ts +3 -1
- package/dist/server/index.js +1 -0
- package/package.json +5 -1
- package/src/client/adapters.ts +18 -1
- package/src/client/context.ts +243 -0
- package/src/client/detail.tsx +13 -8
- package/src/client/entry.tsx +9 -5
- package/src/client/index.ts +2 -0
- package/src/client/mine.tsx +7 -3
- package/src/client/runtime.ts +3 -0
- package/src/client/seen.ts +27 -6
- package/src/client/sheet.tsx +78 -4
- package/src/client/styles.css +11 -2
- package/src/client/words.ts +15 -2
- package/src/contract.mjs +110 -37
- package/src/github/api.ts +169 -0
- package/src/github/compose.ts +354 -0
- package/src/github/filer.ts +96 -0
- package/src/github/hosts.ts +117 -0
- package/src/github/index.ts +15 -0
- package/src/github/privacy.ts +131 -0
- package/src/github/relay.ts +637 -0
- package/src/server/geo.ts +32 -0
- package/src/server/handlers.ts +158 -45
- package/src/server/index.ts +3 -1
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// EVERY PIECE OF CONTEXT THAT HELPS FIX IT, collected by the module with no app code. Each item says, in a line written
|
|
2
|
+
// for the person sending, why it is shared; the sheet lists them all, on by default, and any one can be left out.
|
|
3
|
+
//
|
|
4
|
+
// The trail (the last pages, taps and errors) is kept from the moment the module first mounts, in memory only, in
|
|
5
|
+
// small ring buffers. NEVER captured: what anyone typed (an input's value), cookies, storage, or a query string.
|
|
6
|
+
import type { Identity } from './adapters.ts'
|
|
7
|
+
|
|
8
|
+
export interface ContextItem { key: string; label: string; value: string; why: string }
|
|
9
|
+
export interface Trail { routes: { route: string; at: string }[]; taps: { label: string; at: string }[]; errors: { message: string; frame?: string; at: string }[] }
|
|
10
|
+
export interface ReportContext extends Trail { items: ContextItem[] }
|
|
11
|
+
/** What an app adds through the `context` adapter: anything app-specific, each with why it helps. */
|
|
12
|
+
export interface AppContextItem { label: string; value: string; why: string }
|
|
13
|
+
|
|
14
|
+
const MAX = { routes: 10, taps: 20, errors: 10, items: 40 }
|
|
15
|
+
// ONE trail per page, however many copies of the module a host bundles: the state lives on the global object under a
|
|
16
|
+
// registered symbol, so a second copy finds the first one's listeners installed and shares its trail.
|
|
17
|
+
type Shared = { trail: Trail; installedAt: number; undo: (() => void) | null }
|
|
18
|
+
const SHARED = Symbol.for('@supersuit/suggest-a-change/context')
|
|
19
|
+
const shared: Shared = ((globalThis as Record<symbol, unknown>)[SHARED] as Shared | undefined)
|
|
20
|
+
?? ((globalThis as Record<symbol, unknown>)[SHARED] = { trail: { routes: [], taps: [], errors: [] }, installedAt: 0, undo: null }) as Shared
|
|
21
|
+
const trail = shared.trail
|
|
22
|
+
|
|
23
|
+
const clip = (s: string, max: number) => (s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}…`)
|
|
24
|
+
const iso = () => new Date().toISOString()
|
|
25
|
+
const push = <T>(list: T[], x: T, max: number) => { list.push(x); if (list.length > max) list.splice(0, list.length - max) }
|
|
26
|
+
/** A URL or path with no query and no hash, wherever it appears in a piece of text. */
|
|
27
|
+
export const withoutQueries = (s: string) => s.replace(/([^\s?#'"()]*)[?#][^\s'"():]*/g, (_m, before: string) => before)
|
|
28
|
+
/** A path segment that is an id, a token or an address rather than a name: a uuid, a long number, a long hex or
|
|
29
|
+
* base64 run with letters and digits, anything over 24 characters, or anything with an @. */
|
|
30
|
+
const idLike = (raw: string) => {
|
|
31
|
+
let s = raw
|
|
32
|
+
try { s = decodeURIComponent(raw) } catch { /* as it is */ }
|
|
33
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s)
|
|
34
|
+
|| /^\d{4,}$/.test(s)
|
|
35
|
+
|| (/^[0-9a-f]{12,}$/i.test(s) && /\d/.test(s))
|
|
36
|
+
|| (/^[A-Za-z0-9_-]{16,}$/.test(s) && /\d/.test(s) && /[A-Za-z]/.test(s))
|
|
37
|
+
|| s.length > 24
|
|
38
|
+
|| s.includes('@')
|
|
39
|
+
}
|
|
40
|
+
const routeOf = (href: string) => {
|
|
41
|
+
let path: string
|
|
42
|
+
try { path = new URL(href, 'http://x').pathname } catch { path = String(href).split(/[?#]/)[0] }
|
|
43
|
+
return path.split('/').map((seg) => (seg && idLike(seg) ? ':id' : seg)).join('/')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function recordRoute() {
|
|
47
|
+
if (typeof location === 'undefined') return
|
|
48
|
+
const route = routeOf(location.href)
|
|
49
|
+
if (trail.routes.at(-1)?.route === route) return
|
|
50
|
+
push(trail.routes, { route: clip(route, 300), at: iso() }, MAX.routes)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** What a tapped thing is called: its label or its text, never what was typed into it. */
|
|
54
|
+
function labelOf(target: EventTarget | null): string | null {
|
|
55
|
+
if (!(target instanceof Element)) return null
|
|
56
|
+
// Anything someone types into is "a text field", by its own label at most and never its text: an editable region,
|
|
57
|
+
// a text, search or combo box, wherever inside it the tap landed.
|
|
58
|
+
const typing = target.closest('[contenteditable]:not([contenteditable="false"]), [role="textbox"], [role="searchbox"], [role="combobox"]')
|
|
59
|
+
if (typing) return clip(typing.getAttribute('aria-label')?.trim() || 'a text field', 60)
|
|
60
|
+
const el = target.closest('button, a, summary, label, select, textarea, input, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [aria-label]') ?? target
|
|
61
|
+
const aria = el.getAttribute('aria-label')?.trim()
|
|
62
|
+
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) {
|
|
63
|
+
if (aria) return clip(aria, 60)
|
|
64
|
+
const byId = el.id ? document.querySelector(`label[for="${CSS.escape(el.id)}"]`)?.textContent?.trim() : ''
|
|
65
|
+
if (byId) return clip(byId.replace(/\s+/g, ' '), 60)
|
|
66
|
+
if (el instanceof HTMLTextAreaElement) return 'a text box'
|
|
67
|
+
if (el instanceof HTMLSelectElement) return 'a menu'
|
|
68
|
+
const type = el.type
|
|
69
|
+
return type === 'checkbox' ? 'a checkbox' : type === 'radio' ? 'a choice' : type === 'button' || type === 'submit' ? clip(el.value ? 'a button' : 'a button', 60) : 'a text field'
|
|
70
|
+
}
|
|
71
|
+
if (aria) return clip(aria, 60)
|
|
72
|
+
const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim()
|
|
73
|
+
if (text) return clip(text, 60)
|
|
74
|
+
const title = el.getAttribute('title')?.trim()
|
|
75
|
+
return title ? clip(title, 60) : el.tagName.toLowerCase()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const firstFrame = (stack: unknown): string | undefined => {
|
|
79
|
+
if (typeof stack !== 'string') return undefined
|
|
80
|
+
const line = stack.split('\n').slice(1).map((l) => l.trim()).find((l) => /^at |@/.test(l))
|
|
81
|
+
return line ? clip(withoutQueries(line), 200) : undefined
|
|
82
|
+
}
|
|
83
|
+
const messageOf = (x: unknown): string => (x instanceof Error ? x.message : typeof x === 'string' ? x : x === undefined ? '' : typeof x === 'number' || typeof x === 'boolean' ? String(x) : '[object]')
|
|
84
|
+
function recordError(message: string, stack?: unknown) {
|
|
85
|
+
push(trail.errors, { message: clip(withoutQueries(message || 'An error with no message'), 300), ...(firstFrame(stack) ? { frame: firstFrame(stack) } : {}), at: iso() }, MAX.errors)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Starts keeping the trail. Idempotent: every screen of the module calls it on mount, and it installs once. */
|
|
89
|
+
export function installContextListeners(): void {
|
|
90
|
+
if (shared.undo || typeof window === 'undefined' || typeof document === 'undefined') return
|
|
91
|
+
shared.installedAt = Date.now()
|
|
92
|
+
// A wrapper a host put on top of ours may outlive a reset; once reset, ours only passes the call through.
|
|
93
|
+
let live = true
|
|
94
|
+
recordRoute()
|
|
95
|
+
const onClick = (e: Event) => { const l = labelOf(e.target); if (l) push(trail.taps, { label: l, at: iso() }, MAX.taps) }
|
|
96
|
+
const onError = (e: Event) => { const ev = e as ErrorEvent; recordError(ev.message || messageOf(ev.error), (ev.error as { stack?: unknown } | undefined)?.stack) }
|
|
97
|
+
const onRejection = (e: Event) => { const reason = (e as PromiseRejectionEvent).reason; recordError(`Unhandled rejection: ${messageOf(reason)}`, (reason as { stack?: unknown } | undefined)?.stack) }
|
|
98
|
+
const onNav = () => recordRoute()
|
|
99
|
+
document.addEventListener('click', onClick, true)
|
|
100
|
+
window.addEventListener('error', onError)
|
|
101
|
+
window.addEventListener('unhandledrejection', onRejection)
|
|
102
|
+
window.addEventListener('popstate', onNav)
|
|
103
|
+
window.addEventListener('hashchange', onNav)
|
|
104
|
+
const { pushState, replaceState } = history
|
|
105
|
+
const ourPush = function (this: History, ...a: Parameters<History['pushState']>) { const r = pushState.apply(this, a); if (live) recordRoute(); return r }
|
|
106
|
+
history.pushState = ourPush
|
|
107
|
+
const ourReplace = function (this: History, ...a: Parameters<History['replaceState']>) { const r = replaceState.apply(this, a); if (live) recordRoute(); return r }
|
|
108
|
+
history.replaceState = ourReplace
|
|
109
|
+
const consoleError = console.error
|
|
110
|
+
const ourError = function (this: Console, ...args: unknown[]) {
|
|
111
|
+
if (live) try { recordError(args.map(messageOf).filter(Boolean).join(' '), (args.find((a) => a instanceof Error) as Error | undefined)?.stack) } catch { /* the trail never breaks logging */ }
|
|
112
|
+
return consoleError.apply(this, args as [])
|
|
113
|
+
}
|
|
114
|
+
console.error = ourError
|
|
115
|
+
shared.undo = () => {
|
|
116
|
+
live = false
|
|
117
|
+
document.removeEventListener('click', onClick, true)
|
|
118
|
+
window.removeEventListener('error', onError)
|
|
119
|
+
window.removeEventListener('unhandledrejection', onRejection)
|
|
120
|
+
window.removeEventListener('popstate', onNav)
|
|
121
|
+
window.removeEventListener('hashchange', onNav)
|
|
122
|
+
// Put back only what is still ours: a wrapper the host added later stays, and calls through ours harmlessly.
|
|
123
|
+
if (history.pushState === ourPush) history.pushState = pushState
|
|
124
|
+
if (history.replaceState === ourReplace) history.replaceState = replaceState
|
|
125
|
+
if (console.error === ourError) console.error = consoleError
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Stops keeping the trail and forgets it (tests, and a host that signs someone out). */
|
|
130
|
+
export function resetContext(): void {
|
|
131
|
+
shared.undo?.(); shared.undo = null
|
|
132
|
+
trail.routes.length = 0; trail.taps.length = 0; trail.errors.length = 0
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A copy of the trail as it stands. */
|
|
136
|
+
export const contextTrail = (): Trail => ({ routes: trail.routes.map((r) => ({ ...r })), taps: trail.taps.map((t) => ({ ...t })), errors: trail.errors.map((e) => ({ ...e })) })
|
|
137
|
+
|
|
138
|
+
/** A user agent in plain words: "iPhone, iOS 18.1, Safari 18.1". */
|
|
139
|
+
export function parseUserAgent(ua: string): string {
|
|
140
|
+
const v = (re: RegExp) => re.exec(ua)?.[1]?.replace(/_/g, '.')
|
|
141
|
+
let device = 'Unknown device'; let os = ''
|
|
142
|
+
if (/iPhone/.test(ua)) { device = 'iPhone'; os = `iOS ${v(/OS (\d+[_.]\d+)/) ?? ''}`.trim() }
|
|
143
|
+
else if (/iPad/.test(ua)) { device = 'iPad'; os = `iPadOS ${v(/OS (\d+[_.]\d+)/) ?? ''}`.trim() }
|
|
144
|
+
else if (/Android/.test(ua)) { device = /Mobile/.test(ua) ? 'Android phone' : 'Android tablet'; os = `Android ${v(/Android (\d+(?:\.\d+)?)/) ?? ''}`.trim() }
|
|
145
|
+
else if (/Macintosh|Mac OS X/.test(ua)) { device = 'Mac'; os = 'macOS' }
|
|
146
|
+
else if (/Windows/.test(ua)) { device = 'Windows PC'; os = 'Windows' }
|
|
147
|
+
else if (/CrOS/.test(ua)) { device = 'Chromebook'; os = 'ChromeOS' }
|
|
148
|
+
else if (/Linux/.test(ua)) { device = 'Linux computer'; os = 'Linux' }
|
|
149
|
+
const browser =
|
|
150
|
+
v(/Edg(?:A|iOS)?\/(\d+)/) ? `Edge ${v(/Edg(?:A|iOS)?\/(\d+)/)}`
|
|
151
|
+
: v(/(?:Firefox|FxiOS)\/(\d+)/) ? `Firefox ${v(/(?:Firefox|FxiOS)\/(\d+)/)}`
|
|
152
|
+
: v(/(?:Chrome|CriOS)\/(\d+)/) ? `Chrome ${v(/(?:Chrome|CriOS)\/(\d+)/)}`
|
|
153
|
+
: /Safari/.test(ua) && v(/Version\/(\d+(?:\.\d+)?)/) ? `Safari ${v(/Version\/(\d+(?:\.\d+)?)/)}`
|
|
154
|
+
: ''
|
|
155
|
+
return [device, os, browser].filter(Boolean).join(', ')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The device in plain words from User-Agent Client Hints when the browser has them, else from the user agent. */
|
|
159
|
+
async function deviceWords(given?: string): Promise<string> {
|
|
160
|
+
if (given) return parseUserAgent(given)
|
|
161
|
+
const nav = typeof navigator === 'undefined' ? undefined : (navigator as Navigator & { userAgentData?: { getHighEntropyValues?(h: string[]): Promise<Record<string, unknown>> } })
|
|
162
|
+
const uad = nav?.userAgentData
|
|
163
|
+
if (uad?.getHighEntropyValues) {
|
|
164
|
+
try {
|
|
165
|
+
const h = await Promise.race([uad.getHighEntropyValues(['platform', 'platformVersion', 'model', 'fullVersionList', 'mobile']), new Promise<null>((r) => setTimeout(() => r(null), 300))])
|
|
166
|
+
if (h) {
|
|
167
|
+
const brands = (h.fullVersionList as { brand: string; version: string }[] | undefined)?.filter((b) => !/Not.?A.?Brand|Chromium/i.test(b.brand)) ?? []
|
|
168
|
+
const b = brands.find((x) => /Edge/.test(x.brand)) ?? brands[0]
|
|
169
|
+
const platform = String(h.platform ?? '')
|
|
170
|
+
const device = String(h.model ?? '') || (platform === 'macOS' ? 'Mac' : platform === 'Windows' ? 'Windows PC' : platform === 'Android' ? (h.mobile ? 'Android phone' : 'Android tablet') : platform || 'Unknown device')
|
|
171
|
+
const os = [platform, String(h.platformVersion ?? '').split('.').slice(0, 2).join('.')].filter(Boolean).join(' ')
|
|
172
|
+
return [device, os, b ? `${b.brand.replace('Google ', '').replace('Microsoft ', '')} ${b.version.split('.')[0]}` : ''].filter(Boolean).join(', ')
|
|
173
|
+
}
|
|
174
|
+
} catch { /* fall back to the user agent */ }
|
|
175
|
+
}
|
|
176
|
+
return parseUserAgent(nav?.userAgent ?? '')
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const two = (n: number) => String(n).padStart(2, '0')
|
|
180
|
+
function timeWords(d: Date): string {
|
|
181
|
+
const zone = (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone } catch { return '' } })()
|
|
182
|
+
const off = -d.getTimezoneOffset()
|
|
183
|
+
const offset = `UTC${off >= 0 ? '+' : '-'}${two(Math.floor(Math.abs(off) / 60))}:${two(Math.abs(off) % 60)}`
|
|
184
|
+
return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())} ${two(d.getHours())}:${two(d.getMinutes())} (${[zone, offset].filter(Boolean).join(', ')})`
|
|
185
|
+
}
|
|
186
|
+
const media = (q: string) => { try { return typeof matchMedia === 'function' && matchMedia(q).matches } catch { return false } }
|
|
187
|
+
|
|
188
|
+
export interface CollectOptions {
|
|
189
|
+
identity?: Identity | null
|
|
190
|
+
where?: { screen?: string; route?: string }
|
|
191
|
+
about?: { app?: string; version?: string }
|
|
192
|
+
/** The app's own items (the `context` adapter). */
|
|
193
|
+
extra?: () => Promise<AppContextItem[]>
|
|
194
|
+
/** Ask the browser for a precise location (shows its permission prompt). Off unless the app opts in. */
|
|
195
|
+
precise?: boolean
|
|
196
|
+
/** A user agent to read instead of the browser's (tests). */
|
|
197
|
+
userAgent?: string
|
|
198
|
+
now?: () => number
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Everything the module knows that would help fix it, each item with why it is shared, and the trail. */
|
|
202
|
+
export async function collectContext(o: CollectOptions = {}): Promise<ReportContext> {
|
|
203
|
+
const now = new Date(o.now?.() ?? Date.now())
|
|
204
|
+
const items: ContextItem[] = []
|
|
205
|
+
const add = (key: string, label: string, value: string | undefined | null, why: string) => { if (value) items.push({ key, label: clip(label, 80), value: clip(value, 500), why: clip(why, 200) }) }
|
|
206
|
+
const who = o.identity as (Identity & { name?: string; username?: string }) | null | undefined
|
|
207
|
+
add('account', 'Your name in the app', who?.name || who?.username, 'so whoever fixes it knows who to thank, and can look at your account if the problem is in it')
|
|
208
|
+
add('time', 'Your local time', timeWords(now), 'so the fixer can line it up with what the app logged at that moment')
|
|
209
|
+
const langs = typeof navigator === 'undefined' ? [] : (navigator.languages?.length ? [...navigator.languages] : navigator.language ? [navigator.language] : [])
|
|
210
|
+
add('language', 'Your languages', langs.join(', '), 'some problems only happen in one language or date format')
|
|
211
|
+
add('device', 'Your device', await deviceWords(o.userAgent), 'so the fixer can try it on the same kind of device and browser')
|
|
212
|
+
if (typeof window !== 'undefined') {
|
|
213
|
+
const s = typeof screen === 'undefined' ? null : screen
|
|
214
|
+
add('screen', 'Screen and window', `${s ? `${s.width}x${s.height} screen, ` : ''}${window.innerWidth}x${window.innerHeight} window, ${window.devicePixelRatio || 1}x pixels`, 'how things are laid out depends on the size of the screen and window')
|
|
215
|
+
const look = [media('(prefers-color-scheme: dark)') ? 'dark mode' : 'light mode', ...(media('(prefers-reduced-motion: reduce)') ? ['reduced motion'] : [])]
|
|
216
|
+
add('appearance', 'Appearance', look.join(', '), 'some problems only show in dark mode or with animations turned down')
|
|
217
|
+
}
|
|
218
|
+
if (typeof navigator !== 'undefined') {
|
|
219
|
+
const c = (navigator as Navigator & { connection?: { type?: string; effectiveType?: string } }).connection
|
|
220
|
+
add('connection', 'Connection', [navigator.onLine === false ? 'offline' : 'online', c?.type, c?.effectiveType && `${c.effectiveType} speed`].filter(Boolean).join(', '), 'a slow or dropped connection explains many problems')
|
|
221
|
+
}
|
|
222
|
+
const since = shared.installedAt || (typeof performance !== 'undefined' ? performance.timeOrigin : 0)
|
|
223
|
+
if (since) {
|
|
224
|
+
const min = Math.max(0, Math.round((now.getTime() - since) / 60_000))
|
|
225
|
+
add('age', 'How long the page was open', min < 1 ? 'under a minute' : `${min} minute${min === 1 ? '' : 's'}`, 'some problems only start after a page has been open a while')
|
|
226
|
+
}
|
|
227
|
+
if (o.precise && typeof navigator !== 'undefined' && navigator.geolocation) {
|
|
228
|
+
const pos = await new Promise<GeolocationPosition | null>((resolve) => {
|
|
229
|
+
try { navigator.geolocation.getCurrentPosition(resolve, () => resolve(null), { maximumAge: 600_000, timeout: 5000 }) } catch { resolve(null) }
|
|
230
|
+
})
|
|
231
|
+
if (pos) add('place', 'Where you were', `${pos.coords.latitude.toFixed(3)}, ${pos.coords.longitude.toFixed(3)}`, 'this app asked for it, because some problems only happen in one place')
|
|
232
|
+
}
|
|
233
|
+
if (o.extra) {
|
|
234
|
+
try {
|
|
235
|
+
const got = await o.extra()
|
|
236
|
+
;(Array.isArray(got) ? got : []).forEach((x) => {
|
|
237
|
+
// An app item that does not say why it is shared is not shared.
|
|
238
|
+
if (x && typeof x.label === 'string' && typeof x.value === 'string' && typeof x.why === 'string' && x.why.trim()) add(`app:${items.filter((i) => i.key.startsWith('app:')).length}`, x.label, x.value, x.why)
|
|
239
|
+
})
|
|
240
|
+
} catch { /* the app's own context failing never blocks a suggestion */ }
|
|
241
|
+
}
|
|
242
|
+
return { items: items.slice(0, MAX.items), ...contextTrail() }
|
|
243
|
+
}
|
package/src/client/detail.tsx
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// it has an end status. A report's words are data: they are rendered as text, never as markup. A report
|
|
4
4
|
// that is still only on this phone is shown from the phone's own copy, with the app's refusal if it gave one.
|
|
5
5
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
6
|
-
import { END, STATUSES } from '../contract.mjs'
|
|
6
|
+
import { END, STATUSES, currentStatus } from '../contract.mjs'
|
|
7
7
|
import type { Adapters, FieldProps } from './adapters.ts'
|
|
8
8
|
import { DefaultField } from './default-field.tsx'
|
|
9
9
|
import { draftKey } from './drafts.ts'
|
|
@@ -13,7 +13,7 @@ import { useRuntime, useTick, type FollowUp } from './runtime.ts'
|
|
|
13
13
|
import { markSeen } from './seen.ts'
|
|
14
14
|
import { Toasts } from './toast.tsx'
|
|
15
15
|
import { dropCache, isRefusal, oneCacheKey, readCache, useClearOnSignOut, writeCache } from './view-cache.ts'
|
|
16
|
-
import { effectiveCeilings, lengthWords, sizeWords, statusMeaning, statusWords, stringsFor } from './words.ts'
|
|
16
|
+
import { effectiveCeilings, linkableIssue, lengthWords, sizeWords, statusMeaning, statusWords, stringsFor } from './words.ts'
|
|
17
17
|
|
|
18
18
|
export function statusOf(r: Record<string, unknown>): string {
|
|
19
19
|
if (typeof r.status === 'string') return r.status
|
|
@@ -22,7 +22,7 @@ export function statusOf(r: Record<string, unknown>): string {
|
|
|
22
22
|
return 'received'
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
/** The step shown under
|
|
25
|
+
/** The step shown under the current status. The timeline entry is the truth (that is where the receiving side puts it); a top-level `step` is read only when there is no timeline. */
|
|
26
26
|
export function stepOf(r: Record<string, unknown>): string | undefined {
|
|
27
27
|
const t = r.timeline
|
|
28
28
|
if (Array.isArray(t) && t.length) { const s = (t[t.length - 1] as { step?: unknown })?.step; return typeof s === 'string' && s ? s : undefined }
|
|
@@ -33,7 +33,10 @@ const timelineOf = (r?: Record<string, unknown>): string[] | undefined =>
|
|
|
33
33
|
|
|
34
34
|
/** The strip is made from the contract's own statuses, one step per distinct word ("planned" reads as "With Pluma"),
|
|
35
35
|
* and a step is marked from the report's timeline: reached, skipped (the report went past it), or still ahead. */
|
|
36
|
-
export function StatusStrip({ status, owner, step, timeline, label }: { status: string; owner: string; step?: string; timeline?: string[]; /** The list's accessible name; the screens pass the app's own (words.text.stepLabel). */ label?: string }) {
|
|
36
|
+
export function StatusStrip({ status: given, owner, step, timeline: givenTimeline, label }: { status: string; owner: string; step?: string; timeline?: string[]; /** The list's accessible name; the screens pass the app's own (words.text.stepLabel). */ label?: string }) {
|
|
37
|
+
// An old (0.1.x) status reads as the new one it means, on the strip and in the timeline it is marked from.
|
|
38
|
+
const status = currentStatus(given)
|
|
39
|
+
const timeline = givenTimeline?.map((s) => currentStatus(s))
|
|
37
40
|
const nodes = useMemo(() => {
|
|
38
41
|
const out: { words: string; ids: string[] }[] = []
|
|
39
42
|
for (const id of STATUSES.filter((s: string) => !END.includes(s))) {
|
|
@@ -46,7 +49,7 @@ export function StatusStrip({ status, owner, step, timeline, label }: { status:
|
|
|
46
49
|
const ended = END.includes(status)
|
|
47
50
|
const here = nodes.findIndex((n) => n.ids.includes(status))
|
|
48
51
|
const at = ended ? nodes.length : Math.max(0, here)
|
|
49
|
-
const items = [...nodes, { words: statusWords(ended ? status : '
|
|
52
|
+
const items = [...nodes, { words: statusWords(ended ? status : 'done', owner), ids: [ended ? status : 'end'] }]
|
|
50
53
|
const reached = new Set(timeline ?? [])
|
|
51
54
|
return (
|
|
52
55
|
<ol className="sac-strip" aria-label={label ?? stringsFor({ app: 'x', owner }).stepLabel} data-status={status}>
|
|
@@ -60,7 +63,7 @@ export function StatusStrip({ status, owner, step, timeline, label }: { status:
|
|
|
60
63
|
return (
|
|
61
64
|
<li key={it.words + i} className="sac-step" data-state={state} aria-current={i === at ? 'step' : undefined}>
|
|
62
65
|
<span>{it.words}</span>
|
|
63
|
-
{i === at &&
|
|
66
|
+
{i === at && step ? <span className="sac-step-detail">{step}</span> : null}
|
|
64
67
|
</li>
|
|
65
68
|
)
|
|
66
69
|
})}
|
|
@@ -188,9 +191,10 @@ export function SuggestionDetail({ adapters, id }: { adapters: Adapters; id: str
|
|
|
188
191
|
if (who === null) return <p role="alert">{S.signedOutMine}</p>
|
|
189
192
|
if (report === undefined) return <p>{S.loading}</p>
|
|
190
193
|
if (report === null) return <p role="alert">{S.couldNotOpen}</p>
|
|
191
|
-
const status = statusOf(report)
|
|
194
|
+
const status = currentStatus(statusOf(report))
|
|
192
195
|
const owner = adapters.words.owner
|
|
193
196
|
const open = !END.includes(status) && status !== 'on-phone'
|
|
197
|
+
const issue = linkableIssue(report.issue)
|
|
194
198
|
const answers = (report.answers ?? {}) as Record<string, string>
|
|
195
199
|
const voice = Array.isArray(report.voice) ? (report.voice as { file: string; ms: number }[]) : []
|
|
196
200
|
const media = Array.isArray(report.media) ? (report.media as { file: string; type: string; bytes: number }[]) : []
|
|
@@ -229,7 +233,8 @@ export function SuggestionDetail({ adapters, id }: { adapters: Adapters; id: str
|
|
|
229
233
|
return (
|
|
230
234
|
<article className="sac-detail" data-report={id}>
|
|
231
235
|
<h3 className="sac-status" data-status={status}>{statusWords(status, owner)}</h3>
|
|
232
|
-
<p className="sac-muted">{statusMeaning(status, owner
|
|
236
|
+
<p className="sac-muted">{statusMeaning(status, owner, report.issue)}</p>
|
|
237
|
+
{issue ? <p><a className="sac-issue-link" href={issue.url} target="_blank" rel="noopener noreferrer">{S.issueLink(issue.number)}</a></p> : null}
|
|
233
238
|
{refusal ? (
|
|
234
239
|
<>
|
|
235
240
|
<p role="alert" className="sac-error">{refusal}</p>
|
package/src/client/entry.tsx
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
// Signed out, it opens nothing and says why. Signed in, it hands over to the host (`onOpen`), which
|
|
4
4
|
// shows the sheet. `<ReportProblem>` is the same control for an error screen, already filled in.
|
|
5
5
|
import { useEffect, useState, type AriaRole, type ReactNode } from 'react'
|
|
6
|
+
import { currentStatus } from '../contract.mjs'
|
|
6
7
|
import type { Adapters } from './adapters.ts'
|
|
7
8
|
import { useRuntime, type Runtime } from './runtime.ts'
|
|
8
|
-
import { firstToastThisOpen, moved, onSeen } from './seen.ts'
|
|
9
|
+
import { firstToastThisOpen, fresh, moved, onSeen } from './seen.ts'
|
|
9
10
|
import { SuggestSheet } from './sheet.tsx'
|
|
10
11
|
import { toast, Toasts } from './toast.tsx'
|
|
11
12
|
import { useClearOnSignOut } from './view-cache.ts'
|
|
@@ -19,7 +20,7 @@ export function KeepSending({ adapters }: { adapters: Adapters }) {
|
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
// THE NEWS: once per mount, asks the app for this person's reports and compares each with what they last opened. A move
|
|
22
|
-
// since then is counted (the dot), and the newest one gets the page load's one toast, whichever of the entry or a
|
|
23
|
+
// since then is counted (the dot), and the newest one the last page load had not already seen gets the page load's one toast, whichever of the entry or a
|
|
23
24
|
// root-mounted SuggestNews asks first (the guard is per page load and account, in seen.ts). Opening a report anywhere
|
|
24
25
|
// recounts. `toasted` says this mount showed the toast, so a standalone entry knows to draw a toast host for it.
|
|
25
26
|
function useNews(adapters: Adapters, rt: Runtime | null): { moved: number; toasted: boolean } {
|
|
@@ -44,10 +45,13 @@ function useNews(adapters: Adapters, rt: Runtime | null): { moved: number; toast
|
|
|
44
45
|
try { now = await adapters.identity() } catch { now = null }
|
|
45
46
|
if (!live || !rows || !now || now.account !== rt.who.account) return
|
|
46
47
|
const m = await count()
|
|
47
|
-
if (!live || !m
|
|
48
|
+
if (!live || !m) return
|
|
49
|
+
// Only a move the last page load had not already seen is toasted; an older one keeps just the dot.
|
|
50
|
+
const f = await fresh(rt.store, id, m)
|
|
51
|
+
if (!f.length || !firstToastThisOpen(id)) return
|
|
48
52
|
const at = (x: string) => rows!.find((r) => r.id === x)?.at ?? 0
|
|
49
|
-
const top = [...
|
|
50
|
-
const note = top.status === 'not-
|
|
53
|
+
const top = [...f].sort((a, b) => at(b.id) - at(a.id))[0]
|
|
54
|
+
const note = currentStatus(top.status) === 'not-planned' ? rows.find((r) => r.id === top.id)?.note : undefined
|
|
51
55
|
setToasted(true)
|
|
52
56
|
toast({ id: `sac-moved-${top.id}`, text: S.movedToast(statusWords(top.status, adapters.words.owner), typeof note === 'string' && note ? note : undefined), ms: 6000 })
|
|
53
57
|
})()
|
package/src/client/index.ts
CHANGED
|
@@ -15,3 +15,5 @@ export * from './mine.tsx'
|
|
|
15
15
|
export { seenStatuses, markSeen, moved, onSeen, seenKey, type Seen } from './seen.ts'
|
|
16
16
|
export * from './detail.tsx'
|
|
17
17
|
export { refreshIdentity } from './runtime.ts'
|
|
18
|
+
export { collectContext, installContextListeners, contextTrail, resetContext, parseUserAgent, withoutQueries } from './context.ts'
|
|
19
|
+
export type { ContextItem, AppContextItem, ReportContext, Trail, CollectOptions } from './context.ts'
|
package/src/client/mine.tsx
CHANGED
|
@@ -11,7 +11,8 @@ import { useRuntime, useTick } from './runtime.ts'
|
|
|
11
11
|
import { moved, onSeen } from './seen.ts'
|
|
12
12
|
import { dropCache, isRefusal, mineCacheKey, pruneOnes, readCache, useClearOnSignOut, writeCache } from './view-cache.ts'
|
|
13
13
|
import { Toasts } from './toast.tsx'
|
|
14
|
-
import {
|
|
14
|
+
import { currentStatus } from '../contract.mjs'
|
|
15
|
+
import { linkableIssue, statusMeaning, statusWords, stringsFor } from './words.ts'
|
|
15
16
|
|
|
16
17
|
type Row = Summary & { error?: string }
|
|
17
18
|
|
|
@@ -119,14 +120,17 @@ export function MySuggestions({ adapters, onOpen }: { adapters: Adapters; onOpen
|
|
|
119
120
|
<ul className="sac-list">
|
|
120
121
|
{rows.map((r) => {
|
|
121
122
|
const preview = typeof r.words === 'string' ? r.words : typeof r.title === 'string' ? r.title : ''
|
|
123
|
+
const issue = linkableIssue(r.issue)
|
|
122
124
|
return (
|
|
123
|
-
<li key={r.id} className="sac-row" data-status={r.status}>
|
|
125
|
+
<li key={r.id} className="sac-row" data-status={currentStatus(r.status)}>
|
|
124
126
|
<button type="button" className="sac-row-button" onClick={() => (onOpen ? onOpen(r.id) : setOpen(r.id))}>
|
|
125
127
|
{fresh.has(r.id) ? <span className="sac-new" data-new="">{S.newMark}</span> : null}
|
|
126
128
|
{preview ? <span className="sac-row-words">{preview}</span> : null}
|
|
127
129
|
<span className="sac-row-status">{statusWords(r.status, owner)}</span>
|
|
128
|
-
<span className="sac-muted">{statusMeaning(r.status, owner)}</span>
|
|
130
|
+
<span className="sac-muted">{statusMeaning(r.status, owner, r.issue)}</span>
|
|
129
131
|
</button>
|
|
132
|
+
{/* The row links to the issue; someone who cannot read the repo still sees the number and the state. */}
|
|
133
|
+
{issue ? <a className="sac-issue-link" href={issue.url} target="_blank" rel="noopener noreferrer">{S.issueLink(issue.number)}</a> : null}
|
|
130
134
|
{r.error ? (
|
|
131
135
|
<>
|
|
132
136
|
<p role="alert" className="sac-error">{r.error}</p>
|
package/src/client/runtime.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// when none of its writes is left there, because a write leaves only after the adapter said `done`.
|
|
8
8
|
import { useEffect, useRef, useState } from 'react'
|
|
9
9
|
import type { Adapters, Identity } from './adapters.ts'
|
|
10
|
+
import { installContextListeners } from './context.ts'
|
|
10
11
|
import { draftKey } from './drafts.ts'
|
|
11
12
|
import { openStore, type Store } from './idb.ts'
|
|
12
13
|
import { makeOutbox, type Op, type Sent } from './outbox.ts'
|
|
@@ -179,6 +180,8 @@ export function useRuntime(adapters: Adapters): { rt: Runtime | null; who: Ident
|
|
|
179
180
|
const [signal, setSignal] = useState(0)
|
|
180
181
|
const current = useRef<{ account: string | null; release: () => void }>({ account: null, release: () => {} })
|
|
181
182
|
const alive = useRef(true)
|
|
183
|
+
// Every screen of the module mounts this, so the trail (pages, taps, errors) starts with the first one; it installs once.
|
|
184
|
+
useEffect(() => { installContextListeners() }, [])
|
|
182
185
|
useEffect(() => { alive.current = true; return () => { alive.current = false; current.current.release(); current.current = { account: null, release: () => {} } } }, [])
|
|
183
186
|
useEffect(() => { const l = () => setSignal((n) => n + 1); identityListeners.add(l); return () => { identityListeners.delete(l) } }, [])
|
|
184
187
|
useEffect(() => { if (state.rt) state.rt.adapters = adapters })
|
package/src/client/seen.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// every end) is "moved": the entry shows a dot, My suggestions marks the row New, and one toast says it. Opening
|
|
5
5
|
// the report marks it seen. The record sits beside the cached views, under the same app-and-account key, so another
|
|
6
6
|
// account never reads it and signing out clears it with them (clearViewCache).
|
|
7
|
-
import { TOLD } from '../contract.mjs'
|
|
7
|
+
import { TOLD, currentStatus } from '../contract.mjs'
|
|
8
8
|
import { draftKey } from './drafts.ts'
|
|
9
9
|
import type { Store } from './idb.ts'
|
|
10
10
|
import { readCache, writeCache } from './view-cache.ts'
|
|
@@ -34,8 +34,9 @@ export async function seenStatuses(store: Store, id: Id): Promise<Seen> {
|
|
|
34
34
|
export function markSeen(store: Store, id: Id, reportId: string, status: string): Promise<void> {
|
|
35
35
|
return serial(seenKey(id), async () => {
|
|
36
36
|
const seen = await seenStatuses(store, id)
|
|
37
|
-
|
|
38
|
-
seen[reportId]
|
|
37
|
+
const now = currentStatus(status) // kept in the new words, so an old status and the one it means are the same news
|
|
38
|
+
if (seen[reportId] === now) return
|
|
39
|
+
seen[reportId] = now
|
|
39
40
|
await writeCache(store, seenKey(id), seen)
|
|
40
41
|
for (const l of listeners) l()
|
|
41
42
|
})
|
|
@@ -51,9 +52,10 @@ export function moved(store: Store, id: Id, rows: { id: string; status: string }
|
|
|
51
52
|
let changed = false
|
|
52
53
|
for (const r of rows) {
|
|
53
54
|
if (typeof r?.id !== 'string' || typeof r.status !== 'string' || r.status === 'on-phone') continue
|
|
54
|
-
const had = seen[r.id]
|
|
55
|
-
|
|
56
|
-
if (had ===
|
|
55
|
+
const had = seen[r.id] === undefined ? undefined : currentStatus(seen[r.id])
|
|
56
|
+
const now = currentStatus(r.status)
|
|
57
|
+
if (had === now) continue
|
|
58
|
+
if (had === undefined || !TOLD.includes(now)) { seen[r.id] = now; changed = true; continue }
|
|
57
59
|
out.push({ id: r.id, status: r.status })
|
|
58
60
|
}
|
|
59
61
|
if (changed) await writeCache(store, seenKey(id), seen)
|
|
@@ -70,3 +72,22 @@ export function firstToastThisOpen(id: Id): boolean {
|
|
|
70
72
|
toasted.add(k)
|
|
71
73
|
return true
|
|
72
74
|
}
|
|
75
|
+
|
|
76
|
+
/** WHAT THE LAST PAGE LOAD ALREADY SAW. The moves (report and status) that were already news the last time the app was
|
|
77
|
+
* loaded, so a move the person was told about, or could have been, is not toasted again on the next load; it keeps
|
|
78
|
+
* only the dot and the New mark until they open it. Returns the moves in `m` that are new since then, and records `m`
|
|
79
|
+
* as what this load saw. With no record yet (the first load on this release) every move is fresh, as before. */
|
|
80
|
+
export const noticedKey = (id: Id) => `${draftKey(id)}#view:noticed`
|
|
81
|
+
export function fresh(store: Store, id: Id, m: { id: string; status: string }[]): Promise<{ id: string; status: string }[]> {
|
|
82
|
+
return serial(noticedKey(id), async () => {
|
|
83
|
+
const had = await readCache<Seen>(store, noticedKey(id))
|
|
84
|
+
const before = had && typeof had === 'object' ? had : {}
|
|
85
|
+
const out = m.filter((r) => before[r.id] !== r.status)
|
|
86
|
+
const now: Seen = { ...before }
|
|
87
|
+
for (const r of m) now[r.id] = r.status
|
|
88
|
+
// A report no longer moved (opened, or gone) drops out, so the record stays the size of the dot.
|
|
89
|
+
for (const k of Object.keys(now)) if (!m.some((r) => r.id === k)) delete now[k]
|
|
90
|
+
await writeCache(store, noticedKey(id), now)
|
|
91
|
+
return out
|
|
92
|
+
})
|
|
93
|
+
}
|
package/src/client/sheet.tsx
CHANGED
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
// through the outbox, so a closed app loses nothing. A voice note alone is a whole report.
|
|
7
7
|
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
|
8
8
|
import { isAcceptedMedia, sanitizeRoute, typeForFile, validateReport } from '../contract.mjs'
|
|
9
|
-
import type { Adapters, FieldProps, Kind } from './adapters.ts'
|
|
9
|
+
import type { Adapters, FieldProps, Kind, SheetDestination } from './adapters.ts'
|
|
10
|
+
import { destinationWords, whatGoes, type GoesItem } from '../github/compose.ts'
|
|
11
|
+
import { validContact } from '../contract.mjs'
|
|
12
|
+
import type { Contact } from './adapters.ts'
|
|
13
|
+
import { collectContext, type ReportContext } from './context.ts'
|
|
10
14
|
import { useDialog } from './dialog.ts'
|
|
11
15
|
import { DefaultField } from './default-field.tsx'
|
|
12
16
|
import { draftKey, loadDraft, saveDraft } from './drafts.ts'
|
|
@@ -83,6 +87,32 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
|
|
|
83
87
|
const [announce, setAnnounce] = useState('')
|
|
84
88
|
const [sent, setSent] = useState<{ id: string; files: { file: string; name: string; bytes: number }[] } | null>(null)
|
|
85
89
|
const [loaded, setLoaded] = useState(false)
|
|
90
|
+
const [dest, setDest] = useState<SheetDestination | null>(null)
|
|
91
|
+
// Everything shared so it can be fixed, each with why; all on by default, any one can be left out (`choice`).
|
|
92
|
+
const [ctx, setCtx] = useState<ReportContext | null>(null)
|
|
93
|
+
const [contact, setContact] = useState<Contact | null>(null)
|
|
94
|
+
const [choice, setChoice] = useState<Record<string, boolean>>({})
|
|
95
|
+
// Where it files, asked once when the sheet opens; until it answers (or with none) the one-line note stays.
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!adapters.destination) return
|
|
98
|
+
let live = true
|
|
99
|
+
adapters.destination().then((d) => { if (live) setDest(d && typeof d.repo === 'string' ? d : null) }, () => { if (live) setDest(null) })
|
|
100
|
+
return () => { live = false }
|
|
101
|
+
}, [adapters])
|
|
102
|
+
const contextOptions = useCallback(() => {
|
|
103
|
+
const c = adapters.context
|
|
104
|
+
return typeof c === 'function' ? { extra: c, precise: false } : { extra: c?.items ? () => c.items!() : undefined, precise: Boolean(c?.precise) }
|
|
105
|
+
}, [adapters])
|
|
106
|
+
const gather = useCallback(() => collectContext({ identity: who ?? null, where, about: adapters.about, ...contextOptions(), ...(adapters.phone?.now ? { now: adapters.phone.now } : {}) }), [who, where, adapters, contextOptions])
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (!who) return
|
|
109
|
+
let live = true
|
|
110
|
+
void gather().then((c) => { if (live) setCtx(c) })
|
|
111
|
+
if (adapters.contact) adapters.contact().then((c) => { if (live) setContact(c && !validContact(c).length ? c : null) }, () => {})
|
|
112
|
+
return () => { live = false }
|
|
113
|
+
// where is an object the host may rebuild each render; the context is gathered once per open and again at Send.
|
|
114
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
115
|
+
}, [adapters, who])
|
|
86
116
|
const chain = useRef<Promise<unknown>>(Promise.resolve())
|
|
87
117
|
// What is on the sheet, kept in step with state so a second take or picture added a moment later counts the first.
|
|
88
118
|
const voiceRef = useRef<Voice[]>([])
|
|
@@ -265,6 +295,30 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
|
|
|
265
295
|
] as { name: Diag; label: string; value: string | undefined }[]).filter((d): d is { name: Diag; label: string; value: string } => Boolean(d.value))
|
|
266
296
|
const lineParts = [`this screen (${where.screen}, ${sanitizeRoute(where.route)})`, ...avail.map((d) => d.label)]
|
|
267
297
|
const summary = `${S.goesTo(adapters.words.owner)} ${S.includes(lineParts)}`
|
|
298
|
+
// With a destination, the one line becomes the list of exactly what the issue will carry, built by the same function
|
|
299
|
+
// the issue is written from (`whatGoes`), so the list and the issue cannot drift. It follows the draft as it changes.
|
|
300
|
+
// Every row of the list, whether or not there is a destination; optional rows carry a tick box.
|
|
301
|
+
const rows = whatGoes({
|
|
302
|
+
kind, words, voice: voice.map((v) => ({ file: v.file, ms: v.ms })), media: media.map((m) => ({ file: m.file, type: m.type, bytes: m.bytes })),
|
|
303
|
+
where: { screen: where.screen, route: sanitizeRoute(where.route) },
|
|
304
|
+
diagnostics: Object.fromEntries(avail.map((d) => [d.name, d.value])),
|
|
305
|
+
appName: adapters.about?.app ?? adapters.words.app,
|
|
306
|
+
...(dest?.name ? { filerName: dest.name } : {}),
|
|
307
|
+
...(ctx ? { context: ctx } : {}),
|
|
308
|
+
...(contact ? { who: { contact } } : {}),
|
|
309
|
+
}, dest ?? { repo: '', public: false, account: '', attaches: true })
|
|
310
|
+
const isOn = (key: string, fallback = true) => choice[key] ?? rows.find((r) => r.key === key)?.on ?? fallback
|
|
311
|
+
const goes = dest ? rows : null
|
|
312
|
+
const destWords = dest ? destinationWords(dest) : null
|
|
313
|
+
const optionalRow = (g: GoesItem) => (
|
|
314
|
+
<li key={g.key} data-goes={g.key} className="sac-goes-opt">
|
|
315
|
+
<label>
|
|
316
|
+
<input type="checkbox" checked={isOn(g.key)} onChange={(e) => setChoice((c) => ({ ...c, [g.key]: e.target.checked }))} />{' '}
|
|
317
|
+
<span className="sac-goes-label">{g.text}</span>{g.value ? <>: <span className="sac-goes-value">{g.value}</span></> : null}{' '}
|
|
318
|
+
<span className="sac-muted sac-goes-why">({g.why}{g.reason ? `; off by default because ${g.reason}` : ''})</span>
|
|
319
|
+
</label>
|
|
320
|
+
</li>
|
|
321
|
+
)
|
|
268
322
|
|
|
269
323
|
const hasAnything = Boolean(words.trim() || voice.length || media.length)
|
|
270
324
|
|
|
@@ -273,13 +327,20 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
|
|
|
273
327
|
setError('')
|
|
274
328
|
await chain.current
|
|
275
329
|
const d = await saveDraft(rt.store, key, { kind, words, attachments: [...voice.map((v) => v.key), ...media.map((m) => m.key)] })
|
|
330
|
+
// What was left out never leaves the phone: the context gathered again now (the trail has grown), minus every row
|
|
331
|
+
// the person unticked, and the diagnostics those rows stand for.
|
|
332
|
+
const fresh = await gather()
|
|
333
|
+
const keep = { ...fresh, items: fresh.items.filter((i) => isOn(`ctx:${i.key}`)), routes: isOn('routes') ? fresh.routes : [], taps: isOn('taps') ? fresh.taps : [], errors: isOn('errors') ? fresh.errors : [] }
|
|
334
|
+
const leftOut: Record<string, Diag> = { 'ctx:device': 'device', 'ctx:screen': 'screen', 'ctx:time': 'at' }
|
|
276
335
|
const diagnostics: Record<string, string> = {}
|
|
277
|
-
for (const x of avail) diagnostics[x.name] = x.value
|
|
336
|
+
for (const x of avail) if (!Object.entries(leftOut).some(([k, d]) => d === x.name && !isOn(k))) diagnostics[x.name] = x.value
|
|
337
|
+
const reach = contact && isOn('contact') ? contact : null
|
|
278
338
|
const report: Record<string, unknown> & { id: string } = {
|
|
279
339
|
id: d.reportId,
|
|
280
340
|
kind,
|
|
281
341
|
...(adapters.about ? { app: Object.fromEntries(Object.entries(adapters.about).filter(([k]) => k !== 'version')) } : {}),
|
|
282
|
-
who: { member: who.account, role: who.role },
|
|
342
|
+
who: { member: who.account, role: who.role, ...(reach ? { contact: reach } : {}) },
|
|
343
|
+
context: keep,
|
|
283
344
|
words: words.trim(),
|
|
284
345
|
voice: voice.map((v) => ({ file: v.file, ms: v.ms, field: v.field })),
|
|
285
346
|
media: await Promise.all(media.map(async (m) => {
|
|
@@ -398,7 +459,20 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
|
|
|
398
459
|
) : null}
|
|
399
460
|
</fieldset>
|
|
400
461
|
|
|
401
|
-
|
|
462
|
+
{goes && destWords ? (
|
|
463
|
+
<div className="sac-goes sac-fyi" data-sac-goes="">
|
|
464
|
+
<p className="sac-goes-head"><strong>{destWords.headline}</strong>{' ('}<span className={destWords.danger ? 'sac-danger' : 'sac-muted'}>{destWords.privacy}</span>)</p>
|
|
465
|
+
<p className="sac-muted">It will carry:</p>
|
|
466
|
+
<ul className="sac-goes-list">{goes.filter((g) => !g.optional).map((g) => <li key={g.key} data-goes={g.key}>{g.text}</li>)}</ul>
|
|
467
|
+
{goes.some((g) => g.optional) ? <ul className="sac-goes-opts" aria-label={S.contextLegend}>{goes.filter((g) => g.optional).map(optionalRow)}</ul> : null}
|
|
468
|
+
<p className="sac-muted">{destWords.account}</p>
|
|
469
|
+
</div>
|
|
470
|
+
) : (
|
|
471
|
+
<div className="sac-fyi">
|
|
472
|
+
<p className="sac-muted sac-fyi" data-sac-summary="">{summary}</p>
|
|
473
|
+
{rows.some((g) => g.optional) ? <ul className="sac-goes-opts" aria-label={S.contextLegend}>{rows.filter((g) => g.optional).map(optionalRow)}</ul> : null}
|
|
474
|
+
</div>
|
|
475
|
+
)}
|
|
402
476
|
|
|
403
477
|
{error ? <p role="alert" className="sac-error">{error}</p> : null}
|
|
404
478
|
<div className="sac-actions">
|
package/src/client/styles.css
CHANGED
|
@@ -56,6 +56,11 @@
|
|
|
56
56
|
.sac-muted { color: var(--sac-muted); font-size: 0.9rem; }
|
|
57
57
|
.sac-fyi { font-size: 0.8rem; margin: 0; }
|
|
58
58
|
.sac-error { color: var(--sac-danger); margin: 0; }
|
|
59
|
+
.sac-danger { color: var(--sac-danger); font-weight: 600; }
|
|
60
|
+
.sac-goes p { margin: 0; }
|
|
61
|
+
.sac-goes-list { margin: 2px 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: 2px 0; }
|
|
62
|
+
.sac-goes-list li:not(:last-child)::after { content: " \00b7 "; white-space: pre; color: var(--sac-muted); }
|
|
63
|
+
.sac-issue-link { color: var(--sac-accent); font-size: 0.9rem; }
|
|
59
64
|
.sac-receipt-line { font-weight: 600; margin: 0; }
|
|
60
65
|
.sac-strip { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
|
|
61
66
|
.sac-step { border: 1px solid var(--sac-border); border-radius: 999px; padding: 4px 10px; color: var(--sac-muted); }
|
|
@@ -63,8 +68,12 @@
|
|
|
63
68
|
.sac-step[data-state="now"] { border-color: var(--sac-accent); color: var(--sac-fg); font-weight: 600; }
|
|
64
69
|
.sac-step-detail { display: block; font-weight: 400; font-size: 0.85rem; }
|
|
65
70
|
.sac-words { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
66
|
-
|
|
67
|
-
|
|
71
|
+
/* Toasts sit small at the bottom edge, never over the top of the host's page: the top is where an app opens onto
|
|
72
|
+
its own content, and a full-width bar there reads as the app's header. Each is a pill as wide as its words, with a
|
|
73
|
+
drawn x so it reads as dismissable (the whole pill is the button). */
|
|
74
|
+
.sac-toasts { position: fixed; left: 0; right: 0; bottom: calc(env(safe-area-inset-bottom) + 12px); z-index: calc(var(--sac-z) + 1); display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 0 16px; pointer-events: none; }
|
|
75
|
+
.sac-toast { pointer-events: auto; font: inherit; font-size: 0.875rem; line-height: 1.3; width: auto; max-width: min(100%, 24rem); text-align: left; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--sac-border); background: var(--sac-bg); color: var(--sac-fg); display: inline-flex; align-items: center; gap: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18); }
|
|
76
|
+
.sac-toast::after { content: "\00d7"; color: var(--sac-muted); font-size: 1rem; line-height: 1; }
|
|
68
77
|
.sac-toast[data-tone="warn"] { background: var(--sac-fg); color: var(--sac-bg); }
|
|
69
78
|
.sac-entry-why { margin-left: 8px; color: var(--sac-muted); font-size: 0.9rem; }
|
|
70
79
|
.sac-sheet :focus-visible, .sac-entry:focus-visible, .sac-button:focus-visible, .sac-record:focus-visible, .sac-row-button:focus-visible { outline: 3px solid var(--sac-focus); outline-offset: 2px; }
|