create-feedbax 0.2.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.
Files changed (99) hide show
  1. package/dist/cli.js +2042 -16
  2. package/dist/index.js +1997 -5
  3. package/package.json +1 -1
  4. package/template/.feedbax-template.sha256 +1 -1
  5. package/template/AGENTS.md +13 -0
  6. package/template/CONNECTED.md +404 -0
  7. package/template/README.md +58 -0
  8. package/template/components.json +1 -1
  9. package/template/eslint.config.mjs +23 -0
  10. package/template/migrations/0001_connected.sql +6 -0
  11. package/template/migrations/0002_participation.sql +8 -0
  12. package/template/migrations/0003_images.sql +3 -0
  13. package/template/migrations/0004_followed_status.sql +9 -0
  14. package/template/package.json +9 -5
  15. package/template/src/authorized-draft-post.tsx +1 -0
  16. package/template/src/changelog-page.tsx +63 -54
  17. package/template/src/cloudflare-workers.d.ts +6 -0
  18. package/template/src/comment-creation.test.tsx +42 -1
  19. package/template/src/comment-thread-page.ts +11 -1
  20. package/template/src/components/command-palette.tsx +59 -30
  21. package/template/src/components/connected-identity-provider.tsx +108 -0
  22. package/template/src/components/device-profile-provider.tsx +41 -26
  23. package/template/src/components/follow-post-button.tsx +89 -0
  24. package/template/src/components/header.tsx +2 -0
  25. package/template/src/components/image-attachments.tsx +187 -0
  26. package/template/src/components/loader.tsx +48 -5
  27. package/template/src/components/notification-inbox.tsx +232 -0
  28. package/template/src/components/portal-footer.tsx +14 -0
  29. package/template/src/components/profile-menu.tsx +30 -0
  30. package/template/src/components/ui/components/command.tsx +30 -29
  31. package/template/src/components/ui/components/message-scroller.tsx +2 -2
  32. package/template/src/components/ui/components/scroll-area.tsx +0 -1
  33. package/template/src/components/ui/components/switch.tsx +29 -0
  34. package/template/src/components/ui/styles/globals.css +1 -1
  35. package/template/src/connected-identity.test.ts +144 -0
  36. package/template/src/connected-identity.ts +122 -0
  37. package/template/src/connected-participation.test.tsx +67 -0
  38. package/template/src/connected-runtime.test.ts +241 -0
  39. package/template/src/connected-runtime.ts +176 -0
  40. package/template/src/connected-social.test.tsx +158 -0
  41. package/template/src/feedback-cache-invalidation.ts +1 -0
  42. package/template/src/feedback-runtime.ts +79 -12
  43. package/template/src/feedbax/config/index.test.ts +28 -0
  44. package/template/src/feedbax/config/index.ts +93 -0
  45. package/template/src/feedbax/feedback/connected-feedback.ts +206 -0
  46. package/template/src/feedbax/feedback/connected.test.ts +272 -0
  47. package/template/src/feedbax/feedback/connected.ts +215 -0
  48. package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
  49. package/template/src/feedbax/feedback/email-delivery.ts +66 -0
  50. package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
  51. package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
  52. package/template/src/feedbax/feedback/image-feedback.ts +101 -0
  53. package/template/src/feedbax/feedback/images.test.ts +223 -0
  54. package/template/src/feedbax/feedback/images.ts +324 -0
  55. package/template/src/feedbax/feedback/index.ts +55 -1
  56. package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
  57. package/template/src/feedbax/feedback/participation.test.ts +653 -0
  58. package/template/src/feedbax/feedback/participation.ts +502 -0
  59. package/template/src/images-runtime.test.ts +133 -0
  60. package/template/src/images-runtime.ts +125 -0
  61. package/template/src/index.css +97 -440
  62. package/template/src/lazy-post-detail-overlay.tsx +13 -2
  63. package/template/src/overlay-post-detail.test.tsx +59 -8
  64. package/template/src/overlay-post-detail.tsx +20 -69
  65. package/template/src/participation-client.ts +63 -0
  66. package/template/src/participation-runtime.test.ts +35 -0
  67. package/template/src/participation-runtime.ts +255 -0
  68. package/template/src/portal-comments.ts +24 -5
  69. package/template/src/portal-feedback-form.tsx +18 -3
  70. package/template/src/portal-feedback-runtime.ts +3 -0
  71. package/template/src/portal-feedback-server-function.ts +6 -3
  72. package/template/src/portal-feedback-submission.ts +10 -0
  73. package/template/src/portal-server-entry.ts +4 -0
  74. package/template/src/post-creation-flow.tsx +2 -2
  75. package/template/src/post-detail-content.test.tsx +233 -0
  76. package/template/src/post-detail-content.tsx +162 -0
  77. package/template/src/post-detail-query.ts +34 -0
  78. package/template/src/post-results.tsx +1 -1
  79. package/template/src/public-cache-policy.test.ts +11 -4
  80. package/template/src/public-cache-policy.ts +1 -1
  81. package/template/src/public-feedback-index.tsx +9 -39
  82. package/template/src/public-feedback-page.test.tsx +65 -0
  83. package/template/src/public-feedback-page.ts +12 -1
  84. package/template/src/public-post-detail-skeleton.tsx +54 -0
  85. package/template/src/public-post-detail.tsx +298 -208
  86. package/template/src/public-post-route.test.tsx +39 -3
  87. package/template/src/public-post-route.tsx +15 -28
  88. package/template/src/public-roadmap-route.test.tsx +38 -2
  89. package/template/src/public-roadmap-view.tsx +56 -23
  90. package/template/src/public-shell.test.tsx +26 -0
  91. package/template/src/routeTree.gen.ts +49 -3
  92. package/template/src/router.tsx +2 -0
  93. package/template/src/routes/__root.tsx +3 -5
  94. package/template/src/routes/email-preferences.tsx +77 -0
  95. package/template/src/routes/submit.tsx +1 -1
  96. package/template/src/safe-public-failure.ts +3 -1
  97. package/template/src/server.ts +41 -1
  98. package/template/src/trusted-feedback-handler.test.ts +1 -1
  99. package/template/src/vote-toggle.tsx +42 -9
