@eifi1/ui-kit 0.36.0 → 0.38.0

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 (147) hide show
  1. package/README.md +11 -4
  2. package/dist/components/field-parts.d.ts +3 -3
  3. package/dist/components/field-parts.js.map +1 -1
  4. package/dist/components/help-popover.d.ts +1 -1
  5. package/dist/components/help-popover.js.map +1 -1
  6. package/dist/components/numeric-field.d.ts +1 -1
  7. package/dist/components/numeric-field.js.map +1 -1
  8. package/dist/eslint/index.js +1 -1
  9. package/dist/eslint/role-utilities.d.ts +34 -2
  10. package/dist/eslint/role-utilities.js +30 -9
  11. package/dist/eslint/role-utilities.js.map +1 -1
  12. package/dist/feedback/feedback-comment-thread.d.ts +80 -0
  13. package/dist/feedback/feedback-comment-thread.js +162 -0
  14. package/dist/feedback/feedback-comment-thread.js.map +1 -0
  15. package/dist/feedback/feedback-dialog-labels.js +2 -1
  16. package/dist/feedback/feedback-dialog-labels.js.map +1 -1
  17. package/dist/feedback/feedback-dialog.d.ts +13 -0
  18. package/dist/feedback/feedback-dialog.js +12 -4
  19. package/dist/feedback/feedback-dialog.js.map +1 -1
  20. package/dist/feedback/feedback-menu-labels.js +2 -1
  21. package/dist/feedback/feedback-menu-labels.js.map +1 -1
  22. package/dist/feedback/feedback-menu.d.ts +17 -3
  23. package/dist/feedback/feedback-menu.js +11 -1
  24. package/dist/feedback/feedback-menu.js.map +1 -1
  25. package/dist/feedback/feedback-public-labels.d.ts +50 -0
  26. package/dist/feedback/feedback-public-labels.js +26 -0
  27. package/dist/feedback/feedback-public-labels.js.map +1 -0
  28. package/dist/feedback/feedback-public-list.d.ts +118 -0
  29. package/dist/feedback/feedback-public-list.js +248 -0
  30. package/dist/feedback/feedback-public-list.js.map +1 -0
  31. package/dist/feedback/feedback-public-switch.d.ts +51 -0
  32. package/dist/feedback/feedback-public-switch.js +50 -0
  33. package/dist/feedback/feedback-public-switch.js.map +1 -0
  34. package/dist/feedback/feedback-public-types.d.ts +75 -0
  35. package/dist/feedback/feedback-public-types.js +2 -0
  36. package/dist/feedback/feedback-public-types.js.map +1 -0
  37. package/dist/feedback/feedback-record.d.ts +32 -1
  38. package/dist/feedback/feedback-record.js +10 -0
  39. package/dist/feedback/feedback-record.js.map +1 -1
  40. package/dist/feedback/feedback-replies.d.ts +97 -0
  41. package/dist/feedback/feedback-replies.js +114 -0
  42. package/dist/feedback/feedback-replies.js.map +1 -0
  43. package/dist/feedback/feedback-row-detail-labels.js +1 -0
  44. package/dist/feedback/feedback-row-detail-labels.js.map +1 -1
  45. package/dist/feedback/feedback-row-detail.d.ts +38 -10
  46. package/dist/feedback/feedback-row-detail.js +50 -50
  47. package/dist/feedback/feedback-row-detail.js.map +1 -1
  48. package/dist/feedback/feedback-submit-dialog.d.ts +3 -1
  49. package/dist/feedback/feedback-submit-dialog.js +6 -1
  50. package/dist/feedback/feedback-submit-dialog.js.map +1 -1
  51. package/dist/feedback/feedback-submit.d.ts +8 -0
  52. package/dist/feedback/feedback-submit.js +3 -2
  53. package/dist/feedback/feedback-submit.js.map +1 -1
  54. package/dist/feedback/feedback-table.d.ts +15 -5
  55. package/dist/feedback/feedback-table.js +46 -5
  56. package/dist/feedback/feedback-table.js.map +1 -1
  57. package/dist/feedback/feedback-thread-parts-labels.d.ts +6 -0
  58. package/dist/feedback/feedback-thread-parts-labels.js +36 -0
  59. package/dist/feedback/feedback-thread-parts-labels.js.map +1 -0
  60. package/dist/feedback/feedback-thread.d.ts +8 -0
  61. package/dist/feedback/feedback-thread.js +7 -2
  62. package/dist/feedback/feedback-thread.js.map +1 -1
  63. package/dist/feedback/feedback-vote-button.d.ts +48 -0
  64. package/dist/feedback/feedback-vote-button.js +72 -0
  65. package/dist/feedback/feedback-vote-button.js.map +1 -0
  66. package/dist/feedback.d.ts +6 -0
  67. package/dist/feedback.js +6 -0
  68. package/dist/feedback.js.map +1 -1
  69. package/dist/i18n/defaults.js +12 -0
  70. package/dist/i18n/defaults.js.map +1 -1
  71. package/dist/i18n/german.js +64 -2
  72. package/dist/i18n/german.js.map +1 -1
  73. package/dist/i18n/kit-labels.d.ts +20 -3
  74. package/dist/i18n/kit-labels.js.map +1 -1
  75. package/dist/i18n/locales/en.js +80 -3
  76. package/dist/i18n/locales/en.js.map +3 -3
  77. package/dist/i18n/locales/es.js +64 -2
  78. package/dist/i18n/locales/es.js.map +1 -1
  79. package/dist/i18n/locales/fr.js +64 -2
  80. package/dist/i18n/locales/fr.js.map +1 -1
  81. package/dist/i18n/locales/hu.js +64 -2
  82. package/dist/i18n/locales/hu.js.map +1 -1
  83. package/dist/i18n/locales/it.js +64 -2
  84. package/dist/i18n/locales/it.js.map +1 -1
  85. package/dist/i18n/locales/zh.js +64 -2
  86. package/dist/i18n/locales/zh.js.map +1 -1
  87. package/dist/i18n/review.js +4 -0
  88. package/dist/i18n/review.js.map +1 -1
  89. package/dist/i18n/shared-labels.js +1 -1
  90. package/dist/i18n/shared-labels.js.map +1 -1
  91. package/dist/index.d.ts +2 -0
  92. package/dist/index.js +6 -0
  93. package/dist/index.js.map +1 -1
  94. package/dist/lib/plain-text.d.ts +1 -1
  95. package/dist/lib/plain-text.js.map +1 -1
  96. package/dist/notifications/feedback-notification-types-labels.d.ts +15 -0
  97. package/dist/notifications/feedback-notification-types-labels.js +11 -0
  98. package/dist/notifications/feedback-notification-types-labels.js.map +1 -0
  99. package/dist/notifications/feedback-notification-types.d.ts +28 -0
  100. package/dist/notifications/feedback-notification-types.js +21 -0
  101. package/dist/notifications/feedback-notification-types.js.map +1 -0
  102. package/dist/rhf/fields.d.ts +1 -1
  103. package/dist/rhf/fields.js.map +1 -1
  104. package/dist/settings/device-reset-setting.d.ts +4 -4
  105. package/dist/settings/device-reset-setting.js.map +1 -1
  106. package/package.json +1 -1
  107. package/src/components/field-parts.tsx +4 -4
  108. package/src/components/help-popover.tsx +1 -1
  109. package/src/components/numeric-field.ts +1 -1
  110. package/src/eslint/role-utilities.ts +67 -11
  111. package/src/feedback/feedback-comment-thread.tsx +255 -0
  112. package/src/feedback/feedback-dialog-labels.ts +1 -0
  113. package/src/feedback/feedback-dialog.tsx +30 -2
  114. package/src/feedback/feedback-menu-labels.ts +1 -0
  115. package/src/feedback/feedback-menu.tsx +29 -3
  116. package/src/feedback/feedback-public-labels.ts +76 -0
  117. package/src/feedback/feedback-public-list.tsx +395 -0
  118. package/src/feedback/feedback-public-switch.tsx +102 -0
  119. package/src/feedback/feedback-public-types.ts +82 -0
  120. package/src/feedback/feedback-record.ts +39 -1
  121. package/src/feedback/feedback-replies.tsx +235 -0
  122. package/src/feedback/feedback-row-detail-labels.ts +1 -0
  123. package/src/feedback/feedback-row-detail.tsx +103 -74
  124. package/src/feedback/feedback-submit-dialog.tsx +9 -0
  125. package/src/feedback/feedback-submit.tsx +10 -1
  126. package/src/feedback/feedback-table.tsx +73 -6
  127. package/src/feedback/feedback-thread-parts-labels.ts +41 -0
  128. package/src/feedback/feedback-thread.tsx +18 -1
  129. package/src/feedback/feedback-vote-button.tsx +122 -0
  130. package/src/feedback.ts +10 -0
  131. package/src/i18n/defaults.ts +12 -0
  132. package/src/i18n/german.ts +62 -0
  133. package/src/i18n/kit-labels.tsx +20 -3
  134. package/src/i18n/locales/en.ts +7 -0
  135. package/src/i18n/locales/es.ts +62 -0
  136. package/src/i18n/locales/fr.ts +62 -0
  137. package/src/i18n/locales/hu.ts +62 -0
  138. package/src/i18n/locales/it.ts +62 -0
  139. package/src/i18n/locales/zh.ts +62 -0
  140. package/src/i18n/review.ts +4 -0
  141. package/src/i18n/shared-labels.ts +1 -1
  142. package/src/index.ts +9 -0
  143. package/src/lib/plain-text.ts +1 -1
  144. package/src/notifications/feedback-notification-types-labels.ts +24 -0
  145. package/src/notifications/feedback-notification-types.ts +46 -0
  146. package/src/rhf/fields.tsx +1 -1
  147. package/src/settings/device-reset-setting.tsx +4 -4
