@eifi1/ui-kit 0.37.0 → 0.38.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 (130) hide show
  1. package/README.md +11 -4
  2. package/dist/eslint/index.js +1 -1
  3. package/dist/feedback/feedback-comment-thread.d.ts +87 -0
  4. package/dist/feedback/feedback-comment-thread.js +170 -0
  5. package/dist/feedback/feedback-comment-thread.js.map +1 -0
  6. package/dist/feedback/feedback-dialog-labels.js +2 -1
  7. package/dist/feedback/feedback-dialog-labels.js.map +1 -1
  8. package/dist/feedback/feedback-dialog.d.ts +13 -0
  9. package/dist/feedback/feedback-dialog.js +12 -4
  10. package/dist/feedback/feedback-dialog.js.map +1 -1
  11. package/dist/feedback/feedback-labels.d.ts +11 -0
  12. package/dist/feedback/feedback-labels.js +4 -1
  13. package/dist/feedback/feedback-labels.js.map +1 -1
  14. package/dist/feedback/feedback-menu-labels.js +2 -1
  15. package/dist/feedback/feedback-menu-labels.js.map +1 -1
  16. package/dist/feedback/feedback-menu.d.ts +17 -3
  17. package/dist/feedback/feedback-menu.js +11 -1
  18. package/dist/feedback/feedback-menu.js.map +1 -1
  19. package/dist/feedback/feedback-public-labels.d.ts +53 -0
  20. package/dist/feedback/feedback-public-labels.js +27 -0
  21. package/dist/feedback/feedback-public-labels.js.map +1 -0
  22. package/dist/feedback/feedback-public-list.d.ts +139 -0
  23. package/dist/feedback/feedback-public-list.js +299 -0
  24. package/dist/feedback/feedback-public-list.js.map +1 -0
  25. package/dist/feedback/feedback-public-switch.d.ts +51 -0
  26. package/dist/feedback/feedback-public-switch.js +50 -0
  27. package/dist/feedback/feedback-public-switch.js.map +1 -0
  28. package/dist/feedback/feedback-public-types.d.ts +75 -0
  29. package/dist/feedback/feedback-public-types.js +2 -0
  30. package/dist/feedback/feedback-public-types.js.map +1 -0
  31. package/dist/feedback/feedback-record.d.ts +32 -1
  32. package/dist/feedback/feedback-record.js +10 -0
  33. package/dist/feedback/feedback-record.js.map +1 -1
  34. package/dist/feedback/feedback-replies-context.d.ts +7 -0
  35. package/dist/feedback/feedback-replies-context.js +7 -0
  36. package/dist/feedback/feedback-replies-context.js.map +1 -0
  37. package/dist/feedback/feedback-replies.d.ts +97 -0
  38. package/dist/feedback/feedback-replies.js +114 -0
  39. package/dist/feedback/feedback-replies.js.map +1 -0
  40. package/dist/feedback/feedback-row-detail-labels.js +1 -0
  41. package/dist/feedback/feedback-row-detail-labels.js.map +1 -1
  42. package/dist/feedback/feedback-row-detail.d.ts +47 -10
  43. package/dist/feedback/feedback-row-detail.js +51 -50
  44. package/dist/feedback/feedback-row-detail.js.map +1 -1
  45. package/dist/feedback/feedback-submit-dialog.d.ts +3 -1
  46. package/dist/feedback/feedback-submit-dialog.js +6 -1
  47. package/dist/feedback/feedback-submit-dialog.js.map +1 -1
  48. package/dist/feedback/feedback-submit.d.ts +8 -0
  49. package/dist/feedback/feedback-submit.js +3 -2
  50. package/dist/feedback/feedback-submit.js.map +1 -1
  51. package/dist/feedback/feedback-table.d.ts +15 -5
  52. package/dist/feedback/feedback-table.js +46 -5
  53. package/dist/feedback/feedback-table.js.map +1 -1
  54. package/dist/feedback/feedback-thread-parts-labels.d.ts +6 -0
  55. package/dist/feedback/feedback-thread-parts-labels.js +36 -0
  56. package/dist/feedback/feedback-thread-parts-labels.js.map +1 -0
  57. package/dist/feedback/feedback-thread.d.ts +8 -0
  58. package/dist/feedback/feedback-thread.js +7 -2
  59. package/dist/feedback/feedback-thread.js.map +1 -1
  60. package/dist/feedback/feedback-vote-button.d.ts +49 -0
  61. package/dist/feedback/feedback-vote-button.js +72 -0
  62. package/dist/feedback/feedback-vote-button.js.map +1 -0
  63. package/dist/feedback.d.ts +6 -0
  64. package/dist/feedback.js +6 -0
  65. package/dist/feedback.js.map +1 -1
  66. package/dist/i18n/defaults.js +12 -0
  67. package/dist/i18n/defaults.js.map +1 -1
  68. package/dist/i18n/german.js +69 -3
  69. package/dist/i18n/german.js.map +1 -1
  70. package/dist/i18n/kit-labels.d.ts +16 -0
  71. package/dist/i18n/kit-labels.js.map +1 -1
  72. package/dist/i18n/locales/en.js +85 -3
  73. package/dist/i18n/locales/en.js.map +3 -3
  74. package/dist/i18n/locales/es.js +69 -3
  75. package/dist/i18n/locales/es.js.map +1 -1
  76. package/dist/i18n/locales/fr.js +69 -3
  77. package/dist/i18n/locales/fr.js.map +1 -1
  78. package/dist/i18n/locales/hu.js +69 -3
  79. package/dist/i18n/locales/hu.js.map +1 -1
  80. package/dist/i18n/locales/it.js +69 -3
  81. package/dist/i18n/locales/it.js.map +1 -1
  82. package/dist/i18n/locales/zh.js +69 -3
  83. package/dist/i18n/locales/zh.js.map +1 -1
  84. package/dist/i18n/review.js +5 -0
  85. package/dist/i18n/review.js.map +1 -1
  86. package/dist/index.d.ts +2 -0
  87. package/dist/index.js +6 -0
  88. package/dist/index.js.map +1 -1
  89. package/dist/notifications/feedback-notification-types-labels.d.ts +15 -0
  90. package/dist/notifications/feedback-notification-types-labels.js +11 -0
  91. package/dist/notifications/feedback-notification-types-labels.js.map +1 -0
  92. package/dist/notifications/feedback-notification-types.d.ts +28 -0
  93. package/dist/notifications/feedback-notification-types.js +21 -0
  94. package/dist/notifications/feedback-notification-types.js.map +1 -0
  95. package/package.json +1 -1
  96. package/src/feedback/feedback-comment-thread.tsx +277 -0
  97. package/src/feedback/feedback-dialog-labels.ts +1 -0
  98. package/src/feedback/feedback-dialog.tsx +30 -2
  99. package/src/feedback/feedback-labels.ts +14 -0
  100. package/src/feedback/feedback-menu-labels.ts +1 -0
  101. package/src/feedback/feedback-menu.tsx +29 -3
  102. package/src/feedback/feedback-public-labels.ts +80 -0
  103. package/src/feedback/feedback-public-list.tsx +478 -0
  104. package/src/feedback/feedback-public-switch.tsx +102 -0
  105. package/src/feedback/feedback-public-types.ts +82 -0
  106. package/src/feedback/feedback-record.ts +39 -1
  107. package/src/feedback/feedback-replies-context.ts +9 -0
  108. package/src/feedback/feedback-replies.tsx +235 -0
  109. package/src/feedback/feedback-row-detail-labels.ts +1 -0
  110. package/src/feedback/feedback-row-detail.tsx +115 -74
  111. package/src/feedback/feedback-submit-dialog.tsx +9 -0
  112. package/src/feedback/feedback-submit.tsx +10 -1
  113. package/src/feedback/feedback-table.tsx +73 -6
  114. package/src/feedback/feedback-thread-parts-labels.ts +41 -0
  115. package/src/feedback/feedback-thread.tsx +18 -1
  116. package/src/feedback/feedback-vote-button.tsx +123 -0
  117. package/src/feedback.ts +10 -0
  118. package/src/i18n/defaults.ts +12 -0
  119. package/src/i18n/german.ts +66 -0
  120. package/src/i18n/kit-labels.tsx +16 -0
  121. package/src/i18n/locales/en.ts +8 -0
  122. package/src/i18n/locales/es.ts +66 -0
  123. package/src/i18n/locales/fr.ts +66 -0
  124. package/src/i18n/locales/hu.ts +66 -0
  125. package/src/i18n/locales/it.ts +66 -0
  126. package/src/i18n/locales/zh.ts +66 -0
  127. package/src/i18n/review.ts +5 -0
  128. package/src/index.ts +9 -0
  129. package/src/notifications/feedback-notification-types-labels.ts +24 -0
  130. package/src/notifications/feedback-notification-types.ts +46 -0
@@ -1,5 +1,6 @@
1
1
  import { FEEDBACK_CATEGORY_ORDER } from "./feedback-vocabulary";
2
2
  import type { FeedbackCategory, FeedbackStatus } from "./feedback-vocabulary";
3
+ import type { FeedbackReply } from "./feedback-public-types";
3
4
 
4
5
  /**
5
6
  * One feedback report as the three apps send it, and the facts the kit reads off it.
@@ -102,7 +103,9 @@ export interface FeedbackRecord {
102
103
  /** Up to 5 other files; `null` = none. Optional because a server older than the
103
104
  * column sends no key at all (keksdose dev #578 met exactly that). */
104
105
  attachment_urls?: string[] | null;
105
- /** The team's answer; `null` = none written. */
106
+ /** The team's answer; `null` = none written. **Frozen since 0.38** (§9.7): answered
107
+ * as stored for one release, written by nothing, then dropped — the answer is
108
+ * {@link FeedbackRecord.replies} now. */
106
109
  outcome: string | null;
107
110
  /** Set on ENTERING `DONE` / `WONT_DO`, cleared on leaving them. ISO 8601. */
108
111
  resolved_at: string | null;
@@ -110,6 +113,24 @@ export interface FeedbackRecord {
110
113
  created_at: string;
111
114
  /** ISO 8601. */
112
115
  updated_at: string;
116
+ // ── Round 0.38 (§9.3): all optional, so an app without public parts sends none. ──
117
+ /** The admin: the full name; the author: their own; `null`: none to show. */
118
+ author_name?: string | null;
119
+ /** The viewer filed it. */
120
+ mine?: boolean | null;
121
+ /** Visible to the app's other users (§9.3); never on a CRASH. */
122
+ public?: boolean | null;
123
+ /** Made public by the 0.38 migration: the author may switch it off at any status. */
124
+ filed_private?: boolean | null;
125
+ /** People who want this (the author's own vote included, §9.6). */
126
+ votes?: number | null;
127
+ /** The viewer's vote. */
128
+ voted?: boolean | null;
129
+ /** Comments (an admin's count includes hidden ones). */
130
+ comment_count?: number | null;
131
+ /** The admin's answers, oldest first (§9.7). Absent: a server before 0.38 — read
132
+ * `outcome` instead. */
133
+ replies?: FeedbackReply[] | null;
113
134
  }
114
135
 
115
136
  /* ── Constants (§3.4, §3.5, §4.3) ─────────────────────────────────────────── */
@@ -195,6 +216,23 @@ export const FEEDBACK_AWAITING_STATUSES: readonly FeedbackStatus[] = Object.free
195
216
  "NEEDS_LIVE_TEST",
196
217
  ] as const);
197
218
 
219
+ /**
220
+ * The **decided** statuses (§9.7, decision 13): `DONE`, `WONT_DO`, `POSTPONED`. A
221
+ * status-only move into one tells the author nothing (a status alone sends no
222
+ * notification), so the row detail nudges the admin toward a reply until one with that
223
+ * `status_to` exists.
224
+ */
225
+ export const FEEDBACK_DECIDED_STATUSES: readonly FeedbackStatus[] = Object.freeze([
226
+ "DONE",
227
+ "WONT_DO",
228
+ "POSTPONED",
229
+ ] as const);
230
+
231
+ /** A comment's longest body after trimming (§9.8). */
232
+ export const FEEDBACK_COMMENT_MAX = 4000;
233
+ /** A reply's longest body after trimming (§9.7). */
234
+ export const FEEDBACK_REPLY_MAX = 8000;
235
+
198
236
  /* ── The body (§3.4) ──────────────────────────────────────────────────────── */
199
237
 
200
238
  /**
@@ -0,0 +1,9 @@
1
+ import { createContext } from "react";
2
+
3
+ /**
4
+ * `true` inside `FeedbackRowDetail`'s Comments section (0.38.1, Kurvenschmiede): the
5
+ * detail's own Replies section draws the report's replies already, so a
6
+ * `FeedbackCommentThread` there draws the comments alone — whatever `replies` it was
7
+ * handed. Internal: not re-exported.
8
+ */
9
+ export const FeedbackRepliesDrawnAboveContext = createContext(false);
@@ -0,0 +1,235 @@
1
+ import { useState } from "react";
2
+ import { BellOff } from "lucide-react";
3
+ import { Select } from "../components/select";
4
+ import type { CommitScope } from "../components/write-lock";
5
+ import { useKitLabels, useKitLocale, type LabelOverride } from "../i18n/kit-labels";
6
+ import { cn } from "../lib/cn";
7
+ import { formatDate } from "../lib/format";
8
+ import { FeedbackStatusBadge, selectableFeedbackStatuses, type FeedbackStatus } from "./feedback-inbox";
9
+ import { useFeedbackStatusLabels } from "./feedback-labels";
10
+ import type { FeedbackReply } from "./feedback-public-types";
11
+ import { FEEDBACK_DECIDED_STATUSES, FEEDBACK_REPLY_MAX } from "./feedback-record";
12
+ import { ChatComposer } from "./feedback-thread";
13
+ import { DEFAULT_FEEDBACK_REPLIES_LABELS } from "./feedback-thread-parts-labels";
14
+
15
+ /**
16
+ * The admin's answers as a list (docs/feedback-harmonization.md §9.7, decision 5): each
17
+ * reply its own row — who, when, what, and the status it moved the report to — where
18
+ * 0.37 had one `outcome` field that every answer overwrote. {@link FeedbackReplyTimeline}
19
+ * draws them; {@link FeedbackReplyComposer} writes the next one, with the status picker
20
+ * beside it (decision 13: a reply notifies the author, a status alone does not, so the
21
+ * status belongs with the words).
22
+ */
23
+
24
+ /* ── Labels ───────────────────────────────────────────────────────────────── */
25
+
26
+ /** The `feedbackReplies` namespace of `<UiKitProvider labels>`. */
27
+ export interface FeedbackRepliesLabels {
28
+ /** The timeline's accessible name. de-CH: "Antworten" */
29
+ replies: string;
30
+ /** No reply yet. de-CH: "Noch keine Antwort" */
31
+ empty: string;
32
+ /** A reply without an author — a loop's, a migrated outcome, an erased admin (§9.7).
33
+ * The app's word for its admin; never "the team". de-CH: "Admin" */
34
+ admin: string;
35
+ /** Read before a reply's status chip ("→ Done"). de-CH: "Status geändert auf" */
36
+ statusChanged: string;
37
+ /** The reply box's accessible name. de-CH: "Antwort schreiben" */
38
+ field: string;
39
+ /** de-CH: "Antwort schreiben…" */
40
+ placeholder: string;
41
+ /** The send button. de-CH: "Antworten" */
42
+ reply: string;
43
+ /** The status picker's accessible name. de-CH: "Status mit der Antwort" */
44
+ status: string;
45
+ /** The picker's first option: the reply changes no status. de-CH: "Keine Änderung" */
46
+ noChange: string;
47
+ /** Above the composer when a decided status was set without a reply (§9.7, B2).
48
+ * de-CH: "Die meldende Person wurde nicht benachrichtigt. Schreiben Sie eine Antwort." */
49
+ nudge: string;
50
+ }
51
+
52
+ export { DEFAULT_FEEDBACK_REPLIES_LABELS };
53
+
54
+ /** `feedbackReplies` resolved: English, then `<UiKitProvider labels>`, then `labels`. */
55
+ export function useFeedbackRepliesLabels(labels?: LabelOverride<FeedbackRepliesLabels>): FeedbackRepliesLabels {
56
+ return useKitLabels("feedbackReplies", DEFAULT_FEEDBACK_REPLIES_LABELS, labels);
57
+ }
58
+
59
+ /**
60
+ * The status a row detail should nudge toward (§9.7, B2), or `undefined`: the row's
61
+ * status when it is decided ({@link FEEDBACK_DECIDED_STATUSES}) and no reply carries it
62
+ * as its `status_to` — a status-only move from the table, a swipe or the pills told the
63
+ * author nothing. `undefined` too without `replies` (a server before 0.38).
64
+ */
65
+ export function feedbackReplyNudge(row: {
66
+ status: FeedbackStatus;
67
+ replies?: readonly Pick<FeedbackReply, "status_to">[] | null;
68
+ }): FeedbackStatus | undefined {
69
+ if (!row.replies || !FEEDBACK_DECIDED_STATUSES.includes(row.status)) return undefined;
70
+ return row.replies.some((reply) => reply.status_to === row.status) ? undefined : row.status;
71
+ }
72
+
73
+ /* ── The timeline ─────────────────────────────────────────────────────────── */
74
+
75
+ export interface FeedbackReplyTimelineProps {
76
+ /** Oldest first, as the server sends them. */
77
+ replies: readonly FeedbackReply[];
78
+ /** How a reply's time reads. Default: the date and time in the provider's locale. */
79
+ renderDate?: (iso: string) => string;
80
+ /** Tag bodies and names `data-private` (a host's screen-share blur). Default true. */
81
+ redact?: boolean;
82
+ labels?: LabelOverride<FeedbackRepliesLabels>;
83
+ className?: string;
84
+ }
85
+
86
+ /**
87
+ * The replies, oldest first, down a rule: each with its author (`null` → "Admin"), its
88
+ * time, the status it set ("→ Done"; the admin view adds where from) and its text. No
89
+ * edit and no delete (§9.7): a correction is a new reply.
90
+ */
91
+ export function FeedbackReplyTimeline({
92
+ replies,
93
+ renderDate,
94
+ redact = true,
95
+ labels: labelsProp,
96
+ className,
97
+ }: FeedbackReplyTimelineProps) {
98
+ const labels = useFeedbackRepliesLabels(labelsProp);
99
+ const locale = useKitLocale();
100
+ const priv = redact ? "" : undefined;
101
+ if (replies.length === 0) {
102
+ return <p className={cn("text-sm text-placeholder", className)}>{labels.empty}</p>;
103
+ }
104
+ const dateText = (iso: string) =>
105
+ renderDate ? renderDate(iso) : formatDate(iso, { dateStyle: "medium", timeStyle: "short" }, { locale });
106
+ return (
107
+ <ol aria-label={labels.replies} className={cn("space-y-3 border-s-2 border-subtle ps-3", className)}>
108
+ {replies.map((reply) => (
109
+ <li key={reply.id} data-feedback-reply={reply.id} className="min-w-0 text-sm">
110
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-caption text-muted">
111
+ <span data-private={priv} className="font-medium text-secondary">
112
+ {reply.author_name ?? labels.admin}
113
+ </span>
114
+ <time dateTime={reply.created_at} className="tabular-nums">
115
+ {dateText(reply.created_at)}
116
+ </time>
117
+ {reply.status_to && (
118
+ <span className="inline-flex min-w-0 max-w-full flex-wrap items-center gap-1" data-feedback-reply-status={reply.status_to}>
119
+ <span className="sr-only">{labels.statusChanged}</span>
120
+ {reply.status_from && reply.status_from !== reply.status_to && (
121
+ <>
122
+ <FeedbackStatusBadge status={reply.status_from} />
123
+ <span aria-hidden>→</span>
124
+ </>
125
+ )}
126
+ {!reply.status_from && <span aria-hidden>→</span>}
127
+ <FeedbackStatusBadge status={reply.status_to} />
128
+ </span>
129
+ )}
130
+ </div>
131
+ <p data-private={priv} dir="auto" className="mt-0.5 whitespace-pre-wrap break-words text-start text-primary">
132
+ {reply.body}
133
+ </p>
134
+ </li>
135
+ ))}
136
+ </ol>
137
+ );
138
+ }
139
+
140
+ /* ── The composer ─────────────────────────────────────────────────────────── */
141
+
142
+ export interface FeedbackReplyComposerProps {
143
+ /** The report's status now — what "No change" keeps. */
144
+ status: FeedbackStatus;
145
+ /**
146
+ * `POST /feedback/{id}/replies` — the body (1–{@link FEEDBACK_REPLY_MAX} after
147
+ * trimming) and the status picked, `undefined` for "No change". Return the promise:
148
+ * the box clears when it resolves, and keeps everything on a rejection.
149
+ */
150
+ onReply: (body: string, status?: FeedbackStatus) => void | Promise<unknown>;
151
+ /** The reply is in flight. */
152
+ pending?: boolean;
153
+ /** Preselect this status and say the author has not been told (§9.7, the nudge) —
154
+ * {@link feedbackReplyNudge}. */
155
+ suggestedStatus?: FeedbackStatus;
156
+ /** Reply COMMITS — see ChatComposer's `commit`. */
157
+ commit?: CommitScope;
158
+ labels?: LabelOverride<FeedbackRepliesLabels>;
159
+ className?: string;
160
+ }
161
+
162
+ /**
163
+ * The admin's reply box (§9.7): the text, the **status** to set with it ("No change"
164
+ * first, then every status — {@link selectableFeedbackStatuses}), and **Reply**. A
165
+ * {@link ChatComposer} underneath: Ctrl/⌘+Enter sends, the count runs to 8 000, a draft
166
+ * typed while a reply is in flight stays.
167
+ *
168
+ * With `suggestedStatus` the line "The author has not been told. Add a reply." stands
169
+ * above it and that status is picked, so the reply that answers it records the status
170
+ * and the nudge goes away.
171
+ */
172
+ export function FeedbackReplyComposer({
173
+ status,
174
+ onReply,
175
+ pending = false,
176
+ suggestedStatus,
177
+ commit,
178
+ labels: labelsProp,
179
+ className,
180
+ }: FeedbackReplyComposerProps) {
181
+ const labels = useFeedbackRepliesLabels(labelsProp);
182
+ const statusNames = useFeedbackStatusLabels();
183
+ // The pick, valid for the suggestion it was made under: a new suggestion (another
184
+ // status set from the table) picks itself again.
185
+ const [pick, setPick] = useState<{ under: FeedbackStatus | undefined; value: FeedbackStatus | "" }>({
186
+ under: suggestedStatus,
187
+ value: suggestedStatus ?? "",
188
+ });
189
+ const picked = pick.under === suggestedStatus ? pick.value : (suggestedStatus ?? "");
190
+
191
+ const send = (body: string) => {
192
+ const sent = picked;
193
+ return Promise.resolve(onReply(body, sent || undefined)).then(() => {
194
+ setPick((current) => (current.value === sent ? { under: suggestedStatus, value: "" } : current));
195
+ });
196
+ };
197
+
198
+ return (
199
+ <div className={cn("space-y-2", className)} data-feedback-reply-composer="">
200
+ {suggestedStatus && (
201
+ <p className="flex items-start gap-1.5 text-xs text-secondary" data-feedback-nudge={suggestedStatus}>
202
+ <BellOff className="mt-0.5 size-3.5 shrink-0 text-warning" aria-hidden />
203
+ <span>{labels.nudge}</span>
204
+ </p>
205
+ )}
206
+ <ChatComposer
207
+ onSend={send}
208
+ pending={pending}
209
+ commit={commit}
210
+ maxLength={FEEDBACK_REPLY_MAX}
211
+ showCount
212
+ labels={{ field: labels.field, placeholder: labels.placeholder, send: labels.reply }}
213
+ attachmentSlot={
214
+ <Select
215
+ size="sm"
216
+ aria-label={labels.status}
217
+ value={picked}
218
+ disabled={pending}
219
+ onChange={(event) =>
220
+ setPick({ under: suggestedStatus, value: event.target.value as FeedbackStatus | "" })
221
+ }
222
+ data-feedback-reply-status-pick=""
223
+ >
224
+ <option value="">{labels.noChange}</option>
225
+ {selectableFeedbackStatuses(status).map((value) => (
226
+ <option key={value} value={value}>
227
+ {statusNames[value]}
228
+ </option>
229
+ ))}
230
+ </Select>
231
+ }
232
+ />
233
+ </div>
234
+ );
235
+ }
@@ -18,6 +18,7 @@ export const DEFAULT_FEEDBACK_DETAIL_LABELS: FeedbackDetailLabels = {
18
18
  download: (name) => `Download ${name}`,
19
19
  downloadFailed: "The attachment could not be downloaded.",
20
20
  outcome: "Outcome",
21
+ replies: "Replies",
21
22
  resolvedAt: (date) => `Resolved: ${date}`,
22
23
  outcomeAdd: "Add outcome",
23
24
  outcomeUpdate: "Update",
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
- import type { MouseEvent } from "react";
2
+ import type { MouseEvent, ReactNode } from "react";
3
3
  import { Download, ExternalLink, FileText, RotateCcw } from "lucide-react";
4
4
  import { AuthedImage } from "../components/authed-image";
5
5
  import { CopyButton } from "../components/copy-button";
@@ -40,6 +40,10 @@ import { useFeedbackStatusUndo } from "./feedback-status-undo";
40
40
  import type { FeedbackStatusChange } from "./feedback-status-undo";
41
41
  import type { CommitScope } from "../components/write-lock";
42
42
  import { DEFAULT_FEEDBACK_DETAIL_LABELS } from "./feedback-row-detail-labels";
43
+ import { FeedbackReplyComposer, FeedbackReplyTimeline, feedbackReplyNudge } from "./feedback-replies";
44
+ import { FeedbackPublicSwitch } from "./feedback-public-switch";
45
+ import { FeedbackRepliesDrawnAboveContext } from "./feedback-replies-context";
46
+ import { DEFAULT_FEEDBACK_COMMENTS_LABELS } from "./feedback-thread-parts-labels";
43
47
 
44
48
  /**
45
49
  * One report, opened — the panel a feedback table unfolds under its row (§4.4 of
@@ -69,9 +73,12 @@ import { DEFAULT_FEEDBACK_DETAIL_LABELS } from "./feedback-row-detail-labels";
69
73
  *
70
74
  * The sections, in the contract's order: **Subject** (the category and the title, §8.1;
71
75
  * `showSubject={false}` leaves it out), **Description** (the body; the author's
72
- * Edit while OPEN / READY / IN_PROGRESS), **URL**, **Attachment**, **Outcome** (the admin's
73
- * outcome editor, Rework, Open page), **Send for rework** (only while reworking) and
74
- * **Status**. No comment thread (§2.2: kastlan's comments fold into the body, §7.7).
76
+ * Edit while OPEN / READY / IN_PROGRESS), **URL**, **Attachment**, **Replies** (0.38,
77
+ * §9.7: the timeline, the admin's reply composer with its status picker and the nudge,
78
+ * Rework, Open page — on a row without `replies`, a server before 0.38, the frozen
79
+ * **Outcome** read-only in its place), **Comments** (0.38: the app's
80
+ * `FeedbackCommentThread`, when passed), **Send for rework** (only while reworking) and
81
+ * **Status** (with the **Visible to others** switch when `onPublicChange` is passed).
75
82
  *
76
83
  * The app keeps its data and its API: the row it renders, the PATCH (`onUpdate`), the
77
84
  * upload (`onUpload`), the authenticated GET (`fetcher`) and who is looking (`canEdit`,
@@ -116,16 +123,22 @@ export interface FeedbackDetailLabels {
116
123
  /** The toast when that download failed. de-CH: "Der Anhang konnte nicht
117
124
  * heruntergeladen werden." */
118
125
  downloadFailed: string;
119
- /** Section 4's heading. de-CH: "Ergebnis" */
126
+ /** Section 4's heading on a row without `replies` (a server before 0.38), where the
127
+ * frozen outcome is shown read-only. de-CH: "Ergebnis" */
120
128
  outcome: string;
129
+ /** Section 4's heading since 0.38: the replies' timeline (§9.7). de-CH: "Antworten" */
130
+ replies: string;
121
131
  /** On section 4's heading line once the row is settled, given the formatted
122
132
  * `resolved_at` date. de-CH: "Erledigt am: {{date}}" */
123
133
  resolvedAt: (date: string) => string;
124
- /** The admin's button while there is no outcome. de-CH: "Ergebnis hinzufügen" */
134
+ /** The admin's button while there is no outcome. **Unused since 0.38** (the outcome is
135
+ * frozen, §9.7; a reply is written instead); kept for one release.
136
+ * de-CH: "Ergebnis hinzufügen" */
125
137
  outcomeAdd: string;
126
- /** The same button once there is one. de-CH: "Aktualisieren" */
138
+ /** The same button once there is one. Unused since 0.38, as `outcomeAdd`.
139
+ * de-CH: "Aktualisieren" */
127
140
  outcomeUpdate: string;
128
- /** The line above the outcome editor. de-CH: "Was wurde umgesetzt, entschieden oder
141
+ /** The line above the outcome editor. Unused since 0.38, as `outcomeAdd`. de-CH: "Was wurde umgesetzt, entschieden oder
129
142
  * warum nicht." */
130
143
  outcomePlaceholder: string;
131
144
  /** The button that opens section 5 on an answered row. de-CH: "Nacharbeit" */
@@ -314,7 +327,8 @@ export interface FeedbackRowAccess {
314
327
  /** "Edit description": the author, while the row is OPEN, READY or IN_PROGRESS — on either
315
328
  * page (keksdose `canAuthorEdit`, `feedback-page.tsx:398`, is not gated on `mine`). */
316
329
  canEditDescription: boolean;
317
- /** The outcome editor: the admin on the inbox (`canEdit`). */
330
+ /** The admin on the inbox (`canEdit`): since 0.38 the reply composer (the outcome
331
+ * itself is frozen, §9.7). */
318
332
  canEditOutcome: boolean;
319
333
  /** "Rework": the admin or the author, on an answered row
320
334
  * ({@link FEEDBACK_REWORKABLE_STATUSES}) — §3.4, anyone else gets the server's 403. */
@@ -356,9 +370,14 @@ export function feedbackRowAccess(
356
370
  export interface FeedbackDetailPatch {
357
371
  id: number;
358
372
  body?: string;
359
- /** `null` clears it — a real edit. */
373
+ /** `null` clears it — a real edit. **Frozen since 0.38** (§9.7): server-kit 0.11
374
+ * answers 422; the detail no longer sends it (a reply is written instead). */
360
375
  outcome?: string | null;
361
376
  status?: FeedbackStatus;
377
+ /** 0.38.1: the **Visible to others** flip (§9.3) — what `onPublicChange`'s
378
+ * `PATCH /feedback/{id} {"public": next}` carries, alone, so an app can send it
379
+ * through the same `onUpdate` mutation. */
380
+ public?: boolean;
362
381
  }
363
382
 
364
383
  /**
@@ -564,6 +583,27 @@ export interface FeedbackRowDetailProps {
564
583
  * beside the detail.
565
584
  */
566
585
  showSubject?: boolean;
586
+ /**
587
+ * 0.38: `POST /feedback/{id}/replies` (§9.7) — the body and the status picked
588
+ * (`undefined`: "No change"). With `canEdit` and a row that carries `replies`, the
589
+ * reply composer stands under the timeline. Return the promise: the box clears when it
590
+ * resolves. A failure is the app's to toast — the server's `detail`, else
591
+ * `feedbackToast.replyFailed` (0.38.1).
592
+ */
593
+ onReply?: (body: string, status?: FeedbackStatus) => void | Promise<unknown>;
594
+ /** 0.38: the reply is in flight. */
595
+ replying?: boolean;
596
+ /** 0.38: the report's comments — the app's `FeedbackCommentThread` — in a section of
597
+ * their own under the replies. A thread here draws the comments alone (0.38.1): the
598
+ * Replies section above has the replies, so whatever `replies` the thread is handed
599
+ * are dropped, and nothing is drawn twice. */
600
+ comments?: ReactNode;
601
+ /** 0.38: `PATCH /feedback/{id} {"public": next}` — draws the **Visible to others**
602
+ * switch (`FeedbackPublicSwitch`) in the Status section, for the admin (`canEdit`)
603
+ * and, within §9.3's window, the author. The page's own mutation fits:
604
+ * `onPublicChange={(next) => update.mutate({ id: row.id, public: next })}` —
605
+ * {@link FeedbackDetailPatch}'s `public` (0.38.1). */
606
+ onPublicChange?: (next: boolean) => void;
567
607
  labels?: LabelOverride<FeedbackDetailLabels>;
568
608
  className?: string;
569
609
  }
@@ -605,6 +645,10 @@ export function FeedbackRowDetail({
605
645
  renderDate,
606
646
  commit,
607
647
  showSubject = true,
648
+ onReply,
649
+ replying = false,
650
+ comments,
651
+ onPublicChange,
608
652
  labels: labelsProp,
609
653
  className,
610
654
  }: FeedbackRowDetailProps) {
@@ -616,7 +660,6 @@ export function FeedbackRowDetail({
616
660
  const ownStatusChange = useFeedbackStatusUndo(onUpdate);
617
661
  const changeStatus = statusChange ?? ownStatusChange;
618
662
  const [editingBody, setEditingBody] = useState(false);
619
- const [editingOutcome, setEditingOutcome] = useState(false);
620
663
  const [reworking, setReworking] = useState(false);
621
664
 
622
665
  const access = feedbackRowAccess(row, { canEdit, viewerId });
@@ -640,17 +683,10 @@ export function FeedbackRowDetail({
640
683
  onUpdate({ id: row.id, body: replaceDescription(row.body, value) }, { onSuccess: () => setEditingBody(false) });
641
684
  };
642
685
 
643
- const saveOutcome = (value: string) => {
644
- // Blank saves `null`: no outcome, rather than an empty one.
645
- const next = value.trim() ? value.trim() : null;
646
- if (next === row.outcome) {
647
- setEditingOutcome(false);
648
- return;
649
- }
650
- onUpdate({ id: row.id, outcome: next }, { onSuccess: () => setEditingOutcome(false) });
651
- };
652
-
653
- const showOutcomeEditor = access.canEditOutcome && editingOutcome;
686
+ // 0.38 (§9.7): the replies replace the outcome; a row without them is a server before
687
+ // 0.38, whose frozen outcome is shown as it is, read-only.
688
+ const replies = row.replies;
689
+ const commentLabels = useKitLabels("feedbackComments", DEFAULT_FEEDBACK_COMMENTS_LABELS);
654
690
  const showRework = access.canRework && reworking;
655
691
 
656
692
  return (
@@ -741,7 +777,7 @@ export function FeedbackRowDetail({
741
777
  )}
742
778
 
743
779
  <FeedbackDetailSection
744
- title={labels.outcome}
780
+ title={replies ? labels.replies : labels.outcome}
745
781
  // When the row entered DONE / WONT_DO (keksdose feedback #96).
746
782
  action={
747
783
  row.resolved_at ? (
@@ -749,60 +785,54 @@ export function FeedbackRowDetail({
749
785
  ) : undefined
750
786
  }
751
787
  >
752
- {showOutcomeEditor ? (
753
- <FeedbackNoteEditor
754
- key={row.id}
755
- initial={row.outcome ?? ""}
756
- pending={pending}
757
- commit={commit}
758
- onSave={saveOutcome}
759
- onCancel={() => setEditingOutcome(false)}
760
- saveLabel={labels.save}
761
- cancelLabel={labels.cancel}
762
- placeholder={labels.outcomePlaceholder}
763
- />
764
- ) : (
765
- <div className="space-y-2">
788
+ <div className="space-y-3">
789
+ {replies ? (
790
+ <FeedbackReplyTimeline replies={replies} />
791
+ ) : (
766
792
  <FeedbackProse>{row.outcome || undefined}</FeedbackProse>
767
- {(access.canEditOutcome || (access.canRework && !reworking) || pageHref) && (
768
- <div className="flex flex-wrap gap-2">
769
- {access.canEditOutcome && (
770
- <Button
771
- type="button"
772
- variant="secondary"
773
- onClick={(event) => {
774
- event.stopPropagation();
775
- setEditingOutcome(true);
776
- }}
777
- >
778
- {row.outcome ? labels.outcomeUpdate : labels.outcomeAdd}
779
- </Button>
780
- )}
781
- {access.canRework && !reworking && (
782
- <Button
783
- type="button"
784
- variant="secondary"
785
- onClick={(event) => {
786
- event.stopPropagation();
787
- setReworking(true);
788
- }}
789
- >
790
- <RotateCcw className="size-3.5" aria-hidden /> {labels.rework}
791
- </Button>
792
- )}
793
- {pageHref && (
794
- // A real href (keksdose dev #451): middle-click and ⌘/Ctrl-click are
795
- // the browser's, and only a link has them.
796
- <Button href={pageHref} variant="secondary" onClick={(event) => event.stopPropagation()}>
797
- <ExternalLink className="size-3.5" aria-hidden /> {labels.openPage}
798
- </Button>
799
- )}
800
- </div>
801
- )}
802
- </div>
803
- )}
793
+ )}
794
+ {replies && access.canEditOutcome && onReply && (
795
+ <FeedbackReplyComposer
796
+ key={row.id}
797
+ status={row.status}
798
+ onReply={onReply}
799
+ pending={replying}
800
+ suggestedStatus={feedbackReplyNudge(row)}
801
+ commit={commit}
802
+ />
803
+ )}
804
+ {((access.canRework && !reworking) || pageHref) && (
805
+ <div className="flex flex-wrap gap-2">
806
+ {access.canRework && !reworking && (
807
+ <Button
808
+ type="button"
809
+ variant="secondary"
810
+ onClick={(event) => {
811
+ event.stopPropagation();
812
+ setReworking(true);
813
+ }}
814
+ >
815
+ <RotateCcw className="size-3.5" aria-hidden /> {labels.rework}
816
+ </Button>
817
+ )}
818
+ {pageHref && (
819
+ // A real href (keksdose dev #451): middle-click and ⌘/Ctrl-click are
820
+ // the browser's, and only a link has them.
821
+ <Button href={pageHref} variant="secondary" onClick={(event) => event.stopPropagation()}>
822
+ <ExternalLink className="size-3.5" aria-hidden /> {labels.openPage}
823
+ </Button>
824
+ )}
825
+ </div>
826
+ )}
827
+ </div>
804
828
  </FeedbackDetailSection>
805
829
 
830
+ {comments != null && (
831
+ <FeedbackDetailSection title={commentLabels.comments}>
832
+ <FeedbackRepliesDrawnAboveContext.Provider value>{comments}</FeedbackRepliesDrawnAboveContext.Provider>
833
+ </FeedbackDetailSection>
834
+ )}
835
+
806
836
  {showRework && (
807
837
  <FeedbackReworkSection
808
838
  row={row}
@@ -831,6 +861,17 @@ export function FeedbackRowDetail({
831
861
  onPick={(status) => changeStatus(row, status)}
832
862
  variant="pill"
833
863
  />
864
+ {onPublicChange && (
865
+ <FeedbackPublicSwitch
866
+ row={row}
867
+ isAdmin={canEdit}
868
+ viewerId={viewerId}
869
+ onChange={onPublicChange}
870
+ pending={pending}
871
+ commit={commit}
872
+ className="mt-3"
873
+ />
874
+ )}
834
875
  </FeedbackDetailSection>
835
876
  </FeedbackDetail>
836
877
  );
@@ -2,6 +2,8 @@ import { toast } from "../components/toast";
2
2
  import { captureAppScreenshot } from "./feedback-capture";
3
3
  import { FeedbackContextBox, feedbackContext, type FeedbackSubmitter } from "./feedback-context";
4
4
  import { FeedbackDialog, type FeedbackMultipleSubmission } from "./feedback-dialog";
5
+ import { DEFAULT_FEEDBACK_DIALOG_LABELS } from "./feedback-dialog-labels";
6
+ import { useKitLabels } from "../i18n/kit-labels";
5
7
  import { useFeedbackCategoryLabels, useFeedbackToastLabels } from "./feedback-labels";
6
8
  import {
7
9
  DEFAULT_MAX_ATTACHMENTS,
@@ -45,6 +47,8 @@ export interface FeedbackSubmitDialogProps {
45
47
  version?: string;
46
48
  /** `UseFeedbackSubmitOptions.capture`, as the app passed it. */
47
49
  capture?: false | (() => Promise<File | null>);
50
+ /** `UseFeedbackSubmitOptions.publicNotice` (0.38): the line under the body. */
51
+ publicNotice?: boolean;
48
52
  }
49
53
 
50
54
  /** `FeedbackDialog` as `useFeedbackSubmit` settles it — see the hook for what it settles. */
@@ -61,8 +65,10 @@ export function FeedbackSubmitDialog({
61
65
  environment,
62
66
  version,
63
67
  capture,
68
+ publicNotice = false,
64
69
  }: FeedbackSubmitDialogProps) {
65
70
  const toastText = useFeedbackToastLabels();
71
+ const dialogText = useKitLabels("feedbackDialog", DEFAULT_FEEDBACK_DIALOG_LABELS);
66
72
  const categoryLabels = useFeedbackCategoryLabels();
67
73
 
68
74
  const onCaptureScreenshot =
@@ -101,6 +107,9 @@ export function FeedbackSubmitDialog({
101
107
  )
102
108
  }
103
109
  onCaptureScreenshot={onCaptureScreenshot}
110
+ publicNotice={
111
+ publicNotice ? (dialogText.publicNotice ?? DEFAULT_FEEDBACK_DIALOG_LABELS.publicNotice) : undefined
112
+ }
104
113
  // The context is read off the very values the box below shows (§3.2).
105
114
  onSubmit={(data) => onSubmit(data, feedbackContext({ user, attachUrl, url, environment, version }))}
106
115
  contextSlot={<FeedbackContextBox user={user} url={url} attachUrl={attachUrl} onAttachUrlChange={onAttachUrlChange} />}