Skip to content

Commit c3080a2

Browse files
committed
handle comments
1 parent f737562 commit c3080a2

36 files changed

Lines changed: 863 additions & 317 deletions

‎README.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,24 @@ const resource = resourceFromDocument({
7373
});
7474
```
7575

76+
The bundled read-only discussion example can render the feedback site's test
77+
thread, including labels, canonical reaction counters, minimized comments, and
78+
replies:
79+
80+
`https://feedback.cpp.social/example/?site=feedback-cpp-social&key=feedback%2Fexample&github=link`
81+
7682
## Counter cache
7783

84+
The service database is a cache of GitHub discussions. It keeps
85+
lightweight discussion and comment metadata in `discussions` and `comments`,
86+
content separately in `content`, main-post reactions in `reactions`, comment
87+
reactions in `comment_reactions`, and discussion labels through
88+
`discussion_labels`. Reaction rows retain GitHub account IDs when the API
89+
returns them; an aggregate remainder row is used for accounts not included in
90+
the returned user page. The public API is read-only for discussion content and
91+
counters; discussion creation, voting, starring, and comment moderation are not
92+
exposed.
93+
7894
`cache_fresh_seconds` is the age at which a requested tracked counter needs an
7995
authoritative GitHub refresh. The default is five seconds. A batched request
8096
refreshes only stale resources; recently refreshed resources in the same request

‎config/sites.example.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,7 @@ category_id = "replace-with-category-node-id"
2222
# refresh_cooldown_seconds = 5
2323
# refresh_sweep_seconds = 86400
2424
# max_batch_size = 100
25+
features = ["counters", "viewer_reactions", "voting", "discussion", "comments", "labels", "github_link"]
26+
reaction_counters = ["LAUGH", "HOORAY", "CONFUSED", "HEART", "ROCKET", "EYES"]
27+
upvote_source = "both" # thumbsup, native, or both
28+
downvotes = true

‎config/sites.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ repository_id = "R_kgDOUc9png"
1616
installation_id = 162105624
1717
category = "General"
1818
category_id = "DIC_kwDOUc9pns4DFwQt"
19+
features = ["counters", "viewer_reactions", "voting", "discussion", "comments", "labels", "github_link"]
20+
reaction_counters = ["LAUGH", "HOORAY", "CONFUSED", "HEART", "ROCKET", "EYES"]
21+
upvote_source = "both"
22+
downvotes = true
1923

2024
[sites.cpp-social]
2125
origins = ["https://cpp.social"]

‎runtime/pages/example/index.html‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,15 @@
1111
<body>
1212
<main>
1313
<h1>Feedback integration example</h1>
14-
<p>This page exercises batched cached counts, targeted refreshes, GitHub authentication, voting, and local stars.</p>
14+
<p>This page renders the feedback discussion, canonical reactions, labels, comments, minimized comments, and replies.</p>
1515
<p>API: <code id="api-origin"></code></p>
16-
<div id="cards" class="cards"></div>
16+
<div id="authentication-status" class="authentication-status"></div>
17+
<section id="thread" class="thread"></section>
18+
<form id="comment-form" class="comment-form">
19+
<textarea id="comment-body" rows="4" maxlength="16000" placeholder="Add a comment"></textarea>
20+
<button type="submit">Sign in and comment</button>
21+
<button id="clear-reply" type="button" hidden>Cancel reply</button>
22+
</form>
1723
<p id="status" role="status">Loading…</p>
1824
</main>
1925
<script src="{{EXAMPLE_SCRIPT}}"></script>

‎runtime/pages/example/style.css‎

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,30 +14,85 @@ main {
1414
padding: 2rem;
1515
}
1616