@@ -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;
121
+ /** Visible to the app's other users (§9.3); never on a CRASH. */
122
+ public?: boolean;
123
+ /** Made public by the 0.38 migration: the author may switch it off at any status. */
124
+ filed_private?: boolean;
125
+ /** People who want this (the author's own vote included, §9.6). */
126
+ votes?: number;
127
+ /** The viewer's vote. */
128
+ voted?: boolean;
129
+ /** Comments (an admin's count includes hidden ones). */
130
+ comment_count?: number;
131
+ /** The admin's answers, oldest first (§9.7). Absent: a server before 0.38 — read
132
+ * `outcome` instead. */
133
+ replies?: FeedbackReply[];
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,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,9 @@ 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 { DEFAULT_FEEDBACK_COMMENTS_LABELS } from "./feedback-thread-parts-labels";
43
46
 
44
47
  /**
45
48
  * One report, opened — the panel a feedback table unfolds under its row (§4.4 of
@@ -69,9 +72,12 @@ import { DEFAULT_FEEDBACK_DETAIL_LABELS } from "./feedback-row-detail-labels";
69
72
  *
70
73
  * The sections, in the contract's order: **Subject** (the category and the title, §8.1;
71
74
  * `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).
75
+ * Edit while OPEN / READY / IN_PROGRESS), **URL**, **Attachment**, **Replies** (0.38,
76
+ * §9.7: the timeline, the admin's reply composer with its status picker and the nudge,
77
+ * Rework, Open page — on a row without `replies`, a server before 0.38, the frozen
78
+ * **Outcome** read-only in its place), **Comments** (0.38: the app's
79
+ * `FeedbackCommentThread`, when passed), **Send for rework** (only while reworking) and
80
+ * **Status** (with the **Visible to others** switch when `onPublicChange` is passed).
75
81
  *
76
82
  * The app keeps its data and its API: the row it renders, the PATCH (`onUpdate`), the
77
83
  * upload (`onUpload`), the authenticated GET (`fetcher`) and who is looking (`canEdit`,
@@ -116,16 +122,22 @@ export interface FeedbackDetailLabels {
116
122
  /** The toast when that download failed. de-CH: "Der Anhang konnte nicht
117
123
  * heruntergeladen werden." */