@@ -1,3 +1,4 @@
1
+ import { ImageAttachments } from "./components/image-attachments";
1
2
  import { configuredTurnstileSiteKey } from "./turnstile-configuration";
2
3
  import type { Post, PostType, SubmittedPost } from "@feedbax/feedback";
3
4
  import { useForm } from "@tanstack/react-form";
@@ -120,6 +121,10 @@ export function PortalFeedbackForm({
120
121
  }) {
121
122
  const [draft, setDraft] = useState<StoredDraft | undefined>(initialDraft);
122
123
  const [pending, setPending] = useState(false);
124
+ const [images, setImages] = useState<
125
+ import("@feedbax/feedback").ImageAttachment[]
126
+ >([]);
127
+ const [uploading, setUploading] = useState(false);
123
128
  const [message, setMessage] = useState<string>();
124
129
  const [confirmingWithdrawal, setConfirmingWithdrawal] = useState(false);
125
130
  const turnstileToken = useRef<string | undefined>(undefined);
@@ -161,6 +166,9 @@ export function PortalFeedbackForm({
161
166
  const result = await mutations.submitPost({
162
167
  data: {
163
168
  ...values,
169
+ ...(images.length
170
+ ? { imageIds: images.map((image) => image.id) }
171
+ : {}),
164
172
  ...(turnstileToken.current
165
173
  ? { turnstileToken: turnstileToken.current }
166
174
  : {}),
@@ -408,9 +416,16 @@ export function PortalFeedbackForm({
408
416
  );
409
417
  }}
410
418
  />
419
+ {!draft ? (
420
+ <ImageAttachments
421
+ value={images}
422
+ onChange={setImages}
423
+ onUploadingChange={setUploading}
424
+ />
425
+ ) : null}
411
426
  </FieldGroup>
412
427
  <div className="feedback-submit-actions">
413
- <Button type="submit" disabled={pending}>
428
+ <Button type="submit" disabled={pending || uploading}>
414
429
  {pending ? "Working…" : draft ? "Save changes" : "Create Post"}
415
430
  </Button>
416
431
  {display === "overlay" ? (
@@ -419,7 +434,7 @@ export function PortalFeedbackForm({
419
434
  variant="ghost"
420
435
  className="feedback-cancel"
421
436
  onClick={onCancel}
422
- disabled={pending}
437
+ disabled={pending || uploading}
423
438
  >
424
439
  Cancel
425
440
  </Button>
@@ -430,7 +445,7 @@ export function PortalFeedbackForm({
430
445
  variant="destructive"
431
446
  className="feedback-withdraw"
432
447
  onClick={() => setConfirmingWithdrawal(true)}
433
- disabled={pending}
448
+ disabled={pending || uploading}
434
449
  >
435
450
  Withdraw draft
436
451
  </Button>
@@ -10,6 +10,7 @@ interface PortalSubmissionEnvironment {
10
10
  }
11
11
 
12
12
  interface PortalSubmissionSecurityOptions {
13
+ turnstile?: FeedbaxConfig["turnstile"];
13
14
  environment: PortalSubmissionEnvironment;
14
15
  rateLimitKey: string;
15
16
  }
@@ -22,11 +23,13 @@ interface TurnstileWarningOptions {
22
23
  }
23
24
 
24
25
  export function createPortalSubmissionSecurity({
26
+ turnstile = feedbax.turnstile,
25
27
  environment,
26
28
  rateLimitKey,
27
29
  }: PortalSubmissionSecurityOptions) {
28
30
  const turnstileSecretKey = configuredTurnstileSecret(
29
31
  environment.TURNSTILE_SECRET_KEY,
32
+ turnstile,
30
33
  );
31
34
 
32
35
  return {
@@ -1,7 +1,8 @@
1
1
  import { createServerFn } from "@tanstack/react-start";
2
- import { getRequestIP } from "@tanstack/react-start/server";
2
+ import { getRequest, getRequestIP } from "@tanstack/react-start/server";
3
3
 
4
4
  import {
5
+ requestParticipant,
5
6
  createConfiguredFeedbackModule,
6
7
  feedbackSubmissionLimits,
7
8
  } from "./feedback-runtime";
@@ -24,9 +25,10 @@ export const submitPortalFeedback = createServerFn({ method: "POST" })
24
25
  environment: env,
25
26
  nodeEnv: import.meta.env.PROD ? "production" : "development",
26
27
  });
28
+ const participant = await requestParticipant(getRequest());
27
29
  return runSafePortalMutation(() =>
28
30
  createPortalFeedbackSubmission({
29
- feedback: createConfiguredFeedbackModule(),
31
+ feedback: createConfiguredFeedbackModule(participant),
30
32
  limits: feedbackSubmissionLimits,
31
33
  ...createPortalSubmissionSecurity({
32
34
  environment: env,
@@ -40,9 +42,10 @@ export const submitPortalPost = createServerFn({ method: "POST" })
40
42
  .validator((input: unknown) => input)
41
43
  .handler(async ({ data }) => {
42
44
  const { env } = await import("cloudflare:workers");
45
+ const participant = await requestParticipant(getRequest());
43
46
  return runSafePortalMutation(() =>
44
47
  createPortalPostSubmission({
45
- feedback: createConfiguredFeedbackModule(),
48
+ feedback: createConfiguredFeedbackModule(participant),
46
49
  limits: feedbackSubmissionLimits,
47
50
  ...createPortalSubmissionSecurity({
48
51
  environment: env,
@@ -147,8 +147,18 @@ function validateInput(
147
147
  throw invalidSubmission();
148
148
  }
149
149
  if (!isValidSubmitter(value.submitter)) throw invalidSubmission();
150
+ if (
151
+ value.imageIds !== undefined &&
152
+ (!Array.isArray(value.imageIds) ||
153
+ value.imageIds.length > 4 ||
154
+ value.imageIds.some(
155
+ (id) => typeof id !== "string" || !/^[-a-f0-9]{36}$/.test(id),
156
+ ))
157
+ )
158
+ throw invalidSubmission();
150
159
 
151
160
  return {
161
+ ...(value.imageIds ? { imageIds: value.imageIds as string[] } : {}),
152
162
  title: value.title,
153
163
  description: value.description,
154
164
  type: value.type,
@@ -8,12 +8,14 @@ import {
8
8
  } from "./public-cache-policy";
9
9
 
10
10
  export function createPortalServerEntry({
11
+ connectedHandler,
11
12
  trustedFeedbackHandler,
12
13
  voteHandler,
13
14
  commentHandler,
14
15
  commentMutationHandler,
15
16
  applicationHandler = startHandler.fetch,
16
17
  }: {
18
+ connectedHandler?: (request: Request) => Promise<Response | undefined>;
17
19
  trustedFeedbackHandler: (request: Request) => Promise<Response>;
18
20
  voteHandler?: (request: Request) => Promise<Response>;
19
21
  commentHandler?: (request: Request) => Promise<Response>;
@@ -23,6 +25,8 @@ export function createPortalServerEntry({
23
25
  return createServerEntry({
24
26
  async fetch(request, options) {
25
27
  const url = new URL(request.url);
28
+ const connectedResponse = await connectedHandler?.(request);
29
+ if (connectedResponse) return connectedResponse;
26
30
  if (request.method === "GET" && url.pathname === "/health") {
27
31
  return Response.json(
28
32
  { status: "ok" },
@@ -65,11 +65,11 @@ export function PostCreationFlow({
65
65
  export function PostCreationSkeleton() {
66
66
  return (
67
67
  <main
68
- className="feedback-index submission-page"
68
+ className="feedback-index"
69
69
  aria-label="Loading Post creation"
70
70
  aria-busy="true"
71
71
  >
72
- <section className="feedback-submit creation-skeleton" aria-hidden="true">
72
+ <section className="feedback-submit" aria-hidden="true">
73
73
  <div className="feedback-submit-copy">
74
74
  <Skeleton className="creation-skeleton-eyebrow" />
75
75
  <Skeleton className="creation-skeleton-heading" />
@@ -0,0 +1,233 @@
1
+ // @vitest-environment jsdom
2
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3
+ import {
4
+ cleanup,
5
+ fireEvent,
6
+ render,
7
+ screen,
8
+ waitFor,
9
+ } from "@testing-library/react";
10
+ import { afterEach, expect, it, vi } from "vitest";
11
+ import { PostDetailContent } from "./post-detail-content";
12
+ import { saveDeviceProfile } from "./browser-post-state";
13
+
14
+ const post = {
15
+ slug: "comment-cache",
16
+ title: "Comment cache",
17
+ description: "Feedback",
18
+ type: "Feature Request" as const,
19
+ status: "Planned" as const,
20
+ createdAt: new Date(),
21
+ updatedAt: new Date(),
22
+ };
23
+ const author = { kind: "participant" as const, displayName: "Ama" };
24
+ afterEach(() => {
25
+ cleanup();
26
+ localStorage.clear();
27
+ vi.restoreAllMocks();
28
+ });
29
+
30
+ it.each(["edit", "delete"] as const)(
31
+ "preserves a confirmed Comment %s when another page arrives",
32
+ async (action) => {
33
+ saveDeviceProfile(localStorage, { name: "Ama", email: "ama@example.com" });
34
+ localStorage.setItem(
35
+ "feedbax:comment-capabilities",
36
+ JSON.stringify({
37
+ "comment-1": {
38
+ capability: "capability",
39
+ expiresAt: Date.now() + 60000,
40
+ },
41
+ }),
42
+ );
43
+ let state: "original" | "edit" | "delete" = "original";
44
+ const loadComments = vi.fn(async (_slug: string, cursor?: string) =>
45
+ cursor
46
+ ? {
47
+ items: [
48
+ {
49
+ id: "thread-2",
50
+ comments: [
51
+ {
52
+ id: "comment-2",
53
+ body: "Second page",
54
+ author,
55
+ createdAt: new Date(),
56
+ },
57
+ ],
58
+ },
59
+ ],
60
+ }
61
+ : {
62
+ items:
63
+ state === "delete"
64
+ ? []
65
+ : [
66
+ {
67
+ id: "thread-1",
68
+ comments: [
69
+ {
70
+ id: "comment-1",
71
+ body:
72
+ state === "edit"
73
+ ? "Corrected message"
74
+ : "Original message",
75
+ author,
76
+ createdAt: new Date(),
77
+ },
78
+ ],
79
+ },
80
+ ],
81
+ nextCursor: "next",
82
+ },
83
+ );
84
+ const mutateComment = vi.fn(async () => {
85
+ state = action;
86
+ });
87
+ vi.spyOn(window, "confirm").mockReturnValue(true);
88
+ const client = new QueryClient({
89
+ defaultOptions: { queries: { retry: false } },
90
+ });
91
+ render(
92
+ <QueryClientProvider client={client}>
93
+ <PostDetailContent
94
+ post={post}
95
+ loadComments={loadComments}
96
+ mutateComment={mutateComment}
97
+ />
98
+ </QueryClientProvider>,
99
+ );
100
+ expect(await screen.findByText("Original message")).toBeTruthy();
101
+ fireEvent.click(
102
+ await screen.findByRole("button", {
103
+ name: action === "edit" ? "Edit" : "Delete",
104
+ }),
105
+ );
106
+ if (action === "edit") {
107
+ fireEvent.change(screen.getByRole("textbox", { name: "Comment text" }), {
108
+ target: { value: "Corrected message" },
109
+ });
110
+ fireEvent.click(screen.getByRole("button", { name: "Save" }));
111
+ }
112
+ await waitFor(() => expect(mutateComment).toHaveBeenCalledOnce());
113
+ await waitFor(() =>
114
+ expect(screen.queryByText("Original message")).toBeNull(),
115
+ );
116
+ const more = await screen.findByRole("button", {
117
+ name: "Load more",
118
+ });
119
+ await waitFor(() => expect(more.hasAttribute("disabled")).toBe(false));
120
+ fireEvent.click(more);
121
+ expect(await screen.findByText("Second page")).toBeTruthy();
122
+ expect(screen.queryByText("Original message")).toBeNull();
123
+ expect(screen.queryAllByText("Corrected message")).toHaveLength(
124
+ action === "edit" ? 1 : 0,
125
+ );
126
+ },
127
+ );
128
+
129
+ it("removes a Comment resolved in Notion when the shared query refreshes", async () => {
130
+ let visible = true;
131
+ const loadComments = vi.fn(async () => ({
132
+ items: visible
133
+ ? [
134
+ {
135
+ id: "thread-1",
136
+ comments: [
137
+ {
138
+ id: "comment-1",
139
+ body: "Resolved in Notion",
140
+ author,
141
+ createdAt: new Date(),
142
+ },
143
+ ],
144
+ },
145
+ ]
146
+ : [],
147
+ }));
148
+ const client = new QueryClient({
149
+ defaultOptions: { queries: { retry: false } },
150
+ });
151
+ render(
152
+ <QueryClientProvider client={client}>
153
+ <PostDetailContent post={post} loadComments={loadComments} />
154
+ </QueryClientProvider>,
155
+ );
156
+ expect(await screen.findByText("Resolved in Notion")).toBeTruthy();
157
+ visible = false;
158
+ void client.invalidateQueries({
159
+ queryKey: ["public-post-comments", post.slug],
160
+ });
161
+ await waitFor(() =>
162
+ expect(screen.queryByText("Resolved in Notion")).toBeNull(),
163
+ );
164
+ expect(screen.getByText("No comments yet")).toBeTruthy();
165
+ });
166
+
167
+ it("keeps private edit capabilities out of the shared public Comment cache", async () => {
168
+ saveDeviceProfile(localStorage, { name: "Ama", email: "ama@example.com" });
169
+ const confirmed = {
170
+ id: "comment-1",
171
+ discussionId: "thread-1",
172
+ body: "Public message",
173
+ author,
174
+ createdAt: new Date(),
175
+ commentCapability: "private-edit-capability",
176
+ commentCapabilityExpiresAt: Date.now() + 60000,
177
+ };
178
+ let submitted = false;
179
+ const loadComments = vi.fn(async () => ({
180
+ items: submitted
181
+ ? [
182
+ {
183
+ id: "thread-1",
184
+ comments: [
185
+ {
186
+ id: confirmed.id,
187
+ body: confirmed.body,
188
+ author,
189
+ createdAt: confirmed.createdAt,
190
+ },
191
+ ],
192
+ },
193
+ ]
194
+ : [],
195
+ }));
196
+ const client = new QueryClient({
197
+ defaultOptions: { queries: { retry: false } },
198
+ });
199
+ const snapshots: string[] = [];
200
+ const unsubscribe = client.getQueryCache().subscribe((event) => {
201
+ snapshots.push(JSON.stringify(event.query.state.data));
202
+ });
203
+ render(
204
+ <QueryClientProvider client={client}>
205
+ <PostDetailContent
206
+ post={post}
207
+ loadComments={loadComments}
208
+ submitComment={async () => {
209
+ submitted = true;
210
+ return confirmed;
211
+ }}
212
+ />
213
+ </QueryClientProvider>,
214
+ );
215
+ fireEvent.change(await screen.findByLabelText("Add a comment"), {
216
+ target: { value: confirmed.body },
217
+ });
218
+ fireEvent.click(screen.getByRole("button", { name: "Post comment" }));
219
+ expect(await screen.findByText(confirmed.body)).toBeTruthy();
220
+ await waitFor(() =>
221
+ expect(localStorage.getItem("feedbax:comment-capabilities")).toContain(
222
+ confirmed.commentCapability,
223
+ ),
224
+ );
225
+ const cached = JSON.stringify(
226
+ client.getQueryData(["public-post-comments", post.slug]),
227
+ );
228
+ expect(cached).toContain(confirmed.body);
229
+ expect(cached).not.toContain("commentCapability");
230
+ expect(cached).not.toContain(confirmed.commentCapability);
231
+ expect(snapshots.join("\n")).not.toContain(confirmed.commentCapability);
232
+ unsubscribe();
233
+ });
@@ -0,0 +1,162 @@
1
+ import type { CommentThreadPage, CreatedComment } from "@feedbax/feedback";
2
+ import { type ComponentProps, useMemo } from "react";
3
+ import {
4
+ type InfiniteData,
5
+ useInfiniteQuery,
6
+ useQueryClient,
7
+ } from "@tanstack/react-query";
8
+ import { mergeCommentThreads } from "./comment-thread-page";
9
+ import { PublicPostDetail } from "./public-post-detail";
10
+ import {
11
+ publicPostCommentsQuery,
12
+ type FetchPostComments,
13
+ } from "./post-detail-query";
14
+ import feedbax from "./feedbax";
15
+
16
+ type Props = Omit<
17
+ ComponentProps<typeof PublicPostDetail>,
18
+ "comments" | "loadMore"
19
+ > & { loadComments?: FetchPostComments };
20
+ type Pages = InfiniteData<CommentThreadPage, string | undefined>;
21
+ const emptyComments: FetchPostComments = async () => ({ items: [] });
22
+
23
+ export function PostDetailContent(props: Props) {
24
+ return <PostDiscussion key={props.post.slug} {...props} />;
25
+ }
26
+
27
+ function PostDiscussion({ loadComments, ...props }: Props) {
28
+ const client = useQueryClient();
29
+ const options = publicPostCommentsQuery(
30
+ props.post.slug,
31
+ loadComments ?? emptyComments,
32
+ );
33
+ const query = useInfiniteQuery({
34
+ ...options,
35
+ enabled:
36
+ (props.features ?? feedbax.features).comments && Boolean(loadComments),
37
+ });
38
+ const comments = useMemo(
39
+ () => ({
40
+ items:
41
+ query.data?.pages.reduce(
42
+ (current, page) => mergeCommentThreads(current, page.items),
43
+ [] as CommentThreadPage["items"],
44
+ ) ?? [],
45
+ }),
46
+ [query.data],
47
+ );
48
+ function updatePages(update: (pages: Pages) => Pages) {
49
+ client.setQueryData<Pages>(options.queryKey, (current) =>
50
+ update(current ?? { pages: [{ items: [] }], pageParams: [undefined] }),
51
+ );
52
+ }
53
+ return (
54
+ <PublicPostDetail
55
+ {...props}
56
+ comments={comments}
57
+ commentsLoading={query.isFetching}
58
+ commentsLoadError={query.isError}
59
+ retryComments={() => {
60
+ if (query.isFetchNextPageError) void query.fetchNextPage();
61
+ else void query.refetch();
62
+ }}
63
+ loadMore={
64
+ query.hasNextPage && !query.isError
65
+ ? async () => {
66
+ await query.fetchNextPage();
67
+ }
68
+ : undefined
69
+ }
70
+ submitComment={
71
+ props.submitComment
72
+ ? async (input) => {
73
+ await client.cancelQueries({ queryKey: options.queryKey });
74
+ try {
75
+ const confirmed = await props.submitComment!(input);
76
+ updatePages((current) => upsertComment(current, confirmed));
77
+ return confirmed;
78
+ } finally {
79
+ void client.invalidateQueries({ queryKey: options.queryKey });
80
+ }
81
+ }
82
+ : undefined
83
+ }
84
+ mutateComment={
85
+ props.mutateComment
86
+ ? async (input) => {
87
+ await client.cancelQueries({ queryKey: options.queryKey });
88
+ try {
89
+ const result = await props.mutateComment!(input);
90
+ updatePages((current) => ({
91
+ ...current,
92
+ pages: current.pages.map((page) => ({
93
+ ...page,
94
+ items: page.items
95
+ .map((thread) => ({
96
+ ...thread,
97
+ comments: thread.comments.flatMap((comment) =>
98
+ comment.id !== input.commentId
99
+ ? [comment]
100
+ : input.action === "delete"
101
+ ? []
102
+ : [{ ...comment, body: input.body! }],
103
+ ),
104
+ }))
105
+ .filter((thread) => thread.comments.length > 0),
106
+ })),
107
+ }));
108
+ return result;
109
+ } finally {
110
+ void client.invalidateQueries({ queryKey: options.queryKey });
111
+ }
112
+ }
113
+ : undefined
114
+ }
115
+ />
116
+ );
117
+ }
118
+
119
+ function upsertComment(current: Pages, comment: CreatedComment): Pages {
120
+ // Cache only the public projection; browser edit capabilities stay outside it.
121
+ const publicComment = {
122
+ id: comment.id,
123
+ body: comment.body,
124
+ ...(comment.images?.length
125
+ ? {
126
+ images: comment.images.map((image) => ({
127
+ id: image.id,
128
+ src: image.src,
129
+ contentType: image.contentType,
130
+ bytes: image.bytes,
131
+ })),
132
+ }
133
+ : {}),
134
+ createdAt: comment.createdAt,
135
+ author:
136
+ comment.author.kind === "participant"
137
+ ? {
138
+ kind: "participant" as const,
139
+ displayName: comment.author.displayName,
140
+ ...(comment.author.verified ? { verified: true } : {}),
141
+ }
142
+ : {
143
+ kind: "product-team" as const,
144
+ displayName: comment.author.displayName,
145
+ },
146
+ };
147
+ const thread = { id: comment.discussionId, comments: [publicComment] };
148
+ const present = current.pages.some((page) =>
149
+ page.items.some((item) => item.id === thread.id),
150
+ );
151
+ return {
152
+ ...current,
153
+ pages: current.pages.map((page, index) => ({
154
+ ...page,
155
+ items:
156
+ (index === 0 && !present) ||
157
+ page.items.some((item) => item.id === thread.id)
158
+ ? mergeCommentThreads(page.items, [thread])
159
+ : page.items,
160
+ })),
161
+ };
162
+ }
@@ -0,0 +1,34 @@
1
+ import type { CommentThreadPage, PublicPost } from "@feedbax/feedback";
2
+ import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query";
3
+
4
+ export const publicPostQueryKey = ["public-post"] as const;
5
+ export type FetchPostComments = (
6
+ slug: string,
7
+ cursor?: string,
8
+ ) => Promise<CommentThreadPage>;
9
+
10
+ export function publicPostQuery(
11
+ slug: string,
12
+ loadPost: (slug: string) => Promise<PublicPost | undefined>,
13
+ ) {
14
+ return queryOptions({
15
+ queryKey: [...publicPostQueryKey, slug],
16
+ queryFn: async () => (await loadPost(slug)) ?? null,
17
+ staleTime: 30_000,
18
+ retry: false,
19
+ });
20
+ }
21
+
22
+ export function publicPostCommentsQuery(
23
+ slug: string,
24
+ loadComments: FetchPostComments,
25
+ ) {
26
+ return infiniteQueryOptions({
27
+ queryKey: ["public-post-comments", slug],
28
+ initialPageParam: undefined as string | undefined,
29
+ queryFn: ({ pageParam }) => loadComments(slug, pageParam),
30
+ getNextPageParam: (page) => page.nextCursor,
31
+ staleTime: 0,
32
+ retry: false,
33
+ });
34
+ }
@@ -142,7 +142,7 @@ function PostResultRow({
142
142
  <span className="block text-base font-medium text-foreground sm:text-lg">
143
143
  {post.title}
144
144
  </span>
145
- <span className="mt-1 block text-sm leading-relaxed text-muted-foreground">
145
+ <span className="mt-1 line-clamp-2 break-words text-sm leading-relaxed text-muted-foreground">
146
146
  {post.description}
147
147
  </span>
148
148
  <span
@@ -75,7 +75,7 @@ describe("public portal cache policy", () => {
75
75
  );
76
76
 
77
77
  expect(first?.url).toBe(
78
- "https://feedback.example.com/?types=%5B%22Bug+Report%22%5D&statuses=%5B%22New%22%5D&pageSize=25&sort=top&schema=1",
78
+ "https://feedback.example.com/?types=%5B%22Bug+Report%22%5D&statuses=%5B%22New%22%5D&pageSize=25&sort=top&schema=2",
79
79
  );
80
80
  expect(second?.url).toBe(first?.url);
81
81
  });
@@ -94,12 +94,12 @@ describe("public portal cache policy", () => {
94
94
  new Request("https://feedback.example.com/roadmap"),
95
95
  )?.url,
96
96
  ).toBe(
97
- "https://feedback.example.com/roadmap?sort=updated-at-desc&schema=1",
97
+ "https://feedback.example.com/roadmap?sort=updated-at-desc&schema=2",
98
98
  );
99
99
  expect(
100
100
  canonicalPublicRequest(new Request("https://feedback.example.com/p/slug"))
101
101
  ?.url,
102
- ).toBe("https://feedback.example.com/p/slug?schema=1");
102
+ ).toBe("https://feedback.example.com/p/slug?schema=2");
103
103
  });
104
104
  });
105
105
 
@@ -108,7 +108,7 @@ describe("feed URL normalization", () => {
108
108
  expect(
109
109
  canonicalPublicRequest(
110
110
  new Request(
111
- "https://feedback.example.com/?pageSize=25&sort=new&schema=1",
111
+ "https://feedback.example.com/?pageSize=25&sort=new&schema=2",
112
112
  ),
113
113
  ),
114
114
  ).toBeUndefined();
@@ -139,3 +139,10 @@ describe("feed URL normalization", () => {
139
139
  expect(canonicalPublicRequest(first!)).toBeUndefined();
140
140
  });
141
141
  });
142
+
143
+ it("moves pre-0.2.1 cached projections to a new schema identity", () => {
144
+ const request = canonicalPublicRequest(
145
+ new Request("https://feedback.example.com/?pageSize=25&sort=top&schema=1"),
146
+ );
147
+ expect(new URL(request!.url).searchParams.get("schema")).toBe("2");
148
+ });
@@ -15,7 +15,7 @@ const BROWSER_CACHE_CONTROL = "public, max-age=30";
15
15
  const EDGE_CACHE_CONTROL =
16
16
  "public, max-age=120, stale-while-revalidate=600, stale-if-error=86400";
17
17
  const CHANGELOG_EDGE_CACHE_CONTROL = "public, max-age=30";
18
- const PUBLIC_PROJECTION_SCHEMA_VERSION = "1";
18
+ const PUBLIC_PROJECTION_SCHEMA_VERSION = "2";
19
19
  const PUBLIC_FEEDBACK_PAGE_SIZE = "25";
20
20
  const PUBLIC_ROADMAP_SORT = "updated-at-desc";
21
21