@marktiderman/genesis-support 0.1.0 → 0.2.1
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/README.md +61 -4
- package/dist/{block-fZjTS2sY.d.ts → block-ClKVvk-d.d.ts} +10 -2
- package/dist/chunk-MMBNG2NK.js +89 -0
- package/dist/chunk-MMBNG2NK.js.map +1 -0
- package/dist/{chunk-ZV4KJBUF.js → chunk-OSB6G6Y3.js} +18 -88
- package/dist/chunk-OSB6G6Y3.js.map +1 -0
- package/dist/{chunk-4VFYVQUF.js → chunk-QNUAKW7K.js} +136 -7
- package/dist/chunk-QNUAKW7K.js.map +1 -0
- package/dist/{copy-CuTArBFg.d.ts → copy-D3rc0SCl.d.ts} +141 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/native.d.ts +32 -6
- package/dist/native.js +37 -14
- package/dist/native.js.map +1 -1
- package/dist/web-chat.d.ts +43 -0
- package/dist/web-chat.js +140 -0
- package/dist/web-chat.js.map +1 -0
- package/dist/web.d.ts +1 -1
- package/dist/web.js +2 -1
- package/dist/web.js.map +1 -1
- package/migrations/0001_genesis_support_chat.sql +616 -0
- package/migrations/tests/0001_genesis_support_chat.test.sql +288 -0
- package/package.json +60 -8
- package/dist/chunk-4VFYVQUF.js.map +0 -1
- package/dist/chunk-ZV4KJBUF.js.map +0 -1
package/README.md
CHANGED
|
@@ -104,8 +104,10 @@ await client.submit({ type: "bug", message, context: await collector.collect(),
|
|
|
104
104
|
|
|
105
105
|
- `createSupportClient(supabase)`: `submit` (the Supabase sink), `myReports`, `report`,
|
|
106
106
|
`replies`, `reply`, `markRead`, `deleteReport`, `attachments` (signed URLs), `ideas`,
|
|
107
|
-
`suggestIdea`, `vote`, `unvote`, `shippedForMe
|
|
108
|
-
|
|
107
|
+
`suggestIdea`, `vote`, `unvote`, `shippedForMe`, and for staff `isStaff`, `setStatus` and
|
|
108
|
+
(support-chat block) `assign`; `thread`, `conversationId` and `subscribe` for a thread on
|
|
109
|
+
genesis-chat (see below). Errors are `SupportError` with the database's code (`rateLimited` for
|
|
110
|
+
`GS429`).
|
|
109
111
|
- `createIntakeClient` + `createIntakeSink`: the wire contract over HTTP, for code without
|
|
110
112
|
supabase-js (it takes no screenshots, and says so in `attachmentsDropped`).
|
|
111
113
|
- `contract.ts` is the wire contract (`CONTRACT_VERSION`, headers, `SubmitFeedbackInput`,
|
|
@@ -115,6 +117,58 @@ await client.submit({ type: "bug", message, context: await collector.collect(),
|
|
|
115
117
|
A GitHub issue per report, or a signed webhook, is a server-side sink: the
|
|
116
118
|
`genesis-support-notify` function template in `@marktiderman/genesis-supabase`.
|
|
117
119
|
|
|
120
|
+
## Threads on genesis-chat (the `support-chat` block)
|
|
121
|
+
|
|
122
|
+
With `@marktiderman/genesis-chat` installed, a report's conversation can run on genesis-chat
|
|
123
|
+
instead of `support_replies`: live updates instead of a 30 second re-read, photos, files and
|
|
124
|
+
voice notes, push, an assignee (pushed every message) and internal notes staff write and the
|
|
125
|
+
person never sees. The report stays the record (type, status, message, screenshots, device).
|
|
126
|
+
|
|
127
|
+
1. Apply this package's `migrations/0001_genesis_support_chat.sql` (and its test) after
|
|
128
|
+
`0006_genesis_support.sql` and genesis-chat's `0001` and `0002`. It registers the chat kind
|
|
129
|
+
`support` (the reporter its only member, staff observers who post, no threads), gives every
|
|
130
|
+
report its conversation, and copies the replies already in `support_replies` into it, in
|
|
131
|
+
order and with their times, sending no push.
|
|
132
|
+
2. Give support genesis-chat's client:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
import { createChatClient } from "@marktiderman/genesis-chat";
|
|
136
|
+
const chat = createChatClient(supabase);
|
|
137
|
+
createSupport({ supabase, chat, … }); // native
|
|
138
|
+
const client = createSupportClient(supabase, { chat }); // web
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The client asks the database once (`support_chat_conversation`). With the block, `replies`,
|
|
142
|
+
`reply`, `markRead` and the new `subscribe` go through the report's conversation; without it
|
|
143
|
+
(or without `chat`) they use `support_replies` as before, and a `chat` client given to a
|
|
144
|
+
database without the block says so once (`onChatUnavailable`, default `console.warn`).
|
|
145
|
+
3. Native: `useSupportThread(id)` needs no change. On chat it updates live (no polling), shows
|
|
146
|
+
photos in replies and names voice notes and files; it also returns `mode` and
|
|
147
|
+
`conversationId`. `useSupportConversation(id)` gives a screen of your own the conversation id,
|
|
148
|
+
for genesis-chat's native `ChatThreadScreen`.
|
|
149
|
+
4. Web (`@marktiderman/genesis-support/web-chat`, under genesis-chat's `ChatProvider` in a
|
|
150
|
+
QueryClientProvider): `<SupportReportThread client={client} reportId={id} />` is the person's
|
|
151
|
+
view, the report above genesis-chat's `ChatThread`. `<SupportStaffThread client reportId
|
|
152
|
+
resolveUser staff />` adds the status and assignee pickers; the composer's note toggle writes
|
|
153
|
+
internal notes.
|
|
154
|
+
|
|
155
|
+
Kept in step by the migration: the report's status and the conversation's (open and submitted
|
|
156
|
+
are open, in_progress pending, addressed solved, wont_fix closed; a status set in chat moves the
|
|
157
|
+
report the same way), the report's reply times and read state from messages and
|
|
158
|
+
`genesis_chat.mark_read` (so `unread` and the Help badge keep working), assignees (staff only:
|
|
159
|
+
`client.assign` or `support_assign_report`), and deletion (a deleted report, or account, takes its
|
|
160
|
+
conversation; files follow each package's own cleanup). Staff set statuses with `client.setStatus`.
|
|
161
|
+
|
|
162
|
+
**`support_replies`, for one release.** App builds from before the block still read and write
|
|
163
|
+
`support_replies`, so the two are mirrored: a reply written there becomes a chat message, and a
|
|
164
|
+
chat message (never an internal note) becomes a reply there, edits and deletes included. While
|
|
165
|
+
the mirror runs, 0006's reply limit (60 an hour) counts every message. The next release drops the
|
|
166
|
+
mirror and moves the limit onto chat messages; then `support_replies` can go.
|
|
167
|
+
|
|
168
|
+
One difference: a chat message carries its sender's id, so the person can learn which staff
|
|
169
|
+
member answered (support_replies hid it). Name staff as "Support" in `resolveUser` (the web views
|
|
170
|
+
do by default for the person).
|
|
171
|
+
|
|
118
172
|
## Access, in the database
|
|
119
173
|
|
|
120
174
|
A person sees, edits (while open) and deletes their own reports, replies on them and sees their
|
|
@@ -125,7 +179,10 @@ feature id, vote counts and read receipts are the server's. See the migration's
|
|
|
125
179
|
|
|
126
180
|
## Not yet
|
|
127
181
|
|
|
128
|
-
- No staff (operator)
|
|
129
|
-
|
|
182
|
+
- No staff inbox (operator queue) screen. With the support-chat block, `SupportStaffThread` is one
|
|
183
|
+
report for staff on the web; list reports with `genesis_chat.observed('support')` or a tool of
|
|
184
|
+
yours. Without it, staff work through the database functions.
|
|
185
|
+
- Without the support-chat block there are no live updates: the thread re-reads every 30 seconds
|
|
186
|
+
while open.
|
|
130
187
|
- No ratings, no web idea board, no notification when an idea ships.
|
|
131
188
|
- The CDN (script-tag) build of the web loader isn't published; `mountSupportWidget` is the loader.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as _marktiderman_genesis_features_blocks from '@marktiderman/genesis-features/blocks';
|
|
2
2
|
import { BlockContributions } from '@marktiderman/genesis-features/blocks';
|
|
3
|
-
import {
|
|
3
|
+
import { O as SupportCopyOverrides } from './copy-D3rc0SCl.js';
|
|
4
4
|
|
|
5
5
|
/** The FeatureId of the support block (features.ts). */
|
|
6
6
|
declare const SUPPORT_BLOCK_ID = "support-feedback";
|
|
@@ -35,5 +35,13 @@ interface SupportBlockOptions {
|
|
|
35
35
|
declare function supportContributions(options?: SupportBlockOptions): BlockContributions<never>;
|
|
36
36
|
/** The platform-neutral declaration. */
|
|
37
37
|
declare function defineSupportBlock(options?: SupportBlockOptions): _marktiderman_genesis_features_blocks.BlockDef<"support-feedback", never>;
|
|
38
|
+
/**
|
|
39
|
+
* The support-chat block: a report's conversation runs on genesis-chat. It adds no screens of
|
|
40
|
+
* its own (the support block's thread route renders the conversation); it brings migration
|
|
41
|
+
* `0001_genesis_support_chat.sql` from this package, after 0006 and genesis-chat's migrations.
|
|
42
|
+
*/
|
|
43
|
+
declare function defineSupportChatBlock(options?: {
|
|
44
|
+
requires?: readonly string[];
|
|
45
|
+
}): _marktiderman_genesis_features_blocks.BlockDef<"support-chat", never>;
|
|
38
46
|
|
|
39
|
-
export { SUPPORT_BLOCK_ID as S, type SupportBlockOptions as a, type SupportPaths as b,
|
|
47
|
+
export { SUPPORT_BLOCK_ID as S, type SupportBlockOptions as a, type SupportPaths as b, defineSupportChatBlock as c, defineSupportBlock as d, supportPaths as e, supportContributions as s };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { cleanRoute } from './chunk-OSB6G6Y3.js';
|
|
2
|
+
|
|
3
|
+
// src/record.ts
|
|
4
|
+
var FEEDBACK_TYPES = ["bug", "idea", "question", "praise"];
|
|
5
|
+
var DEVICE_MAX_BYTES = 4096;
|
|
6
|
+
var EXTRA_MAX_BYTES = 16384;
|
|
7
|
+
var jsonBytes = (value) => new TextEncoder().encode(JSON.stringify(value)).length;
|
|
8
|
+
var read = (value) => typeof value === "function" ? value() : value;
|
|
9
|
+
function createContextCollector(options) {
|
|
10
|
+
return {
|
|
11
|
+
collect() {
|
|
12
|
+
return sanitizeContext({
|
|
13
|
+
appVersion: read(options.appVersion),
|
|
14
|
+
route: read(options.route),
|
|
15
|
+
platform: read(options.platform),
|
|
16
|
+
device: read(options.device),
|
|
17
|
+
userId: read(options.userId),
|
|
18
|
+
extra: read(options.extra)
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function composeCollectors(...collectors) {
|
|
24
|
+
return {
|
|
25
|
+
async collect() {
|
|
26
|
+
let out = {};
|
|
27
|
+
for (const c of collectors) {
|
|
28
|
+
const next = await c.collect();
|
|
29
|
+
out = {
|
|
30
|
+
...out,
|
|
31
|
+
...dropUndefined(next),
|
|
32
|
+
device: next.device || out.device ? { ...out.device, ...next.device } : void 0,
|
|
33
|
+
extra: next.extra || out.extra ? { ...out.extra, ...next.extra } : void 0
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return sanitizeContext(out);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function dropUndefined(value) {
|
|
41
|
+
return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== void 0));
|
|
42
|
+
}
|
|
43
|
+
function sanitizeContext(context) {
|
|
44
|
+
const out = {};
|
|
45
|
+
const route = cleanRoute(context.route);
|
|
46
|
+
if (route) out.route = route;
|
|
47
|
+
if (context.appVersion) out.appVersion = String(context.appVersion).slice(0, 200);
|
|
48
|
+
if (context.platform) out.platform = String(context.platform).slice(0, 40);
|
|
49
|
+
if (context.userId) out.userId = context.userId;
|
|
50
|
+
if (context.device && typeof context.device === "object" && jsonBytes(context.device) <= DEVICE_MAX_BYTES) out.device = context.device;
|
|
51
|
+
if (context.extra && typeof context.extra === "object" && jsonBytes(context.extra) <= EXTRA_MAX_BYTES) out.extra = context.extra;
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
var B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
55
|
+
var LOOKUP = (() => {
|
|
56
|
+
const table = new Int16Array(256).fill(-1);
|
|
57
|
+
for (let i = 0; i < B64.length; i++) table[B64.charCodeAt(i)] = i;
|
|
58
|
+
table["-".charCodeAt(0)] = 62;
|
|
59
|
+
table["_".charCodeAt(0)] = 63;
|
|
60
|
+
return table;
|
|
61
|
+
})();
|
|
62
|
+
function base64ToBytes(input) {
|
|
63
|
+
const comma = input.startsWith("data:") ? input.indexOf(",") : -1;
|
|
64
|
+
const clean = (comma >= 0 ? input.slice(comma + 1) : input).replace(/[\s=]/g, "");
|
|
65
|
+
const out = new Uint8Array(Math.floor(clean.length * 3 / 4));
|
|
66
|
+
let buffer = 0;
|
|
67
|
+
let bits = 0;
|
|
68
|
+
let o = 0;
|
|
69
|
+
for (let i = 0; i < clean.length; i++) {
|
|
70
|
+
const v = LOOKUP[clean.charCodeAt(i)] ?? -1;
|
|
71
|
+
if (v < 0) throw new Error("base64ToBytes: not base64");
|
|
72
|
+
buffer = buffer << 6 | v;
|
|
73
|
+
bits += 6;
|
|
74
|
+
if (bits >= 8) {
|
|
75
|
+
bits -= 8;
|
|
76
|
+
out[o++] = buffer >> bits & 255;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out.subarray(0, o);
|
|
80
|
+
}
|
|
81
|
+
function attachmentBytes(attachment) {
|
|
82
|
+
if (attachment.bytes?.length) return attachment.bytes;
|
|
83
|
+
if (attachment.base64) return base64ToBytes(attachment.base64);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export { DEVICE_MAX_BYTES, EXTRA_MAX_BYTES, FEEDBACK_TYPES, attachmentBytes, base64ToBytes, composeCollectors, createContextCollector, sanitizeContext };
|
|
88
|
+
//# sourceMappingURL=chunk-MMBNG2NK.js.map
|
|
89
|
+
//# sourceMappingURL=chunk-MMBNG2NK.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/record.ts"],"names":[],"mappings":";;;AAWO,IAAM,cAAA,GAA0C,CAAC,KAAA,EAAO,MAAA,EAAQ,YAAY,QAAQ;AAkEpF,IAAM,gBAAA,GAAmB;AACzB,IAAM,eAAA,GAAkB;AAE/B,IAAM,SAAA,GAAY,CAAC,KAAA,KAAmB,IAAI,WAAA,EAAY,CAAE,MAAA,CAAO,IAAA,CAAK,SAAA,CAAU,KAAK,CAAC,CAAA,CAAE,MAAA;AAItF,IAAM,OAAO,CAAK,KAAA,KAA+C,OAAO,KAAA,KAAU,UAAA,GAAc,OAAkB,GAAI,KAAA;AAgB/G,SAAS,uBAAuB,OAAA,EAAoD;AACzF,EAAA,OAAO;AAAA,IACL,OAAA,GAAU;AACR,MAAA,OAAO,eAAA,CAAgB;AAAA,QACrB,UAAA,EAAY,IAAA,CAAK,OAAA,CAAQ,UAAU,CAAA;AAAA,QACnC,KAAA,EAAO,IAAA,CAAK,OAAA,CAAQ,KAAK,CAAA;AAAA,QACzB,QAAA,EAAU,IAAA,CAAK,OAAA,CAAQ,QAAQ,CAAA;AAAA,QAC/B,MAAA,EAAQ,IAAA,CAAK,OAAA,CAAQ,MAAM,CAAA;AAAA,QAC3B,MAAA,EAAQ,IAAA,CAAK,OAAA,CAAQ,MAAM,CAAA;AAAA,QAC3B,KAAA,EAAO,IAAA,CAAK,OAAA,CAAQ,KAAK;AAAA,OAC1B,CAAA;AAAA,IACH;AAAA,GACF;AACF;AAGO,SAAS,qBAAqB,UAAA,EAAkD;AACrF,EAAA,OAAO;AAAA,IACL,MAAM,OAAA,GAAU;AACd,MAAA,IAAI,MAAuB,EAAC;AAC5B,MAAA,KAAA,MAAW,KAAK,UAAA,EAAY;AAC1B,QAAA,MAAM,IAAA,GAAO,MAAM,CAAA,CAAE,OAAA,EAAQ;AAC7B,QAAA,GAAA,GAAM;AAAA,UACJ,GAAG,GAAA;AAAA,UACH,GAAG,cAAc,IAAI,CAAA;AAAA,UACrB,MAAA,EAAQ,IAAA,CAAK,MAAA,IAAU,GAAA,CAAI,MAAA,GAAS,EAAE,GAAG,GAAA,CAAI,MAAA,EAAQ,GAAG,IAAA,CAAK,MAAA,EAAO,GAAI,MAAA;AAAA,UACxE,KAAA,EAAO,IAAA,CAAK,KAAA,IAAS,GAAA,CAAI,KAAA,GAAQ,EAAE,GAAG,GAAA,CAAI,KAAA,EAAO,GAAG,IAAA,CAAK,KAAA,EAAM,GAAI;AAAA,SACrE;AAAA,MACF;AACA,MAAA,OAAO,gBAAgB,GAAG,CAAA;AAAA,IAC5B;AAAA,GACF;AACF;AAEA,SAAS,cAAgC,KAAA,EAAsB;AAC7D,EAAA,OAAO,MAAA,CAAO,WAAA,CAAY,MAAA,CAAO,OAAA,CAAQ,KAAK,CAAA,CAAE,MAAA,CAAO,CAAC,GAAG,CAAC,CAAA,KAAM,CAAA,KAAM,MAAS,CAAC,CAAA;AACpF;AAGO,SAAS,gBAAgB,OAAA,EAA2C;AACzE,EAAA,MAAM,MAAuB,EAAC;AAC9B,EAAA,MAAM,KAAA,GAAQ,UAAA,CAAW,OAAA,CAAQ,KAAK,CAAA;AACtC,EAAA,IAAI,KAAA,MAAW,KAAA,GAAQ,KAAA;AACvB,EAAA,IAAI,OAAA,CAAQ,UAAA,EAAY,GAAA,CAAI,UAAA,GAAa,MAAA,CAAO,QAAQ,UAAU,CAAA,CAAE,KAAA,CAAM,CAAA,EAAG,GAAG,CAAA;AAChF,EAAA,IAAI,OAAA,CAAQ,QAAA,EAAU,GAAA,CAAI,QAAA,GAAW,MAAA,CAAO,QAAQ,QAAQ,CAAA,CAAE,KAAA,CAAM,CAAA,EAAG,EAAE,CAAA;AACzE,EAAA,IAAI,OAAA,CAAQ,MAAA,EAAQ,GAAA,CAAI,MAAA,GAAS,OAAA,CAAQ,MAAA;AACzC,EAAA,IAAI,OAAA,CAAQ,MAAA,IAAU,OAAO,OAAA,CAAQ,MAAA,KAAW,QAAA,IAAY,SAAA,CAAU,OAAA,CAAQ,MAAM,CAAA,IAAK,gBAAA,EAAkB,GAAA,CAAI,SAAS,OAAA,CAAQ,MAAA;AAChI,EAAA,IAAI,OAAA,CAAQ,KAAA,IAAS,OAAO,OAAA,CAAQ,KAAA,KAAU,QAAA,IAAY,SAAA,CAAU,OAAA,CAAQ,KAAK,CAAA,IAAK,eAAA,EAAiB,GAAA,CAAI,QAAQ,OAAA,CAAQ,KAAA;AAC3H,EAAA,OAAO,GAAA;AACT;AAEA,IAAM,GAAA,GAAM,kEAAA;AACZ,IAAM,UAAU,MAAM;AACpB,EAAA,MAAM,QAAQ,IAAI,UAAA,CAAW,GAAG,CAAA,CAAE,KAAK,EAAE,CAAA;AACzC,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,GAAA,CAAI,MAAA,EAAQ,CAAA,EAAA,EAAK,KAAA,CAAM,GAAA,CAAI,UAAA,CAAW,CAAC,CAAC,CAAA,GAAI,CAAA;AAChE,EAAA,KAAA,CAAM,GAAA,CAAI,UAAA,CAAW,CAAC,CAAC,CAAA,GAAI,EAAA;AAC3B,EAAA,KAAA,CAAM,GAAA,CAAI,UAAA,CAAW,CAAC,CAAC,CAAA,GAAI,EAAA;AAC3B,EAAA,OAAO,KAAA;AACT,CAAA,GAAG;AAGI,SAAS,cAAc,KAAA,EAA2B;AACvD,EAAA,MAAM,KAAA,GAAQ,MAAM,UAAA,CAAW,OAAO,IAAI,KAAA,CAAM,OAAA,CAAQ,GAAG,CAAA,GAAI,EAAA;AAC/D,EAAA,MAAM,KAAA,GAAA,CAAS,KAAA,IAAS,CAAA,GAAI,KAAA,CAAM,KAAA,CAAM,KAAA,GAAQ,CAAC,CAAA,GAAI,KAAA,EAAO,OAAA,CAAQ,QAAA,EAAU,EAAE,CAAA;AAChF,EAAA,MAAM,GAAA,GAAM,IAAI,UAAA,CAAW,IAAA,CAAK,MAAO,KAAA,CAAM,MAAA,GAAS,CAAA,GAAK,CAAC,CAAC,CAAA;AAC7D,EAAA,IAAI,MAAA,GAAS,CAAA;AACb,EAAA,IAAI,IAAA,GAAO,CAAA;AACX,EAAA,IAAI,CAAA,GAAI,CAAA;AACR,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,KAAA,CAAM,QAAQ,CAAA,EAAA,EAAK;AACrC,IAAA,MAAM,IAAI,MAAA,CAAO,KAAA,CAAM,UAAA,CAAW,CAAC,CAAC,CAAA,IAAK,EAAA;AACzC,IAAA,IAAI,CAAA,GAAI,CAAA,EAAG,MAAM,IAAI,MAAM,2BAA2B,CAAA;AACtD,IAAA,MAAA,GAAU,UAAU,CAAA,GAAK,CAAA;AACzB,IAAA,IAAA,IAAQ,CAAA;AACR,IAAA,IAAI,QAAQ,CAAA,EAAG;AACb,MAAA,IAAA,IAAQ,CAAA;AACR,MAAA,GAAA,CAAI,CAAA,EAAG,CAAA,GAAK,MAAA,IAAU,IAAA,GAAQ,GAAA;AAAA,IAChC;AAAA,EACF;AACA,EAAA,OAAO,GAAA,CAAI,QAAA,CAAS,CAAA,EAAG,CAAC,CAAA;AAC1B;AAGO,SAAS,gBAAgB,UAAA,EAAmD;AACjF,EAAA,IAAI,UAAA,CAAW,KAAA,EAAO,MAAA,EAAQ,OAAO,UAAA,CAAW,KAAA;AAChD,EAAA,IAAI,UAAA,CAAW,MAAA,EAAQ,OAAO,aAAA,CAAc,WAAW,MAAM,CAAA;AAC7D,EAAA,OAAO,IAAA;AACT","file":"chunk-MMBNG2NK.js","sourcesContent":["/**\n * The feedback record and its two seams: a collector assembles the context\n * (everything except what the person typed), a sink delivers the finished\n * record. Platform-neutral; one default of each ships (`createContextCollector`,\n * `createSupabaseSink`), and an app can swap either.\n */\nimport { cleanRoute, type FeedbackReportType } from \"./contract\";\n\n/** What kind of feedback: a bug, an idea, a question or praise. */\nexport type FeedbackType = FeedbackReportType;\n\nexport const FEEDBACK_TYPES: readonly FeedbackType[] = [\"bug\", \"idea\", \"question\", \"praise\"];\n\n/** Where the person was and what they were running when they sent it. */\nexport interface FeedbackContext {\n /** The version signature, `1.4.0 (37) · preview · 3f2a9c1b`. */\n appVersion?: string;\n /** The screen, as a path. Query strings and fragments are always dropped. */\n route?: string;\n /** `ios`, `android`, `web`. */\n platform?: string;\n /** Model, OS version, locale, screen size, update id… At most 4 KB as JSON. */\n device?: Record<string, unknown>;\n /**\n * Who sent it, for sinks that need it. The Supabase sink ignores it: the\n * database takes the person from the session, never from the request.\n */\n userId?: string;\n /** Anything else small (breadcrumbs, a pin on the page). At most 16 KB as JSON. */\n extra?: Record<string, unknown>;\n}\n\nexport type ScreenshotContentType = \"image/png\" | \"image/jpeg\" | \"image/webp\";\n\n/**\n * A screenshot to attach. The record carries the bytes (or base64) and no\n * URL: the destination stores it and keeps its own key, so nothing in the\n * record expires.\n */\nexport interface FeedbackAttachment {\n kind: \"screenshot\";\n contentType: ScreenshotContentType;\n /** The image, base64 (no `data:` prefix; a data URI is accepted and stripped). */\n base64?: string;\n /** Or the bytes. */\n bytes?: Uint8Array;\n width?: number;\n height?: number;\n}\n\nexport interface FeedbackRecord {\n type: FeedbackType;\n /** What the person wrote. */\n message: string;\n title?: string;\n context: FeedbackContext;\n attachments?: FeedbackAttachment[];\n}\n\n/** Everything but what the person typed. */\nexport interface ContextCollector {\n collect(): Promise<FeedbackContext> | FeedbackContext;\n}\n\n/** What the destination kept. */\nexport interface FeedbackDelivery {\n /** The saved report's id. */\n id: string;\n /** How many attachments the destination saved the report without (an upload failed, or it takes none). */\n attachmentsDropped: number;\n}\n\n/** Takes a finished record to its destination. */\nexport interface FeedbackSink {\n submit(record: FeedbackRecord): Promise<FeedbackDelivery>;\n}\n\nexport const DEVICE_MAX_BYTES = 4096;\nexport const EXTRA_MAX_BYTES = 16384;\n\nconst jsonBytes = (value: unknown) => new TextEncoder().encode(JSON.stringify(value)).length;\n\n/** A value given as itself or as a function that reads it now. */\nexport type Lazy<T> = T | (() => T);\nconst read = <T,>(value: Lazy<T> | undefined): T | undefined => (typeof value === \"function\" ? (value as () => T)() : value);\n\nexport interface ContextCollectorOptions {\n appVersion?: Lazy<string | undefined>;\n route?: Lazy<string | undefined>;\n platform?: Lazy<string | undefined>;\n device?: Lazy<Record<string, unknown> | undefined>;\n userId?: Lazy<string | undefined>;\n extra?: Lazy<Record<string, unknown> | undefined>;\n}\n\n/**\n * The default collector: reads each value when feedback is sent, cleans the\n * route, and drops a device or extra bag over its size cap rather than\n * sending a report the database would refuse.\n */\nexport function createContextCollector(options: ContextCollectorOptions): ContextCollector {\n return {\n collect() {\n return sanitizeContext({\n appVersion: read(options.appVersion),\n route: read(options.route),\n platform: read(options.platform),\n device: read(options.device),\n userId: read(options.userId),\n extra: read(options.extra),\n });\n },\n };\n}\n\n/** Collectors merged left to right (a later one's values win; `device` and `extra` merge by key). */\nexport function composeCollectors(...collectors: ContextCollector[]): ContextCollector {\n return {\n async collect() {\n let out: FeedbackContext = {};\n for (const c of collectors) {\n const next = await c.collect();\n out = {\n ...out,\n ...dropUndefined(next),\n device: next.device || out.device ? { ...out.device, ...next.device } : undefined,\n extra: next.extra || out.extra ? { ...out.extra, ...next.extra } : undefined,\n };\n }\n return sanitizeContext(out);\n },\n };\n}\n\nfunction dropUndefined<T extends object>(value: T): Partial<T> {\n return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as Partial<T>;\n}\n\n/** The context as the database takes it: a clean route, short strings, bags under their caps. */\nexport function sanitizeContext(context: FeedbackContext): FeedbackContext {\n const out: FeedbackContext = {};\n const route = cleanRoute(context.route);\n if (route) out.route = route;\n if (context.appVersion) out.appVersion = String(context.appVersion).slice(0, 200);\n if (context.platform) out.platform = String(context.platform).slice(0, 40);\n if (context.userId) out.userId = context.userId;\n if (context.device && typeof context.device === \"object\" && jsonBytes(context.device) <= DEVICE_MAX_BYTES) out.device = context.device;\n if (context.extra && typeof context.extra === \"object\" && jsonBytes(context.extra) <= EXTRA_MAX_BYTES) out.extra = context.extra;\n return out;\n}\n\nconst B64 = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\";\nconst LOOKUP = (() => {\n const table = new Int16Array(256).fill(-1);\n for (let i = 0; i < B64.length; i++) table[B64.charCodeAt(i)] = i;\n table[\"-\".charCodeAt(0)] = 62;\n table[\"_\".charCodeAt(0)] = 63;\n return table;\n})();\n\n/** Base64 (or a `data:` URI) to bytes, without `atob` (Hermes, workers, Node all agree). */\nexport function base64ToBytes(input: string): Uint8Array {\n const comma = input.startsWith(\"data:\") ? input.indexOf(\",\") : -1;\n const clean = (comma >= 0 ? input.slice(comma + 1) : input).replace(/[\\s=]/g, \"\");\n const out = new Uint8Array(Math.floor((clean.length * 3) / 4));\n let buffer = 0;\n let bits = 0;\n let o = 0;\n for (let i = 0; i < clean.length; i++) {\n const v = LOOKUP[clean.charCodeAt(i)] ?? -1;\n if (v < 0) throw new Error(\"base64ToBytes: not base64\");\n buffer = (buffer << 6) | v;\n bits += 6;\n if (bits >= 8) {\n bits -= 8;\n out[o++] = (buffer >> bits) & 0xff;\n }\n }\n return out.subarray(0, o);\n}\n\n/** The bytes of an attachment, from `bytes` or `base64`. */\nexport function attachmentBytes(attachment: FeedbackAttachment): Uint8Array | null {\n if (attachment.bytes?.length) return attachment.bytes;\n if (attachment.base64) return base64ToBytes(attachment.base64);\n return null;\n}\n"]}
|
|
@@ -71,90 +71,6 @@ function validateSubmitInput(raw) {
|
|
|
71
71
|
return { ok: true, value };
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
// src/record.ts
|
|
75
|
-
var FEEDBACK_TYPES = ["bug", "idea", "question", "praise"];
|
|
76
|
-
var DEVICE_MAX_BYTES = 4096;
|
|
77
|
-
var EXTRA_MAX_BYTES = 16384;
|
|
78
|
-
var jsonBytes = (value) => new TextEncoder().encode(JSON.stringify(value)).length;
|
|
79
|
-
var read = (value) => typeof value === "function" ? value() : value;
|
|
80
|
-
function createContextCollector(options) {
|
|
81
|
-
return {
|
|
82
|
-
collect() {
|
|
83
|
-
return sanitizeContext({
|
|
84
|
-
appVersion: read(options.appVersion),
|
|
85
|
-
route: read(options.route),
|
|
86
|
-
platform: read(options.platform),
|
|
87
|
-
device: read(options.device),
|
|
88
|
-
userId: read(options.userId),
|
|
89
|
-
extra: read(options.extra)
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
function composeCollectors(...collectors) {
|
|
95
|
-
return {
|
|
96
|
-
async collect() {
|
|
97
|
-
let out = {};
|
|
98
|
-
for (const c of collectors) {
|
|
99
|
-
const next = await c.collect();
|
|
100
|
-
out = {
|
|
101
|
-
...out,
|
|
102
|
-
...dropUndefined(next),
|
|
103
|
-
device: next.device || out.device ? { ...out.device, ...next.device } : void 0,
|
|
104
|
-
extra: next.extra || out.extra ? { ...out.extra, ...next.extra } : void 0
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
return sanitizeContext(out);
|
|
108
|
-
}
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
function dropUndefined(value) {
|
|
112
|
-
return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== void 0));
|
|
113
|
-
}
|
|
114
|
-
function sanitizeContext(context) {
|
|
115
|
-
const out = {};
|
|
116
|
-
const route = cleanRoute(context.route);
|
|
117
|
-
if (route) out.route = route;
|
|
118
|
-
if (context.appVersion) out.appVersion = String(context.appVersion).slice(0, 200);
|
|
119
|
-
if (context.platform) out.platform = String(context.platform).slice(0, 40);
|
|
120
|
-
if (context.userId) out.userId = context.userId;
|
|
121
|
-
if (context.device && typeof context.device === "object" && jsonBytes(context.device) <= DEVICE_MAX_BYTES) out.device = context.device;
|
|
122
|
-
if (context.extra && typeof context.extra === "object" && jsonBytes(context.extra) <= EXTRA_MAX_BYTES) out.extra = context.extra;
|
|
123
|
-
return out;
|
|
124
|
-
}
|
|
125
|
-
var B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
126
|
-
var LOOKUP = (() => {
|
|
127
|
-
const table = new Int16Array(256).fill(-1);
|
|
128
|
-
for (let i = 0; i < B64.length; i++) table[B64.charCodeAt(i)] = i;
|
|
129
|
-
table["-".charCodeAt(0)] = 62;
|
|
130
|
-
table["_".charCodeAt(0)] = 63;
|
|
131
|
-
return table;
|
|
132
|
-
})();
|
|
133
|
-
function base64ToBytes(input) {
|
|
134
|
-
const comma = input.startsWith("data:") ? input.indexOf(",") : -1;
|
|
135
|
-
const clean = (comma >= 0 ? input.slice(comma + 1) : input).replace(/[\s=]/g, "");
|
|
136
|
-
const out = new Uint8Array(Math.floor(clean.length * 3 / 4));
|
|
137
|
-
let buffer = 0;
|
|
138
|
-
let bits = 0;
|
|
139
|
-
let o = 0;
|
|
140
|
-
for (let i = 0; i < clean.length; i++) {
|
|
141
|
-
const v = LOOKUP[clean.charCodeAt(i)] ?? -1;
|
|
142
|
-
if (v < 0) throw new Error("base64ToBytes: not base64");
|
|
143
|
-
buffer = buffer << 6 | v;
|
|
144
|
-
bits += 6;
|
|
145
|
-
if (bits >= 8) {
|
|
146
|
-
bits -= 8;
|
|
147
|
-
out[o++] = buffer >> bits & 255;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
return out.subarray(0, o);
|
|
151
|
-
}
|
|
152
|
-
function attachmentBytes(attachment) {
|
|
153
|
-
if (attachment.bytes?.length) return attachment.bytes;
|
|
154
|
-
if (attachment.base64) return base64ToBytes(attachment.base64);
|
|
155
|
-
return null;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
74
|
// src/copy.ts
|
|
159
75
|
var supportCopy = {
|
|
160
76
|
settings: {
|
|
@@ -228,7 +144,21 @@ var supportCopy = {
|
|
|
228
144
|
you: "You",
|
|
229
145
|
sending: "Sending",
|
|
230
146
|
notSent: "Not sent. Tap to retry.",
|
|
231
|
-
retryHint: "Double tap to send again"
|
|
147
|
+
retryHint: "Double tap to send again",
|
|
148
|
+
/** A message's file, where the thread can't show it. */
|
|
149
|
+
voiceNote: "Voice message",
|
|
150
|
+
video: "Video",
|
|
151
|
+
file: "File",
|
|
152
|
+
internalNote: "Internal note"
|
|
153
|
+
},
|
|
154
|
+
staff: {
|
|
155
|
+
status: "Status",
|
|
156
|
+
assignee: "Assigned to",
|
|
157
|
+
unassigned: "Nobody",
|
|
158
|
+
reporter: "Reported by",
|
|
159
|
+
unavailableTitle: "This conversation isn't available",
|
|
160
|
+
unavailableBody: "The report has no conversation you can read.",
|
|
161
|
+
failed: "That change wasn't saved. Try again."
|
|
232
162
|
},
|
|
233
163
|
status: {
|
|
234
164
|
open: "Open",
|
|
@@ -279,6 +209,6 @@ function fill(template, values) {
|
|
|
279
209
|
return template.replace(/\{(\w+)\}/g, (token, name) => values[name] !== void 0 ? String(values[name]) : token);
|
|
280
210
|
}
|
|
281
211
|
|
|
282
|
-
export { CONTRACT_VERSION,
|
|
283
|
-
//# sourceMappingURL=chunk-
|
|
284
|
-
//# sourceMappingURL=chunk-
|
|
212
|
+
export { CONTRACT_VERSION, FEEDBACK_REPORT_TYPES, FEEDBACK_STATUSES, LIMITS, WIDGET_HEADERS, cleanRoute, fill, resolveSupportCopy, supportCopy, validateSubmitInput };
|
|
213
|
+
//# sourceMappingURL=chunk-OSB6G6Y3.js.map
|
|
214
|
+
//# sourceMappingURL=chunk-OSB6G6Y3.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/contract.ts","../src/copy.ts"],"names":[],"mappings":"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","file":"chunk-OSB6G6Y3.js","sourcesContent":["/**\n * The Genesis support wire contract: what a support client (the web loader, a\n * native app, any HTTP caller) and the `genesis-support-intake` Edge Function\n * say to each other. One file, kept byte-identical in two places:\n * `@marktiderman/genesis-support` (`src/contract.ts`) and the function template\n * (`functions/genesis-support-intake/contract.ts`); a test fails when they drift.\n *\n * Dependency-free and additive: `CONTRACT_VERSION` changes only with a shape\n * change, and a reader tolerates fields and widget types it doesn't know, so an\n * old client keeps working.\n */\n\n/** Bumped only on a shape change. Old clients must keep working. */\nexport const CONTRACT_VERSION = 1 as const;\n\n/** The widget kinds a boot can turn on. */\nexport type WidgetType = \"feedback\" | \"roadmap\" | \"changelog\" | \"survey\" | \"messaging\";\n\n/** On the wire the type is open: a client switches on the known ones and ignores the rest. */\nexport type WidgetTypeWire = WidgetType | (string & {});\n\n/**\n * How the person is known to the server.\n * - `genesis-session`: signed in to the app's own Supabase (a user access token). The default.\n * - `signed`: the app's backend mints a short-lived token for the person (reserved).\n * - `anonymous`: a device id only (reserved; the database refuses writes without a session).\n */\nexport type IdentityMode = \"anonymous\" | \"signed\" | \"genesis-session\";\n\nexport type LauncherPosition = \"bottom-right\" | \"bottom-left\" | \"top-right\" | \"top-left\";\n\n/** Theme hints a web launcher may use. Genesis apps use their own theme instead. */\nexport interface ThemeTokens {\n colorBg?: string;\n colorFg?: string;\n colorPrimary?: string;\n colorPrimaryFg?: string;\n radius?: string;\n fontFamily?: string;\n launcherLabel?: string;\n launcherPosition?: LauncherPosition;\n}\n\n/** One enabled widget and its public config (never a secret). */\nexport interface WidgetConfig {\n type: WidgetTypeWire;\n config: Record<string, unknown>;\n}\n\n/** The boot handshake (`GET`): world-readable, so only public values. */\nexport interface BootConfig {\n contractVersion: number;\n project: { id: string; name: string };\n identityMode: IdentityMode;\n theme: ThemeTokens;\n widgets: WidgetConfig[];\n flags: Record<string, boolean>;\n}\n\nexport type FeedbackReportType = \"bug\" | \"idea\" | \"question\" | \"praise\";\nexport type FeedbackStatus = \"open\" | \"submitted\" | \"in_progress\" | \"addressed\" | \"wont_fix\";\nexport type FindingSeverity = \"low\" | \"medium\" | \"high\" | \"critical\";\n\nexport const FEEDBACK_REPORT_TYPES: readonly FeedbackReportType[] = [\"bug\", \"idea\", \"question\", \"praise\"];\nexport const FEEDBACK_STATUSES: readonly FeedbackStatus[] = [\"open\", \"submitted\", \"in_progress\", \"addressed\", \"wont_fix\"];\nconst SEVERITIES: readonly FindingSeverity[] = [\"low\", \"medium\", \"high\", \"critical\"];\n\n/** Client to server: open a report. Screenshots go to storage, not in this body. */\nexport interface SubmitFeedbackInput {\n type?: FeedbackReportType;\n title?: string;\n body: string;\n route?: string;\n appVersion?: string;\n envType?: string;\n severity?: FindingSeverity;\n tags?: string[];\n /** Small, free-form context (breadcrumbs, a pin on the page). At most 16 KB as JSON. */\n context?: Record<string, unknown>;\n}\n\n/** Server to client: the report created. `findingId` is the report's id (one finding per report here). */\nexport interface SubmitFeedbackResult {\n reportId: string;\n findingId: string;\n status: FeedbackStatus;\n}\n\n/** One of the caller's own reports. */\nexport interface FeedbackReportSummary {\n id: string;\n type: FeedbackReportType;\n title: string;\n status: FeedbackStatus;\n route: string | null;\n createdAt: string;\n findingCount: number;\n}\n\n/** The operations the intake function multiplexes on (`{ op, ...args }`). */\nexport type FeedbackOp = \"submit\" | \"list\";\n\n/** Headers a client sends. The person's access token goes in `Authorization: Bearer …`. */\nexport const WIDGET_HEADERS = {\n /** The project's publishable key (an identifier, not a secret). */\n key: \"x-genesis-key\",\n /** `signed` mode: the minted short-lived token. */\n identity: \"x-genesis-identity\",\n /** `anonymous` mode: the device id. */\n device: \"x-genesis-device\",\n /** A proof-of-work or captcha token, for anonymous writes. */\n challenge: \"x-genesis-challenge\",\n /** The contract version the client was built against. */\n contract: \"x-genesis-contract\",\n} as const;\n\n/** Text limits, the same as the database's checks. */\nexport const LIMITS = { title: 300, body: 20000, route: 2000, appVersion: 200, tag: 60, tags: 50, contextBytes: 16384 } as const;\n\n/** A path segment that names someone or something private: an email, a UUID, a number, a long token. */\nfunction privateSegment(segment: string): string | null {\n let s = segment;\n try {\n s = decodeURIComponent(segment);\n } catch {\n // a malformed escape: judge it as written\n }\n if (s.includes(\"@\")) return \":email\";\n if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s)) return \":id\";\n if (/^\\d+$/.test(s)) return \":id\";\n // A long run of letters and digits with a digit in it reads as a token or a code, not a word.\n if (s.length >= 16 && /\\d/.test(s) && /^[A-Za-z0-9_.~-]+$/.test(s)) return \":token\";\n return null;\n}\n\n/**\n * A route as a path only, safe to store and send: the query string and\n * fragment (tokens, codes, emails) are dropped, and path segments that look\n * private (an email, an id, a long token) become `:email`, `:id` or `:token`\n * (`/users/jane@x.test/reset/abc123…` → `/users/:email/reset/:token`).\n */\nexport function cleanRoute(route: string | null | undefined): string | undefined {\n if (typeof route !== \"string\") return undefined;\n const path = route.replace(/[?#].*$/, \"\").trim();\n if (!path) return undefined;\n const safe = path\n .split(\"/\")\n .map((segment) => (segment ? (privateSegment(segment) ?? segment) : segment))\n .join(\"/\");\n return safe.slice(0, LIMITS.route);\n}\n\nexport type SubmitValidation =\n | { ok: true; value: SubmitFeedbackInput }\n | { ok: false; error: \"invalid_args\" | \"payload_too_large\"; field: string };\n\nconst byteLength = (s: string) => new TextEncoder().encode(s).length;\n\n/** Checks a `submit` body the way the server does: known keys only, every limit. */\nexport function validateSubmitInput(raw: unknown): SubmitValidation {\n if (!raw || typeof raw !== \"object\" || Array.isArray(raw)) return { ok: false, error: \"invalid_args\", field: \"body\" };\n const input = raw as Record<string, unknown>;\n const known = new Set([\"type\", \"title\", \"body\", \"route\", \"appVersion\", \"envType\", \"severity\", \"tags\", \"context\"]);\n for (const key of Object.keys(input)) if (!known.has(key)) return { ok: false, error: \"invalid_args\", field: key };\n const optionalString = (key: string, max: number): boolean => input[key] == null || (typeof input[key] === \"string\" && (input[key] as string).length <= max);\n if (typeof input.body !== \"string\" || !input.body.trim()) return { ok: false, error: \"invalid_args\", field: \"body\" };\n if (input.body.length > LIMITS.body) return { ok: false, error: \"payload_too_large\", field: \"body\" };\n if (input.type != null && !FEEDBACK_REPORT_TYPES.includes(input.type as FeedbackReportType)) return { ok: false, error: \"invalid_args\", field: \"type\" };\n if (input.severity != null && !SEVERITIES.includes(input.severity as FindingSeverity)) return { ok: false, error: \"invalid_args\", field: \"severity\" };\n if (!optionalString(\"title\", LIMITS.title)) return { ok: false, error: \"invalid_args\", field: \"title\" };\n if (!optionalString(\"route\", LIMITS.route)) return { ok: false, error: \"invalid_args\", field: \"route\" };\n if (!optionalString(\"appVersion\", LIMITS.appVersion)) return { ok: false, error: \"invalid_args\", field: \"appVersion\" };\n if (!optionalString(\"envType\", 60)) return { ok: false, error: \"invalid_args\", field: \"envType\" };\n if (input.tags != null) {\n if (!Array.isArray(input.tags) || input.tags.length > LIMITS.tags) return { ok: false, error: \"invalid_args\", field: \"tags\" };\n if (!input.tags.every((t) => typeof t === \"string\" && t.length <= LIMITS.tag)) return { ok: false, error: \"invalid_args\", field: \"tags\" };\n }\n if (input.context != null) {\n if (typeof input.context !== \"object\" || Array.isArray(input.context)) return { ok: false, error: \"invalid_args\", field: \"context\" };\n if (byteLength(JSON.stringify(input.context)) > LIMITS.contextBytes) return { ok: false, error: \"payload_too_large\", field: \"context\" };\n }\n const value: SubmitFeedbackInput = { body: input.body };\n if (input.type != null) value.type = input.type as FeedbackReportType;\n if (input.title != null) value.title = input.title as string;\n const route = cleanRoute(input.route as string | undefined);\n if (route) value.route = route;\n if (input.appVersion != null) value.appVersion = input.appVersion as string;\n if (input.envType != null) value.envType = input.envType as string;\n if (input.severity != null) value.severity = input.severity as FindingSeverity;\n if (input.tags != null) value.tags = input.tags as string[];\n if (input.context != null) value.context = input.context as Record<string, unknown>;\n return { ok: true, value };\n}\n","/**\n * Every string the support block shows, in English. Pass `copy` (any part of\n * this shape) to translate or reword; what you leave out stays English.\n */\nexport const supportCopy = {\n settings: {\n section: \"Support\",\n sendFeedback: \"Send feedback\",\n help: \"Help\",\n /** `{count}` new replies. */\n unreadReplies: \"{count} new\",\n },\n feedback: {\n title: \"Send feedback\",\n typeLabel: \"What is it?\",\n types: { bug: \"Bug\", idea: \"Idea\", question: \"Question\", praise: \"Praise\" },\n messageLabel: \"Tell us more\",\n messagePlaceholder: \"What happened, and what did you expect?\",\n screenshotLabel: \"Screenshot\",\n includeScreenshot: \"Include this screenshot\",\n screenshotHint: \"Sent only if you include it. Mark it up to hide anything private first.\",\n annotate: \"Mark up\",\n pin: \"Point to it on the page\",\n pinned: \"Pinned\",\n removeScreenshot: \"Remove\",\n send: \"Send\",\n sending: \"Sending\",\n sentTitle: \"Thanks for the feedback\",\n sentBody: \"We read every report. You'll find it, and our replies, under Help.\",\n sentWithoutScreenshot: \"Your report was sent, but its screenshot couldn't be.\",\n failedTitle: \"Your feedback wasn't sent\",\n failedBody: \"Check your connection and try again.\",\n rateLimited: \"You've sent a lot of feedback in a short time. Try again later.\",\n signInFirst: \"Sign in to send feedback.\",\n messageRequired: \"Write a few words first.\",\n },\n annotate: {\n title: \"Mark up\",\n pen: \"Pen\",\n hide: \"Hide\",\n undo: \"Undo\",\n done: \"Done\",\n cancel: \"Cancel\",\n hint: \"Draw to point something out. Use Hide to cover anything private.\",\n failed: \"The marks couldn't be saved. Try again, or cancel and leave the screenshot out.\",\n },\n inbox: {\n title: \"Help\",\n sectionTitle: \"Your reports\",\n emptyTitle: \"No reports yet\",\n emptyBody: \"When you send feedback it shows up here, with our replies.\",\n errorTitle: \"Couldn't load your reports\",\n errorBody: \"Check your connection, then pull to refresh.\",\n sendFeedback: \"Send feedback\",\n ideaBoard: \"Idea board\",\n shipped: \"You asked, we shipped\",\n delete: \"Delete\",\n deleteConfirmTitle: \"Delete this report?\",\n deleteConfirmBody: \"The report, its replies and its screenshots are deleted and can't be opened again.\",\n cancel: \"Cancel\",\n unread: \"New reply\",\n },\n thread: {\n title: \"Report\",\n placeholder: \"Reply\",\n send: \"Send\",\n read: \"Read\",\n support: \"Support\",\n closed: \"This report is closed. Send new feedback if something's still wrong.\",\n emptyTitle: \"No replies yet\",\n emptyBody: \"We'll answer here.\",\n screenshot: \"Screenshot\",\n notFound: \"That report isn't there any more.\",\n you: \"You\",\n sending: \"Sending\",\n notSent: \"Not sent. Tap to retry.\",\n retryHint: \"Double tap to send again\",\n /** A message's file, where the thread can't show it. */\n voiceNote: \"Voice message\",\n video: \"Video\",\n file: \"File\",\n internalNote: \"Internal note\",\n },\n staff: {\n status: \"Status\",\n assignee: \"Assigned to\",\n unassigned: \"Nobody\",\n reporter: \"Reported by\",\n unavailableTitle: \"This conversation isn't available\",\n unavailableBody: \"The report has no conversation you can read.\",\n failed: \"That change wasn't saved. Try again.\",\n },\n status: {\n open: \"Open\",\n submitted: \"Received\",\n in_progress: \"In progress\",\n addressed: \"Fixed\",\n wont_fix: \"Closed\",\n },\n ideas: {\n title: \"Idea board\",\n emptyTitle: \"No ideas yet\",\n emptyBody: \"Suggest the first one.\",\n suggest: \"Suggest an idea\",\n suggestTitle: \"Suggest an idea\",\n titleLabel: \"Your idea\",\n titlePlaceholder: \"A short title\",\n bodyLabel: \"Why it would help (optional)\",\n submit: \"Suggest\",\n vote: \"Vote\",\n voted: \"Voted\",\n /** `{count}` votes. */\n votes: \"{count} votes\",\n oneVote: \"1 vote\",\n statuses: { open: \"Open\", planned: \"Planned\", in_progress: \"In progress\", shipped: \"Shipped\", declined: \"Not planned\", hidden: \"Hidden\" },\n },\n shipped: {\n title: \"You asked, we shipped\",\n emptyTitle: \"Nothing yet\",\n emptyBody: \"When an idea you suggested or voted for ships, it shows up here.\",\n /** `{feature}`: the FeatureId it shipped as. */\n shippedAs: \"Shipped as {feature}\",\n },\n};\n\n/** The shape a translation fills. */\nexport type SupportCopy = typeof supportCopy;\n\n/** Any part of the catalog. */\nexport type SupportCopyOverrides = { [K in keyof SupportCopy]?: DeepPartial<SupportCopy[K]> };\ntype DeepPartial<T> = { [K in keyof T]?: T[K] extends string ? string : DeepPartial<T[K]> };\n\n/** The catalog with overrides merged over the English. */\nexport function resolveSupportCopy(overrides: SupportCopyOverrides = {}): SupportCopy {\n const merge = (base: Record<string, unknown>, over: Record<string, unknown> | undefined): Record<string, unknown> => {\n if (!over) return base;\n const out: Record<string, unknown> = { ...base };\n for (const [key, value] of Object.entries(over)) {\n if (value === undefined) continue;\n const current = base[key];\n out[key] = current && typeof current === \"object\" && value && typeof value === \"object\" ? merge(current as Record<string, unknown>, value as Record<string, unknown>) : value;\n }\n return out;\n };\n return merge(supportCopy as unknown as Record<string, unknown>, overrides as Record<string, unknown>) as unknown as SupportCopy;\n}\n\n/** Fills `{name}` placeholders. */\nexport function fill(template: string, values: Readonly<Record<string, string | number>>): string {\n return template.replace(/\\{(\\w+)\\}/g, (token, name: string) => (values[name] !== undefined ? String(values[name]) : token));\n}\n"]}
|
|
@@ -1,6 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { sanitizeContext, attachmentBytes } from './chunk-MMBNG2NK.js';
|
|
2
|
+
import { resolveSupportCopy } from './chunk-OSB6G6Y3.js';
|
|
2
3
|
import { defineBlock } from '@marktiderman/genesis-features/blocks';
|
|
3
4
|
|
|
5
|
+
// src/chat.ts
|
|
6
|
+
var SUPPORT_CHAT_BLOCK_ID = "support-chat";
|
|
7
|
+
var SUPPORT_CHAT_KIND = "support";
|
|
8
|
+
var SUPPORT_CHAT_SUBJECT = "support_report";
|
|
9
|
+
var SUPPORT_TO_CHAT_STATUS = {
|
|
10
|
+
open: "open",
|
|
11
|
+
submitted: "open",
|
|
12
|
+
in_progress: "pending",
|
|
13
|
+
addressed: "solved",
|
|
14
|
+
wont_fix: "closed"
|
|
15
|
+
};
|
|
16
|
+
function isMissingFunction(error) {
|
|
17
|
+
if (!error) return false;
|
|
18
|
+
return error.code === "PGRST202" || error.code === "42883";
|
|
19
|
+
}
|
|
20
|
+
|
|
4
21
|
// src/client.ts
|
|
5
22
|
var SUPPORT_BUCKET = "genesis-support";
|
|
6
23
|
var SupportError = class extends Error {
|
|
@@ -21,7 +38,7 @@ function must(operation, result) {
|
|
|
21
38
|
if (result.error) throw new SupportError(result.error.message, result.error.code, operation);
|
|
22
39
|
return result.data;
|
|
23
40
|
}
|
|
24
|
-
var REPORT_COLUMNS = "id, type, title, message, status, feature_id, route, app_version, created_at, updated_at, last_staff_reply_at, last_user_reply_at, user_read_at, staff_read_at";
|
|
41
|
+
var REPORT_COLUMNS = "id, user_id, type, title, message, status, feature_id, route, app_version, created_at, updated_at, last_staff_reply_at, last_user_reply_at, user_read_at, staff_read_at";
|
|
25
42
|
var IDEA_COLUMNS = "id, title, body, status, feature_id, shipped_at, vote_count, created_at";
|
|
26
43
|
function isUnread(row) {
|
|
27
44
|
if (!row.lastStaffReplyAt) return false;
|
|
@@ -42,7 +59,8 @@ function toReport(r) {
|
|
|
42
59
|
lastStaffReplyAt: r.last_staff_reply_at ?? null,
|
|
43
60
|
lastUserReplyAt: r.last_user_reply_at ?? null,
|
|
44
61
|
userReadAt: r.user_read_at ?? null,
|
|
45
|
-
staffReadAt: r.staff_read_at ?? null
|
|
62
|
+
staffReadAt: r.staff_read_at ?? null,
|
|
63
|
+
userId: r.user_id ?? null
|
|
46
64
|
};
|
|
47
65
|
return { ...report, unread: isUnread(report) };
|
|
48
66
|
}
|
|
@@ -61,6 +79,15 @@ function toIdea(r, mine, voted) {
|
|
|
61
79
|
};
|
|
62
80
|
}
|
|
63
81
|
var EXTENSIONS = { "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp" };
|
|
82
|
+
function randomUuid() {
|
|
83
|
+
const c = globalThis.crypto;
|
|
84
|
+
if (c?.randomUUID) return c.randomUUID();
|
|
85
|
+
const hex = Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16));
|
|
86
|
+
hex[12] = "4";
|
|
87
|
+
hex[16] = (parseInt(hex[16], 16) & 3 | 8).toString(16);
|
|
88
|
+
const h = hex.join("");
|
|
89
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
|
90
|
+
}
|
|
64
91
|
function defaultRandomId() {
|
|
65
92
|
const c = globalThis.crypto;
|
|
66
93
|
if (c?.randomUUID) return c.randomUUID();
|
|
@@ -74,6 +101,46 @@ function createSupportClient(supabase, options = {}) {
|
|
|
74
101
|
const { data } = await supabase.auth.getSession();
|
|
75
102
|
return data.session?.user?.id ?? null;
|
|
76
103
|
};
|
|
104
|
+
const chat = options.chat || null;
|
|
105
|
+
let chatInstalled = chat ? null : false;
|
|
106
|
+
const conversations = /* @__PURE__ */ new Map();
|
|
107
|
+
const warn = options.onChatUnavailable ?? ((message) => console.warn(message));
|
|
108
|
+
async function thread(reportId) {
|
|
109
|
+
if (!chat || chatInstalled === false) return { mode: "replies", conversationId: null };
|
|
110
|
+
const known = conversations.get(reportId);
|
|
111
|
+
if (known) return { mode: "chat", conversationId: known };
|
|
112
|
+
const result = await supabase.rpc("support_chat_conversation", { report: reportId });
|
|
113
|
+
if (result.error) {
|
|
114
|
+
if (isMissingFunction(result.error)) {
|
|
115
|
+
chatInstalled = false;
|
|
116
|
+
warn(
|
|
117
|
+
"genesis-support: a chat client was given, but the support-chat block's migration (0001_genesis_support_chat.sql) isn't applied. Report threads use support_replies."
|
|
118
|
+
);
|
|
119
|
+
return { mode: "replies", conversationId: null };
|
|
120
|
+
}
|
|
121
|
+
throw new SupportError(result.error.message, result.error.code, "thread");
|
|
122
|
+
}
|
|
123
|
+
chatInstalled = true;
|
|
124
|
+
const id = typeof result.data === "string" && result.data ? result.data : null;
|
|
125
|
+
if (!id) return { mode: "replies", conversationId: null };
|
|
126
|
+
conversations.set(reportId, id);
|
|
127
|
+
return { mode: "chat", conversationId: id };
|
|
128
|
+
}
|
|
129
|
+
async function chatReply(reportId, m, userId) {
|
|
130
|
+
const paths = m.attachments.map((a) => a.path).filter((p) => Boolean(p));
|
|
131
|
+
const signed = paths.length && chat ? await chat.fileUrls(paths).catch(() => ({})) : {};
|
|
132
|
+
return {
|
|
133
|
+
id: m.id,
|
|
134
|
+
reportId,
|
|
135
|
+
body: m.body,
|
|
136
|
+
fromStaff: m.senderRole === "observer",
|
|
137
|
+
mine: Boolean(userId) && m.senderId === userId,
|
|
138
|
+
createdAt: m.createdAt,
|
|
139
|
+
internal: m.visibility === "observers",
|
|
140
|
+
seq: m.seq,
|
|
141
|
+
attachments: m.attachments.map((a) => ({ ...a, url: (a.path ? signed[a.path] : null) ?? a.url ?? null }))
|
|
142
|
+
};
|
|
143
|
+
}
|
|
77
144
|
const client = {
|
|
78
145
|
async submit(record) {
|
|
79
146
|
const userId = await me();
|
|
@@ -133,6 +200,12 @@ function createSupportClient(supabase, options = {}) {
|
|
|
133
200
|
return row ? toReport(row) : null;
|
|
134
201
|
},
|
|
135
202
|
async replies(reportId) {
|
|
203
|
+
const where = await thread(reportId);
|
|
204
|
+
if (where.mode === "chat" && chat) {
|
|
205
|
+
const [page, userId] = await Promise.all([chat.messages(where.conversationId, { limit: 200 }), me()]);
|
|
206
|
+
const shown = page.messages.filter((m) => m.kind === "text" && !m.deletedAt);
|
|
207
|
+
return Promise.all(shown.map((m) => chatReply(reportId, m, userId)));
|
|
208
|
+
}
|
|
136
209
|
const rows = must(
|
|
137
210
|
"replies",
|
|
138
211
|
await supabase.from("support_replies").select("id, report_id, body, from_staff, created_at").eq("report_id", reportId).order("created_at", { ascending: true })
|
|
@@ -146,7 +219,18 @@ function createSupportClient(supabase, options = {}) {
|
|
|
146
219
|
createdAt: String(r.created_at)
|
|
147
220
|
}));
|
|
148
221
|
},
|
|
149
|
-
async reply(reportId, body) {
|
|
222
|
+
async reply(reportId, body, { internal = false } = {}) {
|
|
223
|
+
const where = await thread(reportId);
|
|
224
|
+
if (where.mode === "chat" && chat) {
|
|
225
|
+
const [sent, userId] = await Promise.all([
|
|
226
|
+
chat.send({ conversationId: where.conversationId, clientId: randomUuid(), body, visibility: internal ? "observers" : "everyone" }).catch((error) => {
|
|
227
|
+
throw new SupportError(error?.message ?? "Not sent", error?.code, "reply");
|
|
228
|
+
}),
|
|
229
|
+
me()
|
|
230
|
+
]);
|
|
231
|
+
return chatReply(reportId, sent, userId);
|
|
232
|
+
}
|
|
233
|
+
if (internal) throw new SupportError("Internal notes need the support-chat block.", "0A000", "reply");
|
|
150
234
|
const row = must(
|
|
151
235
|
"reply",
|
|
152
236
|
await supabase.from("support_replies").insert({ report_id: reportId, body }).select("id, report_id, body, from_staff, created_at").single()
|
|
@@ -154,8 +238,42 @@ function createSupportClient(supabase, options = {}) {
|
|
|
154
238
|
return { id: String(row.id), reportId: String(row.report_id), body: String(row.body), fromStaff: row.from_staff === true, mine: true, createdAt: String(row.created_at) };
|
|
155
239
|
},
|
|
156
240
|
async markRead(reportId) {
|
|
241
|
+
const where = await thread(reportId);
|
|
242
|
+
if (where.mode === "chat" && chat) {
|
|
243
|
+
await chat.markRead(where.conversationId);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
157
246
|
must("markRead", await supabase.rpc("support_mark_read", { report: reportId }));
|
|
158
247
|
},
|
|
248
|
+
thread,
|
|
249
|
+
async conversationId(reportId) {
|
|
250
|
+
return (await thread(reportId)).conversationId;
|
|
251
|
+
},
|
|
252
|
+
subscribe(reportId, onChange) {
|
|
253
|
+
let stopped = false;
|
|
254
|
+
let stop = () => {
|
|
255
|
+
};
|
|
256
|
+
void (async () => {
|
|
257
|
+
const where = await thread(reportId);
|
|
258
|
+
if (where.mode !== "chat" || !chat || stopped) return;
|
|
259
|
+
const sub = chat.subscribeConversation(where.conversationId, await me() ?? "", { onSignal: () => onChange(), onJoin: () => onChange() });
|
|
260
|
+
if (stopped) sub.unsubscribe();
|
|
261
|
+
else stop = () => sub.unsubscribe();
|
|
262
|
+
})().catch(() => void 0);
|
|
263
|
+
return () => {
|
|
264
|
+
stopped = true;
|
|
265
|
+
stop();
|
|
266
|
+
};
|
|
267
|
+
},
|
|
268
|
+
async isStaff() {
|
|
269
|
+
return must("isStaff", await supabase.rpc("support_is_staff")) === true;
|
|
270
|
+
},
|
|
271
|
+
async setStatus(reportId, status, featureId = null) {
|
|
272
|
+
must("setStatus", await supabase.rpc("support_set_report_status", { report: reportId, status, feature: featureId }));
|
|
273
|
+
},
|
|
274
|
+
async assign(reportId, userId) {
|
|
275
|
+
must("assign", await supabase.rpc("support_assign_report", { report: reportId, assignee: userId }));
|
|
276
|
+
},
|
|
159
277
|
async deleteReport(reportId) {
|
|
160
278
|
const rows = must("deleteReport", await supabase.from("support_reports").delete().eq("id", reportId).select("id"));
|
|
161
279
|
if (!rows.length) throw new SupportError("That report wasn't deleted.", "42501", "deleteReport");
|
|
@@ -272,7 +390,18 @@ function defineSupportBlock(options = {}) {
|
|
|
272
390
|
contributes: supportContributions(options)
|
|
273
391
|
});
|
|
274
392
|
}
|
|
393
|
+
function defineSupportChatBlock(options = {}) {
|
|
394
|
+
return defineBlock({
|
|
395
|
+
id: SUPPORT_CHAT_BLOCK_ID,
|
|
396
|
+
requires: options.requires ?? [SUPPORT_BLOCK_ID, "chat"],
|
|
397
|
+
contributes: {
|
|
398
|
+
migrations: [
|
|
399
|
+
{ package: "@marktiderman/genesis-support", file: "migrations/0001_genesis_support_chat.sql", test: "migrations/tests/0001_genesis_support_chat.test.sql" }
|
|
400
|
+
]
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
}
|
|
275
404
|
|
|
276
|
-
export { SUPPORT_BLOCK_ID, SUPPORT_BUCKET, SupportError, createSupabaseSink, createSupportClient, defineSupportBlock, isUnread, supportContributions, supportPaths };
|
|
277
|
-
//# sourceMappingURL=chunk-
|
|
278
|
-
//# sourceMappingURL=chunk-
|
|
405
|
+
export { SUPPORT_BLOCK_ID, SUPPORT_BUCKET, SUPPORT_CHAT_BLOCK_ID, SUPPORT_CHAT_KIND, SUPPORT_CHAT_SUBJECT, SUPPORT_TO_CHAT_STATUS, SupportError, createSupabaseSink, createSupportClient, defineSupportBlock, defineSupportChatBlock, isMissingFunction, isUnread, supportContributions, supportPaths };
|
|
406
|
+
//# sourceMappingURL=chunk-QNUAKW7K.js.map
|
|
407
|
+
//# sourceMappingURL=chunk-QNUAKW7K.js.map
|