create-feedbax 0.2.0 → 0.2.2
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 +17 -5
- package/dist/cli.js +2610 -291
- package/dist/index.js +2375 -200
- package/package.json +7 -7
- package/template/.feedbax-template.sha256 +1 -1
- package/template/.gitignore.template +1 -0
- package/template/.npmignore.template +1 -0
- package/template/.nvmrc +1 -0
- package/template/AGENTS.md +13 -0
- package/template/CONNECTED.md +418 -0
- package/template/README.md +97 -0
- package/template/components.json +1 -1
- package/template/eslint.config.mjs +23 -0
- package/template/feedbax-vite.ts +79 -0
- package/template/migrations/0001_connected.sql +6 -0
- package/template/migrations/0002_participation.sql +8 -0
- package/template/migrations/0003_images.sql +3 -0
- package/template/migrations/0004_followed_status.sql +9 -0
- package/template/migrations/0005_github.sql +4 -0
- package/template/migrations/0006_integration.sql +3 -0
- package/template/migrations/0007_ownership.sql +2 -0
- package/template/migrations/0008_integration_recovery.sql +5 -0
- package/template/migrations/0009_observed_history.sql +3 -0
- package/template/package.json +39 -34
- package/template/src/authorized-draft-post.tsx +1 -0
- package/template/src/changelog-page.tsx +63 -54
- package/template/src/changelog-runtime.ts +2 -1
- package/template/src/cloudflare-workers.d.ts +9 -0
- package/template/src/comment-creation.test.tsx +95 -7
- package/template/src/comment-thread-page.ts +11 -1
- package/template/src/components/command-palette.tsx +59 -30
- package/template/src/components/connected-identity-provider.tsx +135 -0
- package/template/src/components/device-profile-provider.tsx +41 -26
- package/template/src/components/follow-post-button.tsx +78 -0
- package/template/src/components/header.tsx +2 -0
- package/template/src/components/image-attachments.tsx +187 -0
- package/template/src/components/loader.tsx +48 -5
- package/template/src/components/notification-inbox.tsx +232 -0
- package/template/src/components/portal-footer.tsx +14 -0
- package/template/src/components/post-history.tsx +101 -0
- package/template/src/components/profile-menu.tsx +37 -0
- package/template/src/components/ui/components/command.tsx +30 -29
- package/template/src/components/ui/components/message-scroller.tsx +2 -2
- package/template/src/components/ui/components/scroll-area.tsx +0 -1
- package/template/src/components/ui/components/switch.tsx +29 -0
- package/template/src/components/ui/styles/globals.css +1 -1
- package/template/src/configured-feedback.test.ts +82 -0
- package/template/src/configured-feedback.ts +61 -0
- package/template/src/connected-identity.test.ts +148 -0
- package/template/src/connected-identity.ts +127 -0
- package/template/src/connected-participation.test.tsx +67 -0
- package/template/src/connected-runtime.test.ts +244 -0
- package/template/src/connected-runtime.ts +176 -0
- package/template/src/connected-social.test.tsx +158 -0
- package/template/src/feedback-cache-invalidation.ts +3 -0
- package/template/src/feedback-runtime.ts +123 -13
- package/template/src/feedbax/changelog/index.test.ts +35 -0
- package/template/src/feedbax/changelog/index.ts +5 -1
- package/template/src/feedbax/changelog/notion-changelog.test.ts +1 -1
- package/template/src/feedbax/changelog/notion-changelog.ts +1 -1
- package/template/src/feedbax/config/index.test.ts +73 -1
- package/template/src/feedbax/config/index.ts +205 -9
- package/template/src/feedbax/feedback/comment-storage.ts +38 -0
- package/template/src/feedbax/feedback/connected-feedback.ts +235 -0
- package/template/src/feedbax/feedback/connected.test.ts +272 -0
- package/template/src/feedbax/feedback/connected.ts +225 -0
- package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
- package/template/src/feedbax/feedback/email-delivery.ts +66 -0
- package/template/src/feedbax/feedback/feedback-storage.ts +1 -0
- package/template/src/feedbax/feedback/github-comments.ts +390 -0
- package/template/src/feedbax/feedback/github-feedback.test.ts +1138 -0
- package/template/src/feedbax/feedback/github-feedback.ts +581 -0
- package/template/src/feedbax/feedback/github-request.ts +120 -0
- package/template/src/feedbax/feedback/github-service-client.ts +56 -0
- package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
- package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
- package/template/src/feedbax/feedback/image-feedback.ts +114 -0
- package/template/src/feedbax/feedback/images.test.ts +223 -0
- package/template/src/feedbax/feedback/images.ts +350 -0
- package/template/src/feedbax/feedback/index.ts +226 -16
- package/template/src/feedbax/feedback/integration-auth.ts +138 -0
- package/template/src/feedbax/feedback/notion-comments.ts +2 -35
- package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
- package/template/src/feedbax/feedback/observed-history.test.ts +150 -0
- package/template/src/feedbax/feedback/observed-history.ts +219 -0
- package/template/src/feedbax/feedback/ownership.ts +170 -0
- package/template/src/feedbax/feedback/participation.test.ts +653 -0
- package/template/src/feedbax/feedback/participation.ts +502 -0
- package/template/src/history-runtime.test.ts +80 -0
- package/template/src/history-runtime.ts +37 -0
- package/template/src/images-runtime.test.ts +133 -0
- package/template/src/images-runtime.ts +125 -0
- package/template/src/index.css +103 -440
- package/template/src/integration-runtime.test.ts +365 -0
- package/template/src/integration-runtime.ts +289 -0
- package/template/src/lazy-post-detail-overlay.tsx +13 -2
- package/template/src/my-feedback-page.test.tsx +152 -0
- package/template/src/my-feedback-page.tsx +398 -0
- package/template/src/my-feedback-runtime.test.ts +93 -0
- package/template/src/my-feedback-runtime.ts +154 -0
- package/template/src/overlay-post-detail.test.tsx +59 -8
- package/template/src/overlay-post-detail.tsx +20 -69
- package/template/src/participation-client.ts +63 -0
- package/template/src/participation-runtime.test.ts +35 -0
- package/template/src/participation-runtime.ts +255 -0
- package/template/src/portal-comments.ts +35 -5
- package/template/src/portal-feedback-form.tsx +29 -6
- package/template/src/portal-feedback-runtime.ts +3 -0
- package/template/src/portal-feedback-server-function.ts +6 -3
- package/template/src/portal-feedback-submission.ts +19 -0
- package/template/src/portal-server-entry.ts +13 -0
- package/template/src/post-creation-flow.tsx +2 -2
- package/template/src/post-detail-content.test.tsx +233 -0
- package/template/src/post-detail-content.tsx +162 -0
- package/template/src/post-detail-query.ts +34 -0
- package/template/src/post-results.tsx +1 -1
- package/template/src/public-cache-policy.test.ts +11 -4
- package/template/src/public-cache-policy.ts +1 -1
- package/template/src/public-feedback-index.tsx +9 -39
- package/template/src/public-feedback-page.test.tsx +65 -0
- package/template/src/public-feedback-page.ts +12 -1
- package/template/src/public-portal-error.tsx +1 -1
- package/template/src/public-post-detail-skeleton.tsx +54 -0
- package/template/src/public-post-detail.tsx +437 -271
- package/template/src/public-post-route.test.tsx +39 -3
- package/template/src/public-post-route.tsx +15 -28
- package/template/src/public-release-links.test.ts +120 -0
- package/template/src/public-release-links.ts +130 -0
- package/template/src/public-roadmap-route.test.tsx +38 -2
- package/template/src/public-roadmap-view.tsx +56 -23
- package/template/src/public-shell.test.tsx +26 -0
- package/template/src/routeTree.gen.ts +70 -3
- package/template/src/router.tsx +2 -0
- package/template/src/routes/__root.tsx +3 -5
- package/template/src/routes/email-preferences.tsx +77 -0
- package/template/src/routes/my-feedback.tsx +16 -0
- package/template/src/routes/submit.tsx +1 -1
- package/template/src/safe-public-failure.ts +3 -1
- package/template/src/server.ts +87 -2
- package/template/src/submission-intent.ts +26 -0
- package/template/src/trusted-feedback-handler.test.ts +1 -1
- package/template/src/vote-toggle.tsx +42 -9
- package/template/vite.config.ts +2 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import js from "@eslint/js";
|
|
2
|
+
import { plugin as shadcn } from "@shadcn/lint";
|
|
2
3
|
import globals from "globals";
|
|
3
4
|
import tseslint from "typescript-eslint";
|
|
4
5
|
|
|
@@ -17,9 +18,31 @@ export default tseslint.config(
|
|
|
17
18
|
js.configs.recommended,
|
|
18
19
|
...tseslint.configs.recommended,
|
|
19
20
|
{
|
|
21
|
+
plugins: { shadcn },
|
|
20
22
|
languageOptions: {
|
|
21
23
|
globals: { ...globals.browser, ...globals.node },
|
|
22
24
|
parserOptions: { tsconfigRootDir: import.meta.dirname },
|
|
23
25
|
},
|
|
24
26
|
},
|
|
27
|
+
{
|
|
28
|
+
files: ["**/*.{jsx,tsx}"],
|
|
29
|
+
rules: {
|
|
30
|
+
"shadcn/no-raw-colors": "error",
|
|
31
|
+
// Cloudflare's script discovers these challenge containers by class name.
|
|
32
|
+
"shadcn/no-unknown-classes": ["error", { allow: ["cf-turnstile"] }],
|
|
33
|
+
"shadcn/require-static-classes": "error",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
// Shared primitives own their class forwarding; application usage is checked.
|
|
38
|
+
files: ["packages/ui/src/components/**", "src/components/ui/**"],
|
|
39
|
+
rules: { "shadcn/require-static-classes": "off" },
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
// The upstream Sonner wrapper keeps this marker for toast customization.
|
|
43
|
+
files: ["**/components/sonner.tsx"],
|
|
44
|
+
rules: {
|
|
45
|
+
"shadcn/no-unknown-classes": ["error", { allow: ["toaster"] }],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
25
48
|
);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { build } from "esbuild";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import type { Plugin, ResolvedConfig } from "vite";
|
|
5
|
+
|
|
6
|
+
// Only the server receives provider identifiers and operational configuration.
|
|
7
|
+
// The client gets a fixed allowlist, even when copied configuration is customized.
|
|
8
|
+
export function publicFeedbaxConfiguration(): Plugin {
|
|
9
|
+
let configuration: ResolvedConfig;
|
|
10
|
+
return {
|
|
11
|
+
name: "feedbax-public-configuration",
|
|
12
|
+
enforce: "pre",
|
|
13
|
+
configResolved(value) {
|
|
14
|
+
configuration = value;
|
|
15
|
+
},
|
|
16
|
+
async transform(code, id) {
|
|
17
|
+
const root = configuration.root;
|
|
18
|
+
if (
|
|
19
|
+
this.environment.name !== "client" ||
|
|
20
|
+
id.split("?")[0] !== resolve(root, "src/feedbax.ts")
|
|
21
|
+
)
|
|
22
|
+
return;
|
|
23
|
+
const localConfig = resolve(root, "src/feedbax/config/index.ts");
|
|
24
|
+
const result = await build({
|
|
25
|
+
stdin: {
|
|
26
|
+
contents: code,
|
|
27
|
+
loader: "ts",
|
|
28
|
+
resolveDir: resolve(root, "src"),
|
|
29
|
+
},
|
|
30
|
+
bundle: true,
|
|
31
|
+
write: false,
|
|
32
|
+
platform: "node",
|
|
33
|
+
format: "esm",
|
|
34
|
+
alias: {
|
|
35
|
+
"@feedbax/config": existsSync(localConfig)
|
|
36
|
+
? localConfig
|
|
37
|
+
: resolve(root, "../../packages/config/src/index.ts"),
|
|
38
|
+
},
|
|
39
|
+
define: { "import.meta.env": JSON.stringify(configuration.env) },
|
|
40
|
+
});
|
|
41
|
+
const full = (
|
|
42
|
+
await import(
|
|
43
|
+
`data:text/javascript;base64,${Buffer.from(result.outputFiles[0]!.text).toString("base64")}`
|
|
44
|
+
)
|
|
45
|
+
).default;
|
|
46
|
+
const publicConfiguration = {
|
|
47
|
+
product: full.product,
|
|
48
|
+
theme: full.theme,
|
|
49
|
+
features: full.features,
|
|
50
|
+
submissions: full.submissions,
|
|
51
|
+
turnstile: full.turnstile,
|
|
52
|
+
history: full.history,
|
|
53
|
+
...(full.connected
|
|
54
|
+
? {
|
|
55
|
+
connected: {
|
|
56
|
+
issuer: full.connected.issuer,
|
|
57
|
+
loginUrl: full.connected.loginUrl,
|
|
58
|
+
images: full.connected.images,
|
|
59
|
+
...(full.connected.email
|
|
60
|
+
? { email: { provider: full.connected.email.provider } }
|
|
61
|
+
: {}),
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
: {}),
|
|
65
|
+
};
|
|
66
|
+
const publicCode = `export default ${JSON.stringify(publicConfiguration)};`;
|
|
67
|
+
return {
|
|
68
|
+
code: publicCode,
|
|
69
|
+
map: {
|
|
70
|
+
version: 3,
|
|
71
|
+
sources: ["feedbax-public-configuration.ts"],
|
|
72
|
+
sourcesContent: [publicCode],
|
|
73
|
+
names: [],
|
|
74
|
+
mappings: "",
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS participants (subject TEXT PRIMARY KEY NOT NULL, display_name TEXT NOT NULL);
|
|
2
|
+
CREATE TABLE IF NOT EXISTS identity_nonces (nonce TEXT PRIMARY KEY NOT NULL, expires_at INTEGER NOT NULL);
|
|
3
|
+
CREATE TABLE IF NOT EXISTS participant_sessions (token_hash TEXT PRIMARY KEY NOT NULL, subject TEXT NOT NULL REFERENCES participants(subject), expires_at INTEGER NOT NULL);
|
|
4
|
+
CREATE TABLE IF NOT EXISTS participant_votes (post_slug TEXT NOT NULL, subject TEXT NOT NULL REFERENCES participants(subject), PRIMARY KEY(post_slug, subject));
|
|
5
|
+
CREATE TABLE IF NOT EXISTS comment_attribution (comment_id TEXT PRIMARY KEY NOT NULL, subject TEXT NOT NULL REFERENCES participants(subject), display_name TEXT NOT NULL);
|
|
6
|
+
CREATE INDEX IF NOT EXISTS participant_sessions_expiry ON participant_sessions(expires_at);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS post_follows (post_slug TEXT NOT NULL, subject TEXT NOT NULL REFERENCES participants(subject), followed_at INTEGER NOT NULL, PRIMARY KEY(post_slug, subject));
|
|
2
|
+
CREATE INDEX IF NOT EXISTS post_follows_subject ON post_follows(subject);
|
|
3
|
+
CREATE TABLE IF NOT EXISTS participant_contacts (subject TEXT PRIMARY KEY REFERENCES participants(subject), email TEXT, email_enabled INTEGER NOT NULL DEFAULT 0);
|
|
4
|
+
CREATE TABLE IF NOT EXISTS post_observations (post_slug TEXT PRIMARY KEY, status TEXT NOT NULL, checked_at INTEGER NOT NULL, lease_until INTEGER NOT NULL DEFAULT 0, comment_cursor TEXT, lease_token TEXT);
|
|
5
|
+
CREATE TABLE IF NOT EXISTS post_events (id TEXT PRIMARY KEY, post_slug TEXT NOT NULL, kind TEXT NOT NULL, title TEXT NOT NULL, message TEXT NOT NULL, created_at INTEGER NOT NULL, actor_subject TEXT);
|
|
6
|
+
CREATE TABLE IF NOT EXISTS participant_notifications (id TEXT PRIMARY KEY, event_id TEXT NOT NULL REFERENCES post_events(id), subject TEXT NOT NULL REFERENCES participants(subject), created_at INTEGER NOT NULL, read_at INTEGER, email_state TEXT NOT NULL DEFAULT 'pending', attempts INTEGER NOT NULL DEFAULT 0, next_attempt_at INTEGER NOT NULL DEFAULT 0, lease_until INTEGER NOT NULL DEFAULT 0, lease_token TEXT, UNIQUE(event_id, subject));
|
|
7
|
+
CREATE INDEX IF NOT EXISTS notifications_subject ON participant_notifications(subject, created_at, id);
|
|
8
|
+
CREATE INDEX IF NOT EXISTS notifications_delivery ON participant_notifications(email_state, next_attempt_at);
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS image_attachments (id TEXT PRIMARY KEY, subject TEXT NOT NULL REFERENCES participants(subject), object_key TEXT NOT NULL UNIQUE, content_type TEXT NOT NULL, bytes INTEGER NOT NULL, created_at INTEGER NOT NULL, post_slug TEXT, comment_id TEXT, deleting INTEGER NOT NULL DEFAULT 0);
|
|
2
|
+
CREATE INDEX IF NOT EXISTS image_attachments_post ON image_attachments(post_slug,comment_id);
|
|
3
|
+
CREATE INDEX IF NOT EXISTS image_attachments_cleanup ON image_attachments(created_at) WHERE post_slug IS NULL;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
ALTER TABLE post_follows ADD COLUMN observed_status TEXT;
|
|
2
|
+
ALTER TABLE post_follows ADD COLUMN baseline_token TEXT;
|
|
3
|
+
ALTER TABLE post_follows ADD COLUMN ready INTEGER NOT NULL DEFAULT 0;
|
|
4
|
+
ALTER TABLE post_follows ADD COLUMN baseline_cursor TEXT;
|
|
5
|
+
ALTER TABLE post_follows ADD COLUMN baseline_lease_until INTEGER NOT NULL DEFAULT 0;
|
|
6
|
+
ALTER TABLE post_follows ADD COLUMN baseline_checked_at INTEGER NOT NULL DEFAULT 0;
|
|
7
|
+
UPDATE post_follows SET observed_status=(SELECT status FROM post_observations WHERE post_observations.post_slug=post_follows.post_slug);
|
|
8
|
+
ALTER TABLE post_events ADD COLUMN status TEXT;
|
|
9
|
+
CREATE TABLE follow_comment_baselines (post_slug TEXT NOT NULL, subject TEXT NOT NULL, event_id TEXT NOT NULL, PRIMARY KEY(post_slug,subject,event_id), FOREIGN KEY(post_slug,subject) REFERENCES post_follows(post_slug,subject) ON DELETE CASCADE);
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS github_posts (installation_id TEXT NOT NULL, repository_id INTEGER NOT NULL, post_id TEXT NOT NULL, slug TEXT NOT NULL, external_id TEXT, issue_number INTEGER, issue_id INTEGER, record TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('reserved','creating','ready','uncertain','failed','withdrawn')), last_status TEXT NOT NULL DEFAULT 'New', vote_count INTEGER NOT NULL DEFAULT 0, projection_hash TEXT, public_updated_at INTEGER, PRIMARY KEY(installation_id,post_id), UNIQUE(installation_id,slug), UNIQUE(installation_id,external_id), UNIQUE(installation_id,repository_id,issue_id));
|
|
2
|
+
CREATE INDEX IF NOT EXISTS github_posts_repository ON github_posts(installation_id,repository_id,state);
|
|
3
|
+
CREATE TABLE IF NOT EXISTS github_comments (installation_id TEXT NOT NULL, repository_id INTEGER NOT NULL, comment_id TEXT NOT NULL, post_id TEXT NOT NULL, discussion_id TEXT NOT NULL, remote_id INTEGER, record TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('creating','ready','uncertain','deleted')), PRIMARY KEY(installation_id,comment_id), UNIQUE(installation_id,repository_id,remote_id), FOREIGN KEY(installation_id,post_id) REFERENCES github_posts(installation_id,post_id));
|
|
4
|
+
CREATE INDEX IF NOT EXISTS github_comments_post ON github_comments(installation_id,repository_id,post_id,state);
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS integration_credentials (installation_id TEXT PRIMARY KEY, portal_secret TEXT NOT NULL, delivery_secret TEXT NOT NULL, revoked INTEGER NOT NULL DEFAULT 0, paired_at INTEGER NOT NULL);
|
|
2
|
+
CREATE TABLE IF NOT EXISTS portal_integration_nonces (installation_id TEXT NOT NULL, nonce TEXT NOT NULL, expires_at INTEGER NOT NULL, PRIMARY KEY(installation_id,nonce));
|
|
3
|
+
CREATE TABLE IF NOT EXISTS integration_receipts (installation_id TEXT NOT NULL, event_id TEXT NOT NULL, kind TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'pending', received_at INTEGER NOT NULL, cursor TEXT NOT NULL DEFAULT '', last_checked_at INTEGER, failure_code TEXT, PRIMARY KEY(installation_id,event_id));
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS post_ownership (post_id TEXT PRIMARY KEY, post_slug TEXT NOT NULL, subject TEXT NOT NULL, title TEXT NOT NULL, type TEXT NOT NULL, created_at INTEGER NOT NULL, withdrawn INTEGER NOT NULL DEFAULT 0);
|
|
2
|
+
CREATE INDEX IF NOT EXISTS owned_posts_subject ON post_ownership(subject,created_at,post_id);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
ALTER TABLE integration_receipts ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0;
|
|
2
|
+
ALTER TABLE integration_receipts ADD COLUMN next_attempt INTEGER NOT NULL DEFAULT 0;
|
|
3
|
+
ALTER TABLE integration_receipts ADD COLUMN lease_token TEXT;
|
|
4
|
+
ALTER TABLE integration_receipts ADD COLUMN lease_until INTEGER NOT NULL DEFAULT 0;
|
|
5
|
+
CREATE INDEX integration_receipts_due ON integration_receipts(installation_id,state,next_attempt,lease_until);
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS public_post_observations (post_slug TEXT PRIMARY KEY, revision INTEGER NOT NULL DEFAULT 0, projection TEXT, checked_at INTEGER NOT NULL DEFAULT 0, lease_until INTEGER NOT NULL DEFAULT 0, lease_token TEXT);
|
|
2
|
+
CREATE TABLE IF NOT EXISTS public_post_history (id TEXT PRIMARY KEY, post_slug TEXT NOT NULL REFERENCES public_post_observations(post_slug), revision INTEGER NOT NULL, kind TEXT NOT NULL CHECK(kind IN ('status','update')), status TEXT, message TEXT, observed_at INTEGER NOT NULL, UNIQUE(post_slug,revision,kind));
|
|
3
|
+
CREATE INDEX IF NOT EXISTS public_post_history_order ON public_post_history(post_slug,observed_at,revision,kind);
|
package/template/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feedbax-installation",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -16,52 +16,57 @@
|
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@tailwindcss/vite": "4.3.3",
|
|
18
18
|
"@tanstack/react-form": "^1.33.5",
|
|
19
|
-
"@tanstack/react-query": "5.
|
|
20
|
-
"@tanstack/react-router": "1.170.
|
|
21
|
-
"@tanstack/react-start": "1.
|
|
22
|
-
"dotenv": "
|
|
23
|
-
"lucide-react": "1.
|
|
19
|
+
"@tanstack/react-query": "5.104.3",
|
|
20
|
+
"@tanstack/react-router": "1.170.44",
|
|
21
|
+
"@tanstack/react-start": "1.169.2",
|
|
22
|
+
"dotenv": "18.0.8",
|
|
23
|
+
"lucide-react": "1.57.0",
|
|
24
24
|
"next-themes": "0.4.6",
|
|
25
|
-
"react": "19.
|
|
26
|
-
"react-dom": "19.
|
|
25
|
+
"react": "19.3.1",
|
|
26
|
+
"react-dom": "19.3.1",
|
|
27
27
|
"sonner": "2.0.8",
|
|
28
28
|
"tailwindcss": "4.3.3",
|
|
29
|
-
"zod": "4.
|
|
30
|
-
"@base-ui/react": "1.
|
|
29
|
+
"zod": "4.6.7",
|
|
30
|
+
"@base-ui/react": "1.9.0",
|
|
31
31
|
"@fontsource-variable/geist": "5.3.0",
|
|
32
|
-
"@shadcn/react": "0.3.
|
|
32
|
+
"@shadcn/react": "0.3.1",
|
|
33
33
|
"class-variance-authority": "0.7.1",
|
|
34
34
|
"clsx": "2.1.1",
|
|
35
35
|
"cmdk": "^1.1.1",
|
|
36
36
|
"shadcn": "latest",
|
|
37
|
-
"tailwind-merge": "3.
|
|
38
|
-
"tw-animate-css": "1.4.0"
|
|
37
|
+
"tailwind-merge": "3.7.0",
|
|
38
|
+
"tw-animate-css": "1.4.0",
|
|
39
|
+
"drizzle-orm": "0.45.4"
|
|
39
40
|
},
|
|
40
41
|
"devDependencies": {
|
|
41
|
-
"@cloudflare/vite-plugin": "1.
|
|
42
|
-
"@
|
|
43
|
-
"@tanstack/router-
|
|
44
|
-
"@
|
|
45
|
-
"@testing-library/
|
|
46
|
-
"@testing-library/
|
|
47
|
-
"@
|
|
48
|
-
"@types/react
|
|
49
|
-
"@
|
|
50
|
-
"
|
|
42
|
+
"@cloudflare/vite-plugin": "1.64.0",
|
|
43
|
+
"@cloudflare/workers-types": "^5.20261015.1",
|
|
44
|
+
"@tanstack/react-router-devtools": "1.167.2",
|
|
45
|
+
"@tanstack/router-cli": "1.167.44",
|
|
46
|
+
"@testing-library/dom": "10.4.2",
|
|
47
|
+
"@testing-library/react": "16.3.3",
|
|
48
|
+
"@testing-library/user-event": "14.6.8",
|
|
49
|
+
"@types/react": "19.3.0",
|
|
50
|
+
"@types/react-dom": "19.3.0",
|
|
51
|
+
"@vitejs/plugin-react": "6.1.2",
|
|
52
|
+
"jsdom": "30.1.2",
|
|
53
|
+
"miniflare": "4.20260730.0",
|
|
51
54
|
"typescript": "6.0.3",
|
|
52
|
-
"vite": "8.
|
|
53
|
-
"web-vitals": "
|
|
54
|
-
"wrangler": "4.
|
|
55
|
+
"vite": "8.3.4",
|
|
56
|
+
"web-vitals": "6.2.3",
|
|
57
|
+
"wrangler": "4.151.0",
|
|
58
|
+
"esbuild": "0.28.2",
|
|
55
59
|
"@tailwindcss/postcss": "4.3.3",
|
|
56
60
|
"tailwindcss": "4.3.3",
|
|
57
|
-
"vitest": "
|
|
61
|
+
"vitest": "5.0.4",
|
|
58
62
|
"@eslint/js": "10.0.1",
|
|
59
|
-
"@
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
63
|
+
"@shadcn/lint": "0.2.1",
|
|
64
|
+
"@types/node": "24.19.2",
|
|
65
|
+
"feedbax": "0.2.2",
|
|
66
|
+
"eslint": "10.12.0",
|
|
67
|
+
"globals": "17.13.0",
|
|
68
|
+
"prettier": "3.9.10",
|
|
69
|
+
"typescript-eslint": "8.71.2"
|
|
65
70
|
},
|
|
66
71
|
"pnpm": {
|
|
67
72
|
"onlyBuiltDependencies": [
|
|
@@ -69,7 +74,7 @@
|
|
|
69
74
|
]
|
|
70
75
|
},
|
|
71
76
|
"engines": {
|
|
72
|
-
"node": ">=
|
|
77
|
+
"node": "^24.15.0 || >=26.0.0"
|
|
73
78
|
},
|
|
74
79
|
"packageManager": "pnpm@10.29.3",
|
|
75
80
|
"license": "Apache-2.0"
|
|
@@ -146,6 +146,7 @@ export function AuthorizedDraftPost({ slug }: { slug: string }) {
|
|
|
146
146
|
) : (
|
|
147
147
|
<PublicPostDetail
|
|
148
148
|
post={post}
|
|
149
|
+
published={false}
|
|
149
150
|
display={overlaid ? "overlay" : "page"}
|
|
150
151
|
features={{ voting: false, comments: false, changelog: true }}
|
|
151
152
|
summaryMarker={<Badge variant="outline">Draft</Badge>}
|
|
@@ -3,9 +3,8 @@ import type {
|
|
|
3
3
|
PublicChangelogEntry,
|
|
4
4
|
} from "@feedbax/changelog";
|
|
5
5
|
import { Badge } from "@feedbax/ui/components/badge";
|
|
6
|
-
import { Button } from "@feedbax/ui/components/button";
|
|
6
|
+
import { Button, buttonVariants } from "@feedbax/ui/components/button";
|
|
7
7
|
import { ButtonGroup } from "@feedbax/ui/components/button-group";
|
|
8
|
-
import { cn } from "@feedbax/ui/lib/utils";
|
|
9
8
|
import { useRouterState } from "@tanstack/react-router";
|
|
10
9
|
import { ChevronDown, Link2 } from "lucide-react";
|
|
11
10
|
import { useEffect, useMemo, useState } from "react";
|
|
@@ -80,21 +79,24 @@ export function ChangelogPage({
|
|
|
80
79
|
]);
|
|
81
80
|
|
|
82
81
|
return (
|
|
83
|
-
<main className="
|
|
84
|
-
<header className="
|
|
82
|
+
<main className="mx-auto w-full max-w-[96rem] px-6 py-10 lg:px-10">
|
|
83
|
+
<header className="flex flex-wrap items-start justify-between gap-5">
|
|
85
84
|
<div>
|
|
86
|
-
<h1>Changelog</h1>
|
|
87
|
-
<p
|
|
85
|
+
<h1 className="text-2xl font-semibold tracking-tight">Changelog</h1>
|
|
86
|
+
<p className="mt-2 text-sm text-muted-foreground">
|
|
87
|
+
Product updates, fixes, and improvements.
|
|
88
|
+
</p>
|
|
88
89
|
</div>
|
|
90
|
+
<CommandPaletteTrigger className="h-10" />
|
|
89
91
|
</header>
|
|
90
|
-
|
|
91
|
-
<ButtonGroup className="
|
|
92
|
+
{labels.length ? (
|
|
93
|
+
<ButtonGroup className="mt-6 flex-wrap" aria-label="Filter updates">
|
|
92
94
|
{[undefined, ...labels].map((label) => (
|
|
93
95
|
<Button
|
|
94
|
-
className="h-11 md:h-8"
|
|
95
96
|
key={label ?? "all"}
|
|
96
97
|
type="button"
|
|
97
|
-
|
|
98
|
+
size="sm"
|
|
99
|
+
variant={selectedLabel === label ? "secondary" : "outline"}
|
|
98
100
|
aria-pressed={selectedLabel === label}
|
|
99
101
|
disabled={loading}
|
|
100
102
|
onClick={() => void replaceForLabel(label)}
|
|
@@ -103,23 +105,30 @@ export function ChangelogPage({
|
|
|
103
105
|
</Button>
|
|
104
106
|
))}
|
|
105
107
|
</ButtonGroup>
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
) : null}
|
|
109
|
+
<ol
|
|
110
|
+
className="changelog-timeline"
|
|
111
|
+
aria-label="Product updates"
|
|
112
|
+
aria-busy={loading}
|
|
113
|
+
>
|
|
109
114
|
{page.items.map((entry) => (
|
|
110
115
|
<TimelineEntry entry={entry} key={entry.slug} />
|
|
111
116
|
))}
|
|
112
117
|
</ol>
|
|
118
|
+
{!page.items.length && !loading ? (
|
|
119
|
+
<p className="py-12 text-sm text-muted-foreground">
|
|
120
|
+
No updates published yet.
|
|
121
|
+
</p>
|
|
122
|
+
) : null}
|
|
113
123
|
{page.nextCursor ? (
|
|
114
|
-
<div className="
|
|
124
|
+
<div className="mx-auto mt-8 flex max-w-5xl justify-center">
|
|
115
125
|
<Button
|
|
116
|
-
className="h-11 min-w-64 gap-2 text-sm md:h-10"
|
|
117
126
|
type="button"
|
|
118
127
|
variant="outline"
|
|
119
128
|
disabled={loading}
|
|
120
129
|
onClick={() => void loadMore()}
|
|
121
130
|
>
|
|
122
|
-
Load more updates
|
|
131
|
+
{loading ? "Loading updates…" : "Load more updates"}
|
|
123
132
|
<ChevronDown aria-hidden="true" />
|
|
124
133
|
</Button>
|
|
125
134
|
</div>
|
|
@@ -135,28 +144,46 @@ function TimelineEntry({ entry }: { entry: PublicChangelogEntry }) {
|
|
|
135
144
|
<time dateTime={entry.date}>{formatDate(entry.date)}</time>
|
|
136
145
|
<span className="changelog-timeline-marker" aria-hidden="true" />
|
|
137
146
|
<article id={entry.slug} tabIndex={-1}>
|
|
138
|
-
|
|
139
|
-
<div className="
|
|
140
|
-
|
|
141
|
-
{
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
<div className="changelog-entry-title">
|
|
146
|
-
<h2>{entry.title}</h2>
|
|
147
|
-
<a href={`#${entry.slug}`} aria-label={`Link to ${entry.title}`}>
|
|
148
|
-
<Link2 aria-hidden="true" />
|
|
149
|
-
</a>
|
|
150
|
-
</div>
|
|
151
|
-
<p className="changelog-entry-summary">{entry.summary}</p>
|
|
152
|
-
<div className="changelog-entry-body">
|
|
153
|
-
<p>{entry.body}</p>
|
|
154
|
-
</div>
|
|
147
|
+
{entry.labels.length ? (
|
|
148
|
+
<div className="mb-3 flex flex-wrap gap-2" aria-label="Labels">
|
|
149
|
+
{entry.labels.map((label) => (
|
|
150
|
+
<Badge key={label} variant="outline">
|
|
151
|
+
{label}
|
|
152
|
+
</Badge>
|
|
153
|
+
))}
|
|
155
154
|
</div>
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
155
|
+
) : null}
|
|
156
|
+
<div className="flex items-start justify-between gap-3">
|
|
157
|
+
<h2 className="text-lg font-semibold tracking-tight">
|
|
158
|
+
{entry.title}
|
|
159
|
+
</h2>
|
|
160
|
+
<a
|
|
161
|
+
className={buttonVariants({ variant: "ghost", size: "icon-sm" })}
|
|
162
|
+
href={`#${entry.slug}`}
|
|
163
|
+
aria-label={`Link to ${entry.title}`}
|
|
164
|
+
>
|
|
165
|
+
<Link2 aria-hidden="true" />
|
|
166
|
+
</a>
|
|
167
|
+
</div>
|
|
168
|
+
<p className="mt-3 text-sm leading-relaxed">{entry.summary}</p>
|
|
169
|
+
<div className="changelog-entry-body">
|
|
170
|
+
{entry.body
|
|
171
|
+
.trim()
|
|
172
|
+
.split(/\n\s*\n/)
|
|
173
|
+
.filter(Boolean)
|
|
174
|
+
.map((paragraph, index) => (
|
|
175
|
+
<p key={index}>{paragraph}</p>
|
|
176
|
+
))}
|
|
159
177
|
</div>
|
|
178
|
+
{image ? (
|
|
179
|
+
<img
|
|
180
|
+
className="mt-6 w-full border"
|
|
181
|
+
src={image.src}
|
|
182
|
+
alt={image.alt}
|
|
183
|
+
width={760}
|
|
184
|
+
height={420}
|
|
185
|
+
/>
|
|
186
|
+
) : null}
|
|
160
187
|
</article>
|
|
161
188
|
</li>
|
|
162
189
|
);
|
|
@@ -190,24 +217,6 @@ function useUnexpiredImage(image: PublicChangelogEntry["image"]) {
|
|
|
190
217
|
: undefined;
|
|
191
218
|
}
|
|
192
219
|
|
|
193
|
-
function Label({ label }: { label: string }) {
|
|
194
|
-
const tone = label.toLowerCase();
|
|
195
|
-
return (
|
|
196
|
-
<Badge
|
|
197
|
-
className={cn(
|
|
198
|
-
"changelog-label",
|
|
199
|
-
tone === "new feature" && "is-new",
|
|
200
|
-
tone === "improved" && "is-improved",
|
|
201
|
-
tone === "fixed" && "is-fixed",
|
|
202
|
-
)}
|
|
203
|
-
variant="outline"
|
|
204
|
-
>
|
|
205
|
-
<span aria-hidden="true" />
|
|
206
|
-
{label}
|
|
207
|
-
</Badge>
|
|
208
|
-
);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
220
|
function formatDate(date: string) {
|
|
212
221
|
const [year, month, day] = date.split("-").map(Number);
|
|
213
222
|
return new Intl.DateTimeFormat("en-US", {
|
|
@@ -8,7 +8,8 @@ import { requiredEnvironmentValue } from "./feedback-runtime";
|
|
|
8
8
|
|
|
9
9
|
let configuredChangelog: ReturnType<typeof createChangelogModule> | undefined;
|
|
10
10
|
|
|
11
|
-
export function createConfiguredChangelogModule() {
|
|
11
|
+
export function createConfiguredChangelogModule(fresh = false) {
|
|
12
|
+
if (fresh) return buildConfiguredChangelogModule();
|
|
12
13
|
configuredChangelog ??= buildConfiguredChangelogModule();
|
|
13
14
|
return configuredChangelog;
|
|
14
15
|
}
|
|
@@ -6,6 +6,15 @@ declare module "cloudflare:workers" {
|
|
|
6
6
|
}>;
|
|
7
7
|
};
|
|
8
8
|
export const env: {
|
|
9
|
+
GITHUB_SERVICE_SECRET?: string;
|
|
10
|
+
GITHUB_DELIVERY_SECRET?: string;
|
|
11
|
+
GITHUB_PAIRING_SECRET?: string;
|
|
12
|
+
FEEDBAX_UPDATES?: import("@cloudflare/workers-types").Queue<string>;
|
|
13
|
+
FEEDBAX_EMAIL?: import("@feedbax/feedback").CloudflareEmailBinding;
|
|
14
|
+
EMAIL_PROVIDER_SECRET?: string;
|
|
15
|
+
FEEDBAX_IMAGES?: import("@cloudflare/workers-types").R2Bucket;
|
|
16
|
+
FEEDBAX_DB?: import("@feedbax/feedback").ConnectedDatabase;
|
|
17
|
+
IDENTITY_HANDOFF_SECRET?: string;
|
|
9
18
|
TRUSTED_SUBMISSIONS_RATE_LIMITER: {
|
|
10
19
|
limit(options: { key: string }): Promise<{ success: boolean }>;
|
|
11
20
|
};
|
|
@@ -41,7 +41,7 @@ describe("Comment creation", () => {
|
|
|
41
41
|
});
|
|
42
42
|
expect(composer.classList.contains("comment-composer")).toBe(true);
|
|
43
43
|
expect(screen.getByLabelText("Add a comment").getAttribute("rows")).toBe(
|
|
44
|
-
"
|
|
44
|
+
"3",
|
|
45
45
|
);
|
|
46
46
|
expect(
|
|
47
47
|
screen.getByPlaceholderText("Share context, an example, or a question…"),
|
|
@@ -111,12 +111,15 @@ describe("Comment creation", () => {
|
|
|
111
111
|
});
|
|
112
112
|
fireEvent.click(screen.getByRole("button", { name: "Post comment" }));
|
|
113
113
|
expect(screen.getByText("Please add shortcuts.")).toBeTruthy();
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
114
|
+
await waitFor(() =>
|
|
115
|
+
expect(submitComment).toHaveBeenCalledWith({
|
|
116
|
+
slug: "roadmap-search",
|
|
117
|
+
body: "Please add shortcuts.",
|
|
118
|
+
displayName: "Ari",
|
|
119
|
+
email: "private@example.com",
|
|
120
|
+
submissionKey: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/),
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
120
123
|
|
|
121
124
|
confirm({
|
|
122
125
|
id: "notion-comment",
|
|
@@ -147,6 +150,48 @@ describe("Comment creation", () => {
|
|
|
147
150
|
expect(await screen.findByLabelText("Add a comment")).toBeTruthy();
|
|
148
151
|
});
|
|
149
152
|
|
|
153
|
+
it("does not duplicate a native Comment that arrives before its submission confirmation", async () => {
|
|
154
|
+
saveDeviceProfile(localStorage, {
|
|
155
|
+
name: "Ari",
|
|
156
|
+
email: "private@example.com",
|
|
157
|
+
});
|
|
158
|
+
const confirmed = {
|
|
159
|
+
id: "native-comment",
|
|
160
|
+
discussionId: "native-thread",
|
|
161
|
+
body: "One comment",
|
|
162
|
+
author: { kind: "participant" as const, displayName: "Ari" },
|
|
163
|
+
createdAt: new Date(),
|
|
164
|
+
};
|
|
165
|
+
let finish!: (value: typeof confirmed) => void;
|
|
166
|
+
const submitComment = vi.fn(
|
|
167
|
+
() =>
|
|
168
|
+
new Promise<typeof confirmed>((resolve) => {
|
|
169
|
+
finish = resolve;
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
const view = renderCommentDetail({ submitComment });
|
|
173
|
+
fireEvent.change(await screen.findByLabelText("Add a comment"), {
|
|
174
|
+
target: { value: confirmed.body },
|
|
175
|
+
});
|
|
176
|
+
fireEvent.click(screen.getByRole("button", { name: "Post comment" }));
|
|
177
|
+
view.rerender(
|
|
178
|
+
<DeviceProfileProvider>
|
|
179
|
+
<PublicPostDetail
|
|
180
|
+
post={post}
|
|
181
|
+
submitComment={submitComment}
|
|
182
|
+
comments={{
|
|
183
|
+
items: [{ id: confirmed.discussionId, comments: [confirmed] }],
|
|
184
|
+
}}
|
|
185
|
+
/>
|
|
186
|
+
</DeviceProfileProvider>,
|
|
187
|
+
);
|
|
188
|
+
await waitFor(() => expect(submitComment).toHaveBeenCalledTimes(1));
|
|
189
|
+
finish(confirmed);
|
|
190
|
+
await waitFor(() =>
|
|
191
|
+
expect(screen.getAllByText("One comment")).toHaveLength(1),
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
150
195
|
it("rolls back a rejected Comment and shows an actionable error", async () => {
|
|
151
196
|
saveDeviceProfile(localStorage, {
|
|
152
197
|
name: "Ari",
|
|
@@ -164,6 +209,49 @@ describe("Comment creation", () => {
|
|
|
164
209
|
await screen.findByRole("alert");
|
|
165
210
|
expect(screen.queryByText("Temporary message")).toBeNull();
|
|
166
211
|
expect(screen.getByRole("alert").textContent).toContain("Try again");
|
|
212
|
+
expect(
|
|
213
|
+
(screen.getByLabelText("Add a comment") as HTMLTextAreaElement).value,
|
|
214
|
+
).toBe("Temporary message");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("retries a lost Comment response with the same intent and preserves the composer", async () => {
|
|
218
|
+
saveDeviceProfile(localStorage, {
|
|
219
|
+
name: "Ari",
|
|
220
|
+
email: "private@example.com",
|
|
221
|
+
});
|
|
222
|
+
const requests: Record<string, unknown>[] = [];
|
|
223
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init) => {
|
|
224
|
+
requests.push(JSON.parse(String(init?.body)));
|
|
225
|
+
if (requests.length === 1) throw new Error("Response lost. Try again.");
|
|
226
|
+
return Response.json({
|
|
227
|
+
comment: {
|
|
228
|
+
id: "recovered-comment",
|
|
229
|
+
discussionId: "recovered-thread",
|
|
230
|
+
body: "Keep this message",
|
|
231
|
+
author: { kind: "participant", displayName: "Ari" },
|
|
232
|
+
createdAt: "2026-08-25T01:00:00.000Z",
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
renderCommentDetail({ submitComment: submitPublicComment });
|
|
237
|
+
fireEvent.change(await screen.findByLabelText("Add a comment"), {
|
|
238
|
+
target: { value: "Keep this message" },
|
|
239
|
+
});
|
|
240
|
+
fireEvent.click(screen.getByRole("button", { name: "Post comment" }));
|
|
241
|
+
await screen.findByRole("alert");
|
|
242
|
+
expect(
|
|
243
|
+
(screen.getByLabelText("Add a comment") as HTMLTextAreaElement).value,
|
|
244
|
+
).toBe("Keep this message");
|
|
245
|
+
fireEvent.click(screen.getByRole("button", { name: "Post comment" }));
|
|
246
|
+
await waitFor(() => expect(requests).toHaveLength(2));
|
|
247
|
+
await waitFor(() =>
|
|
248
|
+
expect(
|
|
249
|
+
(screen.getByLabelText("Add a comment") as HTMLTextAreaElement).value,
|
|
250
|
+
).toBe(""),
|
|
251
|
+
);
|
|
252
|
+
expect(requests[0]?.submissionKey).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
253
|
+
expect(requests[1]?.submissionKey).toBe(requests[0]?.submissionKey);
|
|
254
|
+
expect(screen.getAllByText("Keep this message")).toHaveLength(1);
|
|
167
255
|
});
|
|
168
256
|
|
|
169
257
|
it("renders a fresh Turnstile challenge after a stale Participation Pass is rejected", async () => {
|
|
@@ -10,7 +10,17 @@ export function mergeCommentThreads(
|
|
|
10
10
|
merged.set(
|
|
11
11
|
thread.id,
|
|
12
12
|
existing
|
|
13
|
-
? {
|
|
13
|
+
? {
|
|
14
|
+
...existing,
|
|
15
|
+
comments: [
|
|
16
|
+
...new Map(
|
|
17
|
+
[...existing.comments, ...thread.comments].map((comment) => [
|
|
18
|
+
comment.id,
|
|
19
|
+
comment,
|
|
20
|
+
]),
|
|
21
|
+
).values(),
|
|
22
|
+
],
|
|
23
|
+
}
|
|
14
24
|
: thread,
|
|
15
25
|
);
|
|
16
26
|
}
|