17-
.cards {
18-
display: grid;
19-
gap: 1rem;
20-
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
21-
margin: 2rem 0;
17+
.authentication-status {
18+
align-items: center;
19+
background: #f1f5f8;
20+
border: 1px solid #d7dbe0;
21+
display: flex;
22+
gap: 0.75rem;
23+
justify-content: space-between;
24+
margin: 1rem 0;
25+
padding: 0.75rem;
2226
}
2327

24-
.card {
28+
.authentication-status small {
29+
color: #555;
30+
margin-right: auto;
31+
}
32+
33+
.thread {
2534
border: 1px solid #ddd;
2635
border-radius: 0.5rem;
2736
padding: 1rem;
2837
}
2938

30-
.card h2 {
39+
.thread h2 {
3140
font-size: 1rem;
3241
margin: 0;
3342
}
3443

35-
.controls {
44+
.reactions {
3645
display: flex;
46+
flex-wrap: wrap;
3747
gap: 0.75rem;
3848
margin-top: 1rem;
3949
}
4050

51+
.poll-option {
52+
border: 1px solid #d7dbe0;
53+
margin-top: 0.5rem;
54+
padding: 0.5rem;
55+
}
56+
57+
.poll-option.selected,
58+
.answer {
59+
border-color: #5b8c5a;
60+
background: #f1f8ef;
61+
}
62+
63+
.verified-answer {
64+
box-shadow: inset 3px 0 #2f6f3e;
65+
}
66+
67+
.comment {
68+
border-left: 3px solid #d7dbe0;
69+
margin-top: 1.5rem;
70+
padding-left: 1rem;
71+
}
72+
73+
.comment-form {
74+
display: grid;
75+
gap: 0.75rem;
76+
margin-top: 1.5rem;
77+
}
78+
79+
textarea {
80+
font: inherit;
81+
padding: 0.75rem;
82+
resize: vertical;
83+
}
84+
85+
.reply {
86+
margin-left: 1.5rem;
87+
}
88+
89+
.tag {
90+
background: #eef2f6;
91+
border-radius: 0.25rem;
92+
display: inline-block;
93+
padding: 0.25rem 0.5rem;
94+
}
95+
4196
button {
4297
cursor: pointer;
4398
font: inherit;

‎runtime/src/api/client.ts‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,17 @@ export interface EnsuredDiscussion {
6060
number: number;
6161
}
6262

63+
export interface DiscussionContent {
64+
readonly discussion: Record<string, unknown>;
65+
readonly comments: readonly Record<string, unknown>[];
66+
}
67+
68+
export interface AddedComment {
69+
readonly id: string;
70+
readonly body?: string;
71+
readonly url?: string;
72+
}
73+
6374
export class FeedbackClient {
6475
readonly #apiOrigin: string;
6576
readonly #site: string;
@@ -124,7 +135,7 @@ export class FeedbackClient {
124135
if (normalized.length === 0) throw new TypeError("At least one resource key is required");
125136
const pending = normalized.filter((key) => this.#viewerNeedsSync(key, token.viewerId));
126137
if (pending.length > 0) {
127-
const url = new URL(`/v1/sites/${encodeURIComponent(this.#site)}/viewer-reactions`, this.#apiOrigin);
138+
const url = new URL(`/v1/sites/${encodeURIComponent(this.#site)}/viewer`, this.#apiOrigin);
128139
url.searchParams.set("keys", pending.join(","));
129140
const headers = { Accept: "application/json", Authorization: `Bearer ${token.value}` };
130141
const init: RequestInit = { headers };
@@ -197,6 +208,45 @@ export class FeedbackClient {
197208
return { id: payload.id, number: payload.number };
198209
}
199210

211+
async discussionContent(key: string, signal?: AbortSignal): Promise<DiscussionContent> {
212+
validateResourceId(key);
213+
const url = new URL(`/v1/sites/${encodeURIComponent(this.#site)}/discussion`, this.#apiOrigin);
214+
url.searchParams.set("keys", key);
215+
const init: RequestInit = { headers: { Accept: "application/json" } };
216+
if (signal) init.signal = signal;
217+
const response = await this.#fetch(url, init);
218+
if (!response.ok) throw new FeedbackError(response.status, await errorCode(response));
219+
const payload: unknown = await response.json();
220+
if (!payload || typeof payload !== "object" || (payload as { v?: unknown }).v !== 1) {
221+
throw new TypeError("Invalid discussion content response");
222+
}
223+
const content = (payload as { content?: unknown }).content;
224+
if (!content || typeof content !== "object") throw new TypeError("Invalid discussion content response");
225+
return { discussion: content as Record<string, unknown>, comments: [] };
226+
}
227+
228+
async addComment(
229+
key: string,
230+
body: string,
231+
token: AccessToken,
232+
replyTo?: string,
233+
signal?: AbortSignal,
234+
): Promise<AddedComment> {
235+
validateResourceId(key);
236+
if (!body.trim() || body.length > 16_000) throw new TypeError("Invalid comment body");
237+
const payload = await this.#post(
238+
"comments",
239+
{ key, body, ...(replyTo === undefined ? {} : { reply_to: replyTo }) },
240+
signal,
241+
token.value,
242+
);
243+
const comment = payload.comment;
244+
if (!comment || typeof comment !== "object" || typeof (comment as { id?: unknown }).id !== "string") {
245+
throw new TypeError("Invalid comment response");
246+
}
247+
return comment as AddedComment;
248+
}
249+
200250
async vote(
201251
key: string,
202252
requested: Vote,

‎runtime/src/auth/status.ts‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { Authentication } from "./controller.js";
2+
3+
export interface AuthenticationStatusOptions {
4+
mount: HTMLElement;
5+
authentication: Authentication;
6+
signedOutLabel?: string;
7+
signedInLabel?: (viewerId?: string) => string;
8+
explanation?: string;
9+
loginLabel?: string;
10+
logoutLabel?: string;
11+
onError?: (error: unknown) => void;
12+
}
13+
14+
export interface AuthenticationStatusController {
15+
readonly element: HTMLElement;
16+
refresh(): void;
17+
}
18+
19+
export function createAuthenticationStatus(
20+
options: AuthenticationStatusOptions,
21+
): AuthenticationStatusController {
22+
const element = document.createElement("section");
23+
element.className = "feedback-authentication-status";
24+
const message = document.createElement("span");
25+
const action = document.createElement("button");
26+
action.type = "button";
27+
element.append(message, action);
28+
options.mount.replaceChildren(element);
29+
30+
const refresh = (): void => {
31+
const token = options.authentication.token();
32+
const signedIn = token !== null;
33+
message.textContent = signedIn
34+
? (options.signedInLabel?.(token.viewerId) ?? "Authenticated")
35+
: (options.signedOutLabel ?? "Not authenticated");
36+
action.textContent = signedIn
37+
? (options.logoutLabel ?? "Log out")
38+
: (options.loginLabel ?? "Log in");
39+
action.setAttribute("aria-label", action.textContent);
40+
action.onclick = () => {
41+
if (options.authentication.token() !== null) {
42+
options.authentication.clear();
43+
refresh();
44+
return;
45+
}
46+
action.disabled = true;
47+
void options.authentication.authenticate()
48+
.then(refresh)
49+
.catch((error: unknown) => {
50+
options.onError?.(error);
51+
refresh();
52+
})
53+
.finally(() => { action.disabled = false; });
54+
};
55+
};
56+
57+
if (options.explanation) {
58+
const explanation = document.createElement("small");
59+
explanation.textContent = options.explanation;
60+
element.insertBefore(explanation, message);
61+
}
62+
refresh();
63+
return { element, refresh };
64+
}

0 commit comments

Comments
 (0)