118
124
  downloadFailed: string;
119
- /** Section 4's heading. de-CH: "Ergebnis" */
125
+ /** Section 4's heading on a row without `replies` (a server before 0.38), where the
126
+ * frozen outcome is shown read-only. de-CH: "Ergebnis" */
120
127
  outcome: string;
128
+ /** Section 4's heading since 0.38: the replies' timeline (§9.7). de-CH: "Antworten" */
129
+ replies: string;
121
130
  /** On section 4's heading line once the row is settled, given the formatted
122
131
  * `resolved_at` date. de-CH: "Erledigt am: {{date}}" */
123
132
  resolvedAt: (date: string) => string;
124
- /** The admin's button while there is no outcome. de-CH: "Ergebnis hinzufügen" */
133
+ /** The admin's button while there is no outcome. **Unused since 0.38** (the outcome is
134
+ * frozen, §9.7; a reply is written instead); kept for one release.
135
+ * de-CH: "Ergebnis hinzufügen" */
125
136
  outcomeAdd: string;
126
- /** The same button once there is one. de-CH: "Aktualisieren" */
137
+ /** The same button once there is one. Unused since 0.38, as `outcomeAdd`.
138
+ * de-CH: "Aktualisieren" */
127
139
  outcomeUpdate: string;
128
- /** The line above the outcome editor. de-CH: "Was wurde umgesetzt, entschieden oder
140
+ /** The line above the outcome editor. Unused since 0.38, as `outcomeAdd`. de-CH: "Was wurde umgesetzt, entschieden oder
129
141
  * warum nicht." */
130
142
  outcomePlaceholder: string;
131
143
  /** The button that opens section 5 on an answered row. de-CH: "Nacharbeit" */
@@ -314,7 +326,8 @@ export interface FeedbackRowAccess {
314
326
  /** "Edit description": the author, while the row is OPEN, READY or IN_PROGRESS — on either
315
327
  * page (keksdose `canAuthorEdit`, `feedback-page.tsx:398`, is not gated on `mine`). */
316
328
  canEditDescription: boolean;
317
- /** The outcome editor: the admin on the inbox (`canEdit`). */
329
+ /** The admin on the inbox (`canEdit`): since 0.38 the reply composer (the outcome
330
+ * itself is frozen, §9.7). */
318
331
  canEditOutcome: boolean;
319
332
  /** "Rework": the admin or the author, on an answered row
320
333
  * ({@link FEEDBACK_REWORKABLE_STATUSES}) — §3.4, anyone else gets the server's 403. */
@@ -356,7 +369,8 @@ export function feedbackRowAccess(
356
369
  export interface FeedbackDetailPatch {
357
370
  id: number;
358
371
  body?: string;
359
- /** `null` clears it — a real edit. */
372
+ /** `null` clears it — a real edit. **Frozen since 0.38** (§9.7): server-kit 0.11
373
+ * answers 422; the detail no longer sends it (a reply is written instead). */
360
374
  outcome?: string | null;
361
375
  status?: FeedbackStatus;
362
376
  }
@@ -564,6 +578,22 @@ export interface FeedbackRowDetailProps {
564
578
  * beside the detail.
565
579
  */
566
580
  showSubject?: boolean;
581
+ /**
582
+ * 0.38: `POST /feedback/{id}/replies` (§9.7) — the body and the status picked
583
+ * (`undefined`: "No change"). With `canEdit` and a row that carries `replies`, the
584
+ * reply composer stands under the timeline. Return the promise: the box clears when it
585
+ * resolves.
586
+ */
587
+ onReply?: (body: string, status?: FeedbackStatus) => void | Promise<unknown>;
588
+ /** 0.38: the reply is in flight. */
589
+ replying?: boolean;
590
+ /** 0.38: the report's comments — the app's `FeedbackCommentThread` — in a section of
591
+ * their own under the replies. */
592
+ comments?: ReactNode;
593
+ /** 0.38: `PATCH /feedback/{id} {"public": next}` — draws the **Visible to others**
594
+ * switch (`FeedbackPublicSwitch`) in the Status section, for the admin (`canEdit`)
595
+ * and, within §9.3's window, the author. */
596
+ onPublicChange?: (next: boolean) => void;
567
597
  labels?: LabelOverride<FeedbackDetailLabels>;
568
598
  className?: string;
569
599
  }
@@ -605,6 +635,10 @@ export function FeedbackRowDetail({
605
635
  renderDate,
606
636
  commit,
607
637
  showSubject = true,
638
+ onReply,
639
+ replying = false,
640
+ comments,
641
+ onPublicChange,
608
642
  labels: labelsProp,
609
643
  className,
610
644
  }: FeedbackRowDetailProps) {
@@ -616,7 +650,6 @@ export function FeedbackRowDetail({
616
650
  const ownStatusChange = useFeedbackStatusUndo(onUpdate);
617
651
  const changeStatus = statusChange ?? ownStatusChange;
618
652
  const [editingBody, setEditingBody] = useState(false);
619
- const [editingOutcome, setEditingOutcome] = useState(false);
620
653
  const [reworking, setReworking] = useState(false);
621
654
 
622
655
  const access = feedbackRowAccess(row, { canEdit, viewerId });
@@ -640,17 +673,10 @@ export function FeedbackRowDetail({
640
673
  onUpdate({ id: row.id, body: replaceDescription(row.body, value) }, { onSuccess: () => setEditingBody(false) });
641
674
  };
642
675
 
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;
676
+ // 0.38 (§9.7): the replies replace the outcome; a row without them is a server before
677
+ // 0.38, whose frozen outcome is shown as it is, read-only.
678
+ const replies = row.replies;
679
+ const commentLabels = useKitLabels("feedbackComments", DEFAULT_FEEDBACK_COMMENTS_LABELS);
654
680
  const showRework = access.canRework && reworking;
655
681
 
656
682
  return (
@@ -741,7 +767,7 @@ export function FeedbackRowDetail({
741
767
  )}
742
768
 
743
769
  <FeedbackDetailSection
744
- title={labels.outcome}
770
+ title={replies ? labels.replies : labels.outcome}
745
771
  // When the row entered DONE / WONT_DO (keksdose feedback #96).
746
772
  action={
747
773
  row.resolved_at ? (
@@ -749,60 +775,52 @@ export function FeedbackRowDetail({
749
775
  ) : undefined
750
776
  }
751
777
  >
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">
778
+ <div className="space-y-3">
779
+ {replies ? (
780
+ <FeedbackReplyTimeline replies={replies} />
781
+ ) : (
766
782
  <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
- )}
783
+ )}
784
+ {replies && access.canEditOutcome && onReply && (
785
+ <FeedbackReplyComposer
786
+ key={row.id}
787
+ status={row.status}
788
+ onReply={onReply}
789
+ pending={replying}
790
+ suggestedStatus={feedbackReplyNudge(row)}
791
+ commit={commit}
792
+ />
793
+ )}
794
+ {((access.canRework && !reworking) || pageHref) && (
795
+ <div className="flex flex-wrap gap-2">
796
+ {access.canRework && !reworking && (
797
+ <Button
798
+ type="button"
799
+ variant="secondary"
800
+ onClick={(event) => {
801
+ event.stopPropagation();
802
+ setReworking(true);
803
+ }}
804
+ >
805
+ <RotateCcw className="size-3.5" aria-hidden /> {labels.rework}
806
+ </Button>
807
+ )}
808
+ {pageHref && (
809
+ // A real href (keksdose dev #451): middle-click and ⌘/Ctrl-click are
810
+ // the browser's, and only a link has them.
811
+ <Button href={pageHref} variant="secondary" onClick={(event) => event.stopPropagation()}>
812
+ <ExternalLink className="size-3.5" aria-hidden /> {labels.openPage}
813
+ </Button>
814
+ )}
815
+ </div>
816
+ )}
817
+ </div>
804
818
  </FeedbackDetailSection>
805
819
 
820
+ {comments != null && (
821
+ <FeedbackDetailSection title={commentLabels.comments}>{comments}</FeedbackDetailSection>
822
+ )}
823
+
806
824
  {showRework && (
807
825
  <FeedbackReworkSection
808
826
  row={row}
@@ -831,6 +849,17 @@ export function FeedbackRowDetail({
831
849
  onPick={(status) => changeStatus(row, status)}
832
850
  variant="pill"
833
851
  />
852
+ {onPublicChange && (
853
+ <FeedbackPublicSwitch
854
+ row={row}
855
+ isAdmin={canEdit}
856
+ viewerId={viewerId}
857
+ onChange={onPublicChange}
858
+ pending={pending}
859
+ commit={commit}
860
+ className="mt-3"
861
+ />
862
+ )}
834
863
  </FeedbackDetailSection>
835
864
  </FeedbackDetail>
836
865
  );
@@ -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} />}
@@ -74,6 +74,14 @@ export interface UseFeedbackSubmitOptions<R = unknown> {
74
74
  * `modern-screenshot`. A rejection becomes the `captureFailed` toast.
75
75
  */
76
76
  capture?: false | (() => Promise<File | null>);
77
+ /**
78
+ * The app's reports are public (0.38, docs/feedback-harmonization.md §9.2 decision 6):
79
+ * `true` shows **"Other users will see the title and text (never your screenshots or
80
+ * files)"** (`feedbackDialog.publicNotice`) under the body. keksdose and Kurvenschmiede
81
+ * pass it — for a viewer who passes the app's `public_viewer` — and kastlan, whose
82
+ * public parts are off, does not. Default `false`.
83
+ */
84
+ publicNotice?: boolean;
77
85
  }
78
86
 
79
87
  export interface FeedbackSubmit {
@@ -134,7 +142,7 @@ export interface FeedbackSubmit {
134
142
  * new draft is somebody's next report.
135
143
  */
136
144
  export function useFeedbackSubmit<R = unknown>(options: UseFeedbackSubmitOptions<R>): FeedbackSubmit {
137
- const { upload, create, user, environment, version, onSubmitted, errorMessage, capture } = options;
145
+ const { upload, create, user, environment, version, onSubmitted, errorMessage, capture, publicNotice } = options;
138
146
  const toastText = useFeedbackToastLabels();
139
147
  const [isOpen, setIsOpen] = useState(false);
140
148
  const [category, setCategory] = useState<FeedbackCategory>("OTHER");
@@ -206,6 +214,7 @@ export function useFeedbackSubmit<R = unknown>(options: UseFeedbackSubmitOptions
206
214
  environment={environment}
207
215
  version={version}
208
216
  capture={capture}
217
+ publicNotice={publicNotice}
209
218
  />
210
219
  </Suspense>
211
220
  ) : null;