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
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import {
|
|
2
|
+
boundedBody,
|
|
3
|
+
BrowserCapabilityAuthorizationError,
|
|
4
|
+
createMyFeedbackModule,
|
|
5
|
+
type ConnectedDatabase,
|
|
6
|
+
type FeedbackModule,
|
|
7
|
+
type VerifiedParticipant,
|
|
8
|
+
} from "@feedbax/feedback";
|
|
9
|
+
import { sameOrigin } from "./connected-identity";
|
|
10
|
+
import { createPortalDraftManagement } from "./portal-feedback-submission";
|
|
11
|
+
|
|
12
|
+
export function createMyFeedbackRequestHandler(options: {
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
siteUrl: string;
|
|
15
|
+
database: ConnectedDatabase;
|
|
16
|
+
getParticipant(request: Request): Promise<VerifiedParticipant | undefined>;
|
|
17
|
+
getFeedback(participant: VerifiedParticipant): FeedbackModule;
|
|
18
|
+
rateLimit(request: Request): Promise<boolean>;
|
|
19
|
+
limits?: { maxTitleLength: number; maxDescriptionLength: number };
|
|
20
|
+
}) {
|
|
21
|
+
const json = (body: unknown, status = 200) =>
|
|
22
|
+
Response.json(body, {
|
|
23
|
+
status,
|
|
24
|
+
headers: {
|
|
25
|
+
"Cache-Control": "no-store",
|
|
26
|
+
"Referrer-Policy": "no-referrer",
|
|
27
|
+
Vary: "Cookie",
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
return async (request: Request): Promise<Response | undefined> => {
|
|
31
|
+
const url = new URL(request.url);
|
|
32
|
+
if (!url.pathname.startsWith("/internal/my-feedback")) return;
|
|
33
|
+
if (!options.enabled) return json({ error: "Not found." }, 404);
|
|
34
|
+
if (request.method !== "GET" && !sameOrigin(request, options.siteUrl))
|
|
35
|
+
return json({ error: "Request origin is invalid." }, 403);
|
|
36
|
+
try {
|
|
37
|
+
const participant = await options.getParticipant(request);
|
|
38
|
+
if (!participant)
|
|
39
|
+
return json(
|
|
40
|
+
{ error: "Sign in through the product to view My feedback." },
|
|
41
|
+
401,
|
|
42
|
+
);
|
|
43
|
+
if (!(await options.rateLimit(request)))
|
|
44
|
+
return json({ error: "Too many requests. Try again shortly." }, 429);
|
|
45
|
+
const feedback = options.getFeedback(participant);
|
|
46
|
+
if (
|
|
47
|
+
url.pathname === "/internal/my-feedback" &&
|
|
48
|
+
request.method === "GET"
|
|
49
|
+
) {
|
|
50
|
+
const filter = url.searchParams.get("filter") ?? "submitted";
|
|
51
|
+
if (
|
|
52
|
+
filter !== "submitted" &&
|
|
53
|
+
filter !== "supported" &&
|
|
54
|
+
filter !== "following"
|
|
55
|
+
)
|
|
56
|
+
return json({ error: "Invalid My feedback filter." }, 400);
|
|
57
|
+
return json(
|
|
58
|
+
await createMyFeedbackModule({
|
|
59
|
+
database: options.database,
|
|
60
|
+
feedback,
|
|
61
|
+
participant,
|
|
62
|
+
}).list({
|
|
63
|
+
filter,
|
|
64
|
+
cursor: url.searchParams.get("cursor") ?? undefined,
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (
|
|
69
|
+
url.pathname !== "/internal/my-feedback/post" ||
|
|
70
|
+
request.method !== "POST"
|
|
71
|
+
)
|
|
72
|
+
return json({ error: "Not found." }, 404);
|
|
73
|
+
const value = JSON.parse(await boundedBody(request, 16_384)) as Record<
|
|
74
|
+
string,
|
|
75
|
+
unknown
|
|
76
|
+
>;
|
|
77
|
+
if (
|
|
78
|
+
!value ||
|
|
79
|
+
typeof value !== "object" ||
|
|
80
|
+
typeof value.id !== "string" ||
|
|
81
|
+
value.id.length > 128
|
|
82
|
+
)
|
|
83
|
+
return json({ error: "Invalid submission request." }, 400);
|
|
84
|
+
if (value.action === "claim") {
|
|
85
|
+
if (
|
|
86
|
+
typeof value.browserCapability !== "string" ||
|
|
87
|
+
value.browserCapability.length > 512
|
|
88
|
+
)
|
|
89
|
+
return json(
|
|
90
|
+
{
|
|
91
|
+
error:
|
|
92
|
+
"A valid Browser Capability is required to link this draft.",
|
|
93
|
+
},
|
|
94
|
+
400,
|
|
95
|
+
);
|
|
96
|
+
const draft = await feedback.claimDraftPost({
|
|
97
|
+
id: value.id,
|
|
98
|
+
browserCapability:
|
|
99
|
+
value.browserCapability as import("@feedbax/feedback").BrowserCapability,
|
|
100
|
+
});
|
|
101
|
+
return draft
|
|
102
|
+
? json(draft)
|
|
103
|
+
: json({ error: "Submission unavailable." }, 404);
|
|
104
|
+
}
|
|
105
|
+
const post = await feedback.getParticipantPost(value.id);
|
|
106
|
+
if (!post) return json({ error: "Submission unavailable." }, 404);
|
|
107
|
+
if (value.action === "read") return json(post);
|
|
108
|
+
if (!post.editable)
|
|
109
|
+
return json(
|
|
110
|
+
{ error: "Only New, unpublished drafts may be changed." },
|
|
111
|
+
409,
|
|
112
|
+
);
|
|
113
|
+
const drafts = createPortalDraftManagement({
|
|
114
|
+
feedback,
|
|
115
|
+
limits: options.limits ?? {
|
|
116
|
+
maxTitleLength: 160,
|
|
117
|
+
maxDescriptionLength: 5000,
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
if (value.action === "edit") {
|
|
121
|
+
const updated = await drafts.edit({
|
|
122
|
+
...value,
|
|
123
|
+
browserCapability: "participant-owner",
|
|
124
|
+
});
|
|
125
|
+
return json({
|
|
126
|
+
id: updated.id,
|
|
127
|
+
slug: updated.slug,
|
|
128
|
+
title: updated.title,
|
|
129
|
+
description: updated.description,
|
|
130
|
+
type: updated.type,
|
|
131
|
+
status: updated.status,
|
|
132
|
+
published: updated.published,
|
|
133
|
+
createdAt: updated.createdAt,
|
|
134
|
+
updatedAt: updated.updatedAt,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
if (value.action === "withdraw") {
|
|
138
|
+
await drafts.withdraw({
|
|
139
|
+
id: value.id,
|
|
140
|
+
browserCapability: "participant-owner",
|
|
141
|
+
});
|
|
142
|
+
return json({ withdrawn: true });
|
|
143
|
+
}
|
|
144
|
+
return json({ error: "Invalid submission action." }, 400);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (error instanceof BrowserCapabilityAuthorizationError)
|
|
147
|
+
return json({ error: "Submission unavailable." }, 404);
|
|
148
|
+
return json(
|
|
149
|
+
{ error: "My feedback is unavailable. Refresh and try again." },
|
|
150
|
+
503,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
cleanup,
|
|
5
|
+
fireEvent,
|
|
6
|
+
render as renderView,
|
|
7
|
+
screen,
|
|
8
|
+
within,
|
|
9
|
+
} from "@testing-library/react";
|
|
4
10
|
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
11
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
12
|
+
import type { ReactNode } from "react";
|
|
5
13
|
|
|
6
14
|
import { OverlayPostDetail } from "./overlay-post-detail";
|
|
7
15
|
import {
|
|
@@ -60,7 +68,7 @@ it("shows loading instead of stale content when the requested slug changes", asy
|
|
|
60
68
|
).toBeTruthy();
|
|
61
69
|
});
|
|
62
70
|
|
|
63
|
-
it("shows
|
|
71
|
+
it("shows Post details without the redundant full-page toolbar", async () => {
|
|
64
72
|
vi.mocked(getPublicPost).mockResolvedValue(firstPost);
|
|
65
73
|
vi.mocked(getPublicPostComments).mockResolvedValue({
|
|
66
74
|
items: [
|
|
@@ -83,25 +91,50 @@ it("shows the designed Post detail with a canonical full-page link", async () =>
|
|
|
83
91
|
expect(
|
|
84
92
|
await screen.findByRole("heading", { name: "First Post" }),
|
|
85
93
|
).toBeTruthy();
|
|
86
|
-
expect(
|
|
87
|
-
screen.getByRole("link", { name: "Open full page" }).getAttribute("href"),
|
|
88
|
-
).toBe("/p/first-post");
|
|
94
|
+
expect(screen.queryByRole("link", { name: "Open full page" })).toBeNull();
|
|
89
95
|
expect(screen.getByRole("complementary", { name: "Details" })).toBeTruthy();
|
|
90
96
|
expect(
|
|
91
|
-
within(screen.getByRole("region", { name: "Comments" })).
|
|
97
|
+
await within(screen.getByRole("region", { name: "Comments" })).findByText(
|
|
92
98
|
"Visible in the dialog",
|
|
93
99
|
),
|
|
94
100
|
).toBeTruthy();
|
|
95
101
|
expect(
|
|
96
102
|
await screen.findByRole("form", { name: "Comment composer" }),
|
|
97
103
|
).toBeTruthy();
|
|
104
|
+
const composer = screen.getByRole("form", { name: "Comment composer" });
|
|
105
|
+
const threads = screen.getByRole("region", { name: "Comment threads" });
|
|
106
|
+
expect(
|
|
107
|
+
composer.compareDocumentPosition(threads) &
|
|
108
|
+
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
109
|
+
).toBeTruthy();
|
|
110
|
+
expect(
|
|
111
|
+
within(threads).queryByRole("form", { name: "Comment composer" }),
|
|
112
|
+
).toBeNull();
|
|
98
113
|
expect(getPublicPostComments).toHaveBeenCalledWith({
|
|
99
114
|
data: { slug: "first-post" },
|
|
100
115
|
});
|
|
101
116
|
expect(screen.queryByRole("link", { name: "Back to Feedback" })).toBeNull();
|
|
102
117
|
});
|
|
103
118
|
|
|
104
|
-
it("
|
|
119
|
+
it("shows the Post while Comments are still loading", async () => {
|
|
120
|
+
vi.mocked(getPublicPost).mockResolvedValue(firstPost);
|
|
121
|
+
let finish!: (page: { items: [] }) => void;
|
|
122
|
+
vi.mocked(getPublicPostComments).mockReturnValue(
|
|
123
|
+
new Promise((resolve) => {
|
|
124
|
+
finish = resolve;
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
render(<OverlayPostDetail slug="first-post" />);
|
|
128
|
+
expect(
|
|
129
|
+
await screen.findByRole("heading", { name: "First Post" }),
|
|
130
|
+
).toBeTruthy();
|
|
131
|
+
expect(screen.getByRole("status", { name: "Loading Comments" })).toBeTruthy();
|
|
132
|
+
expect(screen.queryByText("No comments yet")).toBeNull();
|
|
133
|
+
finish({ items: [] });
|
|
134
|
+
expect(await screen.findByText("No comments yet")).toBeTruthy();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("keeps the Post available and retries a failed Comment load", async () => {
|
|
105
138
|
vi.mocked(getPublicPost).mockResolvedValue(firstPost);
|
|
106
139
|
vi.mocked(getPublicPostComments).mockRejectedValue(
|
|
107
140
|
new Error("Comments temporarily unavailable"),
|
|
@@ -115,7 +148,14 @@ it("keeps the Post and Comment composer available when Comment loading fails", a
|
|
|
115
148
|
expect(
|
|
116
149
|
await screen.findByRole("form", { name: "Comment composer" }),
|
|
117
150
|
).toBeTruthy();
|
|
118
|
-
expect(screen.
|
|
151
|
+
expect(await screen.findByRole("alert")).toHaveProperty(
|
|
152
|
+
"textContent",
|
|
153
|
+
"Comments could not be loaded.",
|
|
154
|
+
);
|
|
155
|
+
expect(screen.queryByText("No comments yet")).toBeNull();
|
|
156
|
+
vi.mocked(getPublicPostComments).mockResolvedValue({ items: [] });
|
|
157
|
+
fireEvent.click(screen.getByRole("button", { name: "Retry Comments" }));
|
|
158
|
+
expect(await screen.findByText("No comments yet")).toBeTruthy();
|
|
119
159
|
expect(screen.queryByText("Draft fallback")).toBeNull();
|
|
120
160
|
});
|
|
121
161
|
|
|
@@ -128,3 +168,14 @@ const firstPost = {
|
|
|
128
168
|
createdAt: new Date("2026-08-22T10:00:00.000Z"),
|
|
129
169
|
updatedAt: new Date("2026-08-22T10:00:00.000Z"),
|
|
130
170
|
};
|
|
171
|
+
|
|
172
|
+
function render(ui: ReactNode) {
|
|
173
|
+
const client = new QueryClient({
|
|
174
|
+
defaultOptions: { queries: { retry: false } },
|
|
175
|
+
});
|
|
176
|
+
return renderView(ui, {
|
|
177
|
+
wrapper: ({ children }) => (
|
|
178
|
+
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
|
179
|
+
),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
@@ -1,88 +1,39 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { CommentThreadPage, PublicPost } from "@feedbax/feedback";
|
|
3
|
-
import { ExternalLink } from "lucide-react";
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
4
2
|
|
|
5
3
|
import {
|
|
6
4
|
getPublicPost,
|
|
7
5
|
getPublicPostComments,
|
|
8
6
|
} from "./public-post-server-function";
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} from "./public-post-detail";
|
|
7
|
+
import { PublicPostDetailSkeleton } from "./public-post-detail-skeleton";
|
|
8
|
+
import { publicPostQuery } from "./post-detail-query";
|
|
9
|
+
import { PostDetailContent } from "./post-detail-content";
|
|
13
10
|
import { AuthorizedDraftPost } from "./authorized-draft-post";
|
|
14
11
|
import { mutatePublicComment, submitPublicComment } from "./public-post-route";
|
|
15
|
-
import { mergeCommentThreads } from "./comment-thread-page";
|
|
16
12
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
comments: CommentThreadPage;
|
|
20
|
-
};
|
|
13
|
+
const loadComments = (slug: string, cursor?: string) =>
|
|
14
|
+
getPublicPostComments({ data: { slug, ...(cursor ? { cursor } : {}) } });
|
|
21
15
|
|
|
22
16
|
export function OverlayPostDetail({ slug }: { slug: string }) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
? await getPublicPostComments({ data: { slug } }).catch(() => ({
|
|
32
|
-
items: [],
|
|
33
|
-
}))
|
|
34
|
-
: { items: [] },
|
|
35
|
-
}))
|
|
36
|
-
.then((value) => current && setDetail(value))
|
|
37
|
-
.catch(
|
|
38
|
-
() => current && setDetail({ post: null, comments: { items: [] } }),
|
|
39
|
-
);
|
|
40
|
-
return () => {
|
|
41
|
-
current = false;
|
|
42
|
-
};
|
|
43
|
-
}, [slug]);
|
|
17
|
+
return <OverlayPost key={slug} slug={slug} />;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function OverlayPost({ slug }: { slug: string }) {
|
|
21
|
+
const query = useQuery(
|
|
22
|
+
publicPostQuery(slug, (value) => getPublicPost({ data: { slug: value } })),
|
|
23
|
+
);
|
|
24
|
+
const post = query.data;
|
|
44
25
|
|
|
45
|
-
if (detail === undefined) return <PublicPostDetailSkeleton />;
|
|
46
26
|
return (
|
|
47
27
|
<div className="overlay-post-detail">
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
</a>
|
|
54
|
-
{detail.post ? (
|
|
55
|
-
<PublicPostDetail
|
|
56
|
-
post={detail.post}
|
|
28
|
+
{query.isPending ? (
|
|
29
|
+
<PublicPostDetailSkeleton display="overlay" />
|
|
30
|
+
) : post ? (
|
|
31
|
+
<PostDetailContent
|
|
32
|
+
post={post}
|
|
57
33
|
display="overlay"
|
|
58
|
-
|
|
34
|
+
loadComments={loadComments}
|
|
59
35
|
submitComment={submitPublicComment}
|
|
60
36
|
mutateComment={mutatePublicComment}
|
|
61
|
-
loadMore={
|
|
62
|
-
detail.comments.nextCursor
|
|
63
|
-
? async () => {
|
|
64
|
-
const next = await getPublicPostComments({
|
|
65
|
-
data: { slug, cursor: detail.comments.nextCursor },
|
|
66
|
-
});
|
|
67
|
-
setDetail((current) =>
|
|
68
|
-
current
|
|
69
|
-
? {
|
|
70
|
-
...current,
|
|
71
|
-
comments: {
|
|
72
|
-
items: mergeCommentThreads(
|
|
73
|
-
current.comments.items,
|
|
74
|
-
next.items,
|
|
75
|
-
),
|
|
76
|
-
...(next.nextCursor
|
|
77
|
-
? { nextCursor: next.nextCursor }
|
|
78
|
-
: {}),
|
|
79
|
-
},
|
|
80
|
-
}
|
|
81
|
-
: current,
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
: undefined
|
|
85
|
-
}
|
|
86
37
|
/>
|
|
87
38
|
) : (
|
|
88
39
|
<AuthorizedDraftPost slug={slug} />
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { queryOptions } from "@tanstack/react-query";
|
|
2
|
+
import type { ParticipantNotification, FollowState } from "@feedbax/feedback";
|
|
3
|
+
export interface NotificationPage {
|
|
4
|
+
items: ParticipantNotification[];
|
|
5
|
+
unreadCount: number;
|
|
6
|
+
nextCursor?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface EmailPreferences {
|
|
9
|
+
emailAvailable: boolean;
|
|
10
|
+
emailEnabled: boolean;
|
|
11
|
+
}
|
|
12
|
+
export async function participationRequest<T>(
|
|
13
|
+
path: string,
|
|
14
|
+
body?: unknown,
|
|
15
|
+
): Promise<T> {
|
|
16
|
+
const response = await fetch(`/internal/connected/${path}`, {
|
|
17
|
+
cache: "no-store",
|
|
18
|
+
...(body === undefined
|
|
19
|
+
? {}
|
|
20
|
+
: {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: { "Content-Type": "application/json" },
|
|
23
|
+
body: JSON.stringify(body),
|
|
24
|
+
}),
|
|
25
|
+
});
|
|
26
|
+
const value = await response.json();
|
|
27
|
+
if (!response.ok)
|
|
28
|
+
throw new Error(
|
|
29
|
+
value && typeof value === "object" && "error" in value
|
|
30
|
+
? String(value.error)
|
|
31
|
+
: "Updates are temporarily unavailable.",
|
|
32
|
+
);
|
|
33
|
+
return value as T;
|
|
34
|
+
}
|
|
35
|
+
export const followQuery = (slug: string) =>
|
|
36
|
+
queryOptions({
|
|
37
|
+
queryKey: ["participant-follow", slug],
|
|
38
|
+
queryFn: () =>
|
|
39
|
+
participationRequest<FollowState>(
|
|
40
|
+
`follow?slug=${encodeURIComponent(slug)}`,
|
|
41
|
+
),
|
|
42
|
+
staleTime: 0,
|
|
43
|
+
retry: false,
|
|
44
|
+
refetchInterval: (query) =>
|
|
45
|
+
query.state.data?.initializing ? 15000 : false,
|
|
46
|
+
});
|
|
47
|
+
export const preferencesQuery = () =>
|
|
48
|
+
queryOptions({
|
|
49
|
+
queryKey: ["participant-email-preferences"],
|
|
50
|
+
queryFn: () => participationRequest<EmailPreferences>("preferences"),
|
|
51
|
+
staleTime: 0,
|
|
52
|
+
retry: false,
|
|
53
|
+
});
|
|
54
|
+
export const notificationQuery = {
|
|
55
|
+
queryKey: ["participant-notifications"],
|
|
56
|
+
initialPageParam: undefined as string | undefined,
|
|
57
|
+
queryFn: ({ pageParam }: { pageParam: string | undefined }) =>
|
|
58
|
+
participationRequest<NotificationPage>(
|
|
59
|
+
`notifications${pageParam ? `?cursor=${encodeURIComponent(pageParam)}` : ""}`,
|
|
60
|
+
),
|
|
61
|
+
getNextPageParam: (last: NotificationPage) => last.nextCursor,
|
|
62
|
+
retry: false as const,
|
|
63
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { expect, it } from "vitest";
|
|
2
|
+
import { createParticipationRequestHandler } from "./participation-runtime";
|
|
3
|
+
|
|
4
|
+
it("keeps social routes private and rejects cross-origin mutations before touching participation", async () => {
|
|
5
|
+
let read = false;
|
|
6
|
+
const handler = createParticipationRequestHandler({
|
|
7
|
+
siteUrl: "https://feedback.example.com",
|
|
8
|
+
enabled: true,
|
|
9
|
+
getParticipant: async () => undefined,
|
|
10
|
+
getModule: () => {
|
|
11
|
+
read = true;
|
|
12
|
+
throw new Error();
|
|
13
|
+
},
|
|
14
|
+
rateLimit: async () => true,
|
|
15
|
+
});
|
|
16
|
+
const privateRead = await handler(
|
|
17
|
+
new Request(
|
|
18
|
+
"https://feedback.example.com/internal/connected/notifications",
|
|
19
|
+
),
|
|
20
|
+
);
|
|
21
|
+
expect(privateRead?.status).toBe(401);
|
|
22
|
+
expect(privateRead?.headers.get("Cache-Control")).toBe("no-store");
|
|
23
|
+
const mutation = await handler(
|
|
24
|
+
new Request("https://feedback.example.com/internal/connected/follow", {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: { Origin: "https://evil.example.com" },
|
|
27
|
+
body: JSON.stringify({ slug: "public", following: true }),
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
expect(mutation?.status).toBe(403);
|
|
31
|
+
expect(read).toBe(false);
|
|
32
|
+
expect(
|
|
33
|
+
await handler(new Request("https://feedback.example.com/roadmap")),
|
|
34
|
+
).toBeUndefined();
|
|
35
|
+
});
|