@qoretechnologies/reqraft 0.10.7 → 0.10.9-pr.74.g452d4d3

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 (55) hide show
  1. package/dist/components/supportTicket/AttachmentChips.d.ts +7 -0
  2. package/dist/components/supportTicket/AttachmentChips.d.ts.map +1 -0
  3. package/dist/components/supportTicket/AttachmentChips.js +26 -0
  4. package/dist/components/supportTicket/AttachmentChips.js.map +1 -0
  5. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts +25 -0
  6. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts.map +1 -0
  7. package/dist/components/supportTicket/InterfaceReferenceTags.js +28 -0
  8. package/dist/components/supportTicket/InterfaceReferenceTags.js.map +1 -0
  9. package/dist/components/supportTicket/TicketMetaTags.d.ts +24 -0
  10. package/dist/components/supportTicket/TicketMetaTags.d.ts.map +1 -0
  11. package/dist/components/supportTicket/TicketMetaTags.js +21 -0
  12. package/dist/components/supportTicket/TicketMetaTags.js.map +1 -0
  13. package/dist/components/supportTicket/TicketReplyBox.d.ts +59 -0
  14. package/dist/components/supportTicket/TicketReplyBox.d.ts.map +1 -0
  15. package/dist/components/supportTicket/TicketReplyBox.js +169 -0
  16. package/dist/components/supportTicket/TicketReplyBox.js.map +1 -0
  17. package/dist/components/supportTicket/fileToAttachment.d.ts +9 -0
  18. package/dist/components/supportTicket/fileToAttachment.d.ts.map +1 -0
  19. package/dist/components/supportTicket/fileToAttachment.js +28 -0
  20. package/dist/components/supportTicket/fileToAttachment.js.map +1 -0
  21. package/dist/components/supportTicket/index.d.ts +7 -0
  22. package/dist/components/supportTicket/index.d.ts.map +1 -0
  23. package/dist/components/supportTicket/index.js +23 -0
  24. package/dist/components/supportTicket/index.js.map +1 -0
  25. package/dist/components/supportTicket/meta.d.ts +55 -0
  26. package/dist/components/supportTicket/meta.d.ts.map +1 -0
  27. package/dist/components/supportTicket/meta.js +77 -0
  28. package/dist/components/supportTicket/meta.js.map +1 -0
  29. package/dist/components/ticketThread/TicketThread.d.ts +36 -0
  30. package/dist/components/ticketThread/TicketThread.d.ts.map +1 -0
  31. package/dist/components/ticketThread/TicketThread.js +137 -0
  32. package/dist/components/ticketThread/TicketThread.js.map +1 -0
  33. package/dist/components/ticketThread/index.d.ts +3 -0
  34. package/dist/components/ticketThread/index.d.ts.map +1 -0
  35. package/dist/components/ticketThread/index.js +6 -0
  36. package/dist/components/ticketThread/index.js.map +1 -0
  37. package/dist/index.d.ts +2 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +2 -0
  40. package/dist/index.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/components/supportTicket/AttachmentChips.tsx +50 -0
  43. package/src/components/supportTicket/InterfaceReferenceTags.stories.tsx +102 -0
  44. package/src/components/supportTicket/InterfaceReferenceTags.tsx +62 -0
  45. package/src/components/supportTicket/TicketMetaTags.stories.tsx +71 -0
  46. package/src/components/supportTicket/TicketMetaTags.tsx +58 -0
  47. package/src/components/supportTicket/TicketReplyBox.stories.tsx +111 -0
  48. package/src/components/supportTicket/TicketReplyBox.tsx +251 -0
  49. package/src/components/supportTicket/fileToAttachment.ts +24 -0
  50. package/src/components/supportTicket/index.ts +6 -0
  51. package/src/components/supportTicket/meta.ts +135 -0
  52. package/src/components/ticketThread/TicketThread.stories.tsx +192 -0
  53. package/src/components/ticketThread/TicketThread.tsx +331 -0
  54. package/src/components/ticketThread/index.ts +6 -0
  55. package/src/index.tsx +2 -0
@@ -0,0 +1,192 @@
1
+ import { StoryObj } from '@storybook/react-vite';
2
+ import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
3
+
4
+ import { StoryMeta } from '../../types';
5
+ import { ITicketThreadMessage, TicketThread } from './TicketThread';
6
+
7
+ const NOW = Date.parse('2026-06-30T12:00:00Z');
8
+ const ago = (mins: number) => new Date(NOW - mins * 60_000).toISOString();
9
+
10
+ const THREAD: ITicketThreadMessage[] = [
11
+ {
12
+ message_id: 'm1',
13
+ author_type: 'customer',
14
+ author_id: 'acme',
15
+ body: 'Our nightly **deploy** started failing after the 3.2 upgrade.',
16
+ created: ago(180),
17
+ attachments: [
18
+ {
19
+ attachment_id: 'att-1',
20
+ filename: 'deploy-trace.log',
21
+ content_type: 'text/plain',
22
+ size_bytes: 12_800,
23
+ },
24
+ ],
25
+ // no `resolveInterfaceIcon` is passed in these base stories, so this chip
26
+ // exercises the built-in default per-kind icon.
27
+ referenced_interfaces: [{ interface_kind: 'workflow', interface_name: 'nightly-deploy:1.2' }],
28
+ },
29
+ {
30
+ message_id: 'm2',
31
+ author_type: 'staff',
32
+ author_id: 'nick',
33
+ body: 'Thanks — that trace points at the datasource pool. Can you confirm the pool size?',
34
+ created: ago(120),
35
+ },
36
+ {
37
+ message_id: 'm3',
38
+ author_type: 'staff',
39
+ author_id: 'nick',
40
+ body: 'Reproduced locally; the pool default dropped to 1 in 3.2.',
41
+ internal: true,
42
+ created: ago(90),
43
+ },
44
+ {
45
+ message_id: 'm4',
46
+ author_type: 'system',
47
+ author_id: 'system',
48
+ body: 'Priority raised to **high**.',
49
+ created: ago(60),
50
+ },
51
+ ];
52
+
53
+ // A thread whose messages reference interfaces on the customer's instance —
54
+ // several at once, including two of the same kind (workflow), plus a job on a
55
+ // follow-up message. Referenced interfaces are `{kind, name}` snapshots.
56
+ const REFERENCED_THREAD: ITicketThreadMessage[] = [
57
+ {
58
+ message_id: 'r1',
59
+ author_type: 'customer',
60
+ author_id: 'acme',
61
+ body: 'These two workflows and the notification service all fail together.',
62
+ created: ago(45),
63
+ referenced_interfaces: [
64
+ { interface_kind: 'workflow', interface_name: 'order-processing:1.0', reference_id: 'ref-1' },
65
+ { interface_kind: 'workflow', interface_name: 'invoice-sync:2.3', reference_id: 'ref-2' },
66
+ { interface_kind: 'service', interface_name: 'notification-svc', reference_id: 'ref-3' },
67
+ ],
68
+ },
69
+ {
70
+ message_id: 'r2',
71
+ author_type: 'customer',
72
+ author_id: 'acme',
73
+ body: 'And this job kicks off the follow-up.',
74
+ created: ago(20),
75
+ referenced_interfaces: [
76
+ { interface_kind: 'job', interface_name: 'nightly-cleanup', reference_id: 'ref-4' },
77
+ ],
78
+ },
79
+ ];
80
+
81
+ const meta = {
82
+ component: TicketThread,
83
+ title: 'Components/TicketThread',
84
+ args: {
85
+ messages: THREAD,
86
+ onDownloadAttachment: fn(),
87
+ },
88
+ } as StoryMeta<typeof TicketThread>;
89
+
90
+ export default meta;
91
+ type Story = StoryObj<typeof meta>;
92
+
93
+ /**
94
+ * The customer viewpoint: the customer's own messages sit on the right under
95
+ * "You", staff appear under a masked "Qorus Support" label, and internal notes
96
+ * are never delivered to this surface (the server filters them). Clicking an
97
+ * attachment chip calls `onDownloadAttachment` with its id + filename.
98
+ */
99
+ export const CustomerView: Story = {
100
+ args: {
101
+ viewerRole: 'customer',
102
+ // the server never sends internal notes to the customer
103
+ messages: THREAD.filter((m) => !m.internal),
104
+ },
105
+ async play({ canvasElement, args }) {
106
+ const canvas = within(canvasElement);
107
+
108
+ await expect(canvas.getByText('You')).toBeInTheDocument();
109
+ await expect(canvas.getByText('Qorus Support')).toBeInTheDocument();
110
+ // internal notes are filtered out for the customer
111
+ await expect(canvas.queryByText('Internal note')).not.toBeInTheDocument();
112
+
113
+ await userEvent.click(canvas.getAllByText('deploy-trace.log')[0]);
114
+ await waitFor(() =>
115
+ expect(args.onDownloadAttachment).toHaveBeenCalledWith('att-1', 'deploy-trace.log')
116
+ );
117
+ },
118
+ };
119
+
120
+ /**
121
+ * Messages can reference interfaces on the customer's instance. Each reference
122
+ * renders as a chip under the message — the interface kind as the chip's key and
123
+ * the interface name as its label — and several references (including two of the
124
+ * same kind) can attach to one message. When the consumer passes
125
+ * `onInterfaceClick` (the customer view, which can open the interface on its own
126
+ * instance) the chips are clickable; `resolveInterfaceIcon` supplies a per-kind
127
+ * icon. The staff view omits `onInterfaceClick`, leaving the chips static.
128
+ */
129
+ export const WithInterfaceReferences: Story = {
130
+ args: {
131
+ viewerRole: 'customer',
132
+ messages: REFERENCED_THREAD,
133
+ onInterfaceClick: fn(),
134
+ resolveInterfaceIcon: (kind) =>
135
+ kind === 'workflow' ? 'GitBranchLine' : kind === 'service' ? 'ServerLine' : 'TimerLine',
136
+ },
137
+ async play({ canvasElement, args }) {
138
+ const canvas = within(canvasElement);
139
+
140
+ // every reference renders, including two workflows on the same message
141
+ await expect(canvas.getByText('order-processing:1.0')).toBeInTheDocument();
142
+ await expect(canvas.getByText('invoice-sync:2.3')).toBeInTheDocument();
143
+ await expect(canvas.getByText('notification-svc')).toBeInTheDocument();
144
+ await expect(canvas.getByText('nightly-cleanup')).toBeInTheDocument();
145
+
146
+ // clicking a chip opens that specific interface
147
+ await userEvent.click(canvas.getByText('invoice-sync:2.3'));
148
+ await waitFor(() =>
149
+ expect(args.onInterfaceClick).toHaveBeenCalledWith(
150
+ expect.objectContaining({
151
+ interface_kind: 'workflow',
152
+ interface_name: 'invoice-sync:2.3',
153
+ })
154
+ )
155
+ );
156
+ },
157
+ };
158
+
159
+ /**
160
+ * The staff viewpoint: staff messages sit on the right, the customer on the
161
+ * left, and staff-only internal notes render amber + bordered under an "Internal
162
+ * note" label so they can never be mistaken for a customer-visible reply.
163
+ */
164
+ export const StaffView: Story = {
165
+ args: {
166
+ viewerRole: 'staff',
167
+ messages: THREAD,
168
+ },
169
+ async play({ canvasElement }) {
170
+ const canvas = within(canvasElement);
171
+
172
+ await expect(canvas.getByText('Internal note')).toBeInTheDocument();
173
+ await expect(canvas.getByText(/Support · nick/)).toBeInTheDocument();
174
+ await expect(canvas.getByText(/Customer · acme/)).toBeInTheDocument();
175
+ // the reference chip renders with the built-in default icon (no resolver passed)
176
+ await expect(canvas.getByText('nightly-deploy:1.2')).toBeInTheDocument();
177
+ },
178
+ };
179
+
180
+ export const Loading: Story = {
181
+ args: { loading: true },
182
+ async play({ canvasElement }) {
183
+ await expect(within(canvasElement).getByText(/Loading conversation/)).toBeInTheDocument();
184
+ },
185
+ };
186
+
187
+ export const Empty: Story = {
188
+ args: { messages: [] },
189
+ async play({ canvasElement }) {
190
+ await expect(within(canvasElement).getByText('No messages yet')).toBeInTheDocument();
191
+ },
192
+ };
@@ -0,0 +1,331 @@
1
+ import {
2
+ ReqoreBubble,
3
+ ReqoreBubbleGroup,
4
+ ReqoreButton,
5
+ ReqoreControlGroup,
6
+ ReqoreEmptyState,
7
+ ReqoreIcon,
8
+ ReqoreP,
9
+ ReqoreSpinner,
10
+ } from '@qoretechnologies/reqore';
11
+ import { IReqoreIconName } from '@qoretechnologies/reqore/dist/types/icons';
12
+ import ReactMarkdown from 'react-markdown';
13
+ import styled from 'styled-components';
14
+ import {
15
+ IInterfaceReference,
16
+ InterfaceReferenceTags,
17
+ TTicketMessageAuthor,
18
+ TTicketViewerRole,
19
+ } from '../supportTicket';
20
+
21
+ /*
22
+ * Shared support-ticket conversation thread, used by every Qorus surface that
23
+ * shows a ticket: the qorus-ide customer view, the admin-portal staff queue, and
24
+ * the Qonsole `support-ticket` renderer. Presentational — data in, callbacks out
25
+ * — so each consumer keeps its own fetch/websocket wiring.
26
+ *
27
+ * Rendered as a chat transcript with Reqore's `ReqoreBubbleGroup` / `ReqoreBubble`
28
+ * (the same bubble primitive the Qonsole chat uses): consecutive same-author
29
+ * messages cluster into one rounded run, each carries a muted timestamp, and the
30
+ * author label shows once at the top of each run. Interface references render via
31
+ * the shared `InterfaceReferenceTags`; the status/priority/category vocabularies
32
+ * live in `../supportTicket/meta`.
33
+ *
34
+ * The `viewerRole` decides which side "you" sit on and how staff-only internal
35
+ * notes render: the customer view never receives internal notes (the server
36
+ * filters them) and sees staff under a masked "Qorus Support" label; the staff
37
+ * view puts staff on the right and paints internal notes amber + bordered under an
38
+ * "Internal note" label so they can never be mistaken for a customer-visible reply.
39
+ * System messages render as a centered muted line, not a bubble.
40
+ */
41
+
42
+ export interface ITicketThreadAttachment {
43
+ attachment_id: string;
44
+ filename: string;
45
+ content_type: string;
46
+ size_bytes: number;
47
+ }
48
+
49
+ export interface ITicketThreadMessage {
50
+ message_id: string;
51
+ author_type: TTicketMessageAuthor;
52
+ author_id: string;
53
+ body: string;
54
+ internal?: boolean;
55
+ created: string;
56
+ attachments?: ITicketThreadAttachment[];
57
+ /** Qorus interfaces this message references, as `{kind, name}` snapshots. */
58
+ referenced_interfaces?: IInterfaceReference[];
59
+ }
60
+
61
+ export interface ITicketThreadProps {
62
+ messages: ITicketThreadMessage[];
63
+ /** which side "you" sit on + how internal notes render. */
64
+ viewerRole?: TTicketViewerRole;
65
+ loading?: boolean;
66
+ /** download an attachment (the consumer wires this to its API). */
67
+ onDownloadAttachment?: (attachmentId: string, filename: string) => void;
68
+ /** resolve a per-kind icon for a referenced interface. Consumers with their own
69
+ * icon vocabulary (e.g. the qorus-ide IDE) pass one; when omitted, a built-in
70
+ * per-kind default is used so every surface shows consistent chips. */
71
+ resolveInterfaceIcon?: (kind: string) => IReqoreIconName;
72
+ /** open a referenced interface; reference chips are static when omitted (e.g. the staff
73
+ * view, which can't reach the customer's instance, leaves this out). */
74
+ onInterfaceClick?: (reference: IInterfaceReference) => void;
75
+ }
76
+
77
+ interface IAuthorMeta {
78
+ icon: IReqoreIconName;
79
+ }
80
+
81
+ const AUTHOR_META: Record<TTicketMessageAuthor, IAuthorMeta> = {
82
+ customer: { icon: 'User3Line' },
83
+ staff: { icon: 'CustomerService2Line' },
84
+ system: { icon: 'InformationLine' },
85
+ };
86
+
87
+ /** Compact relative-time label from an ISO timestamp. */
88
+ const timeAgo = (iso?: string): string => {
89
+ if (!iso) {
90
+ return '';
91
+ }
92
+ const then = new Date(iso).getTime();
93
+ if (Number.isNaN(then)) {
94
+ return '';
95
+ }
96
+ const seconds = Math.round((then - Date.now()) / 1000);
97
+ const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
98
+ const units: [Intl.RelativeTimeFormatUnit, number][] = [
99
+ ['year', 31536000],
100
+ ['month', 2592000],
101
+ ['week', 604800],
102
+ ['day', 86400],
103
+ ['hour', 3600],
104
+ ['minute', 60],
105
+ ['second', 1],
106
+ ];
107
+ for (const [unit, per] of units) {
108
+ if (Math.abs(seconds) >= per || unit === 'second') {
109
+ return rtf.format(Math.round(seconds / per), unit);
110
+ }
111
+ }
112
+ return '';
113
+ };
114
+
115
+ /** Human-readable file size, e.g. "12.3 KB". */
116
+ const formatBytes = (bytes: number): string => {
117
+ if (bytes < 1024) {
118
+ return `${bytes} B`;
119
+ }
120
+ const units = ['KB', 'MB', 'GB'];
121
+ let value = bytes / 1024;
122
+ let unit = 0;
123
+ while (value >= 1024 && unit < units.length - 1) {
124
+ value /= 1024;
125
+ unit += 1;
126
+ }
127
+ return `${value.toFixed(1)} ${units[unit]}`;
128
+ };
129
+
130
+ /** The label shown above a run of messages. Customer view masks staff identity as
131
+ * "Qorus Support"; staff view shows the real author with a role prefix. Internal
132
+ * notes always label as "Internal note" regardless of viewer. */
133
+ const authorLabel = (message: ITicketThreadMessage, viewerRole: TTicketViewerRole): string => {
134
+ if (message.internal) {
135
+ return 'Internal note';
136
+ }
137
+ if (viewerRole === 'customer') {
138
+ return message.author_type === 'customer' ? 'You' : 'Qorus Support';
139
+ }
140
+ return `${message.author_type === 'staff' ? 'Support' : 'Customer'} · ${message.author_id}`;
141
+ };
142
+
143
+ /** Per-role bubble tint: staff green, customer blue, internal notes amber. */
144
+ const bubbleIntent = (
145
+ message: ITicketThreadMessage
146
+ ): 'info' | 'success' | 'warning' => {
147
+ if (message.internal) {
148
+ return 'warning';
149
+ }
150
+ return message.author_type === 'staff' ? 'success' : 'info';
151
+ };
152
+
153
+ const StyledThread = styled.div`
154
+ display: flex;
155
+ flex-direction: column;
156
+ gap: 14px;
157
+ width: 100%;
158
+ `;
159
+
160
+ // System messages read as a centered muted note between bubble clusters; the
161
+ // nested rule flattens react-markdown's own <p> margins so it sits on one line.
162
+ const StyledSystemRow = styled.div`
163
+ display: flex;
164
+ align-items: center;
165
+ justify-content: center;
166
+ gap: 6px;
167
+ font-size: 11px;
168
+ opacity: 0.55;
169
+ text-align: center;
170
+ p {
171
+ margin: 0;
172
+ }
173
+ `;
174
+
175
+ interface IBubbleContentProps {
176
+ message: ITicketThreadMessage;
177
+ showLabel: boolean;
178
+ viewerRole: TTicketViewerRole;
179
+ onDownloadAttachment?: (attachmentId: string, filename: string) => void;
180
+ resolveInterfaceIcon?: (kind: string) => IReqoreIconName;
181
+ onInterfaceClick?: (reference: IInterfaceReference) => void;
182
+ }
183
+
184
+ /** The inner content of a message bubble: an optional author label (shown once
185
+ * per same-author run), the markdown body, attachment chips, and reference chips. */
186
+ const BubbleContent = ({
187
+ message,
188
+ showLabel,
189
+ viewerRole,
190
+ onDownloadAttachment,
191
+ resolveInterfaceIcon,
192
+ onInterfaceClick,
193
+ }: IBubbleContentProps) => {
194
+ const isInternal = !!message.internal;
195
+ return (
196
+ <>
197
+ {showLabel ? (
198
+ <ReqoreControlGroup verticalAlign='center' gapSize='tiny' style={{ marginBottom: 4 }}>
199
+ <ReqoreIcon
200
+ icon={isInternal ? 'EyeOffLine' : AUTHOR_META[message.author_type].icon}
201
+ size='tiny'
202
+ />
203
+ <ReqoreP size='small' effect={{ weight: 'bold', opacity: 0.7 }}>
204
+ {authorLabel(message, viewerRole)}
205
+ </ReqoreP>
206
+ </ReqoreControlGroup>
207
+ ) : null}
208
+ <ReactMarkdown>{message.body}</ReactMarkdown>
209
+ {message.attachments?.length ? (
210
+ <ReqoreControlGroup wrap gapSize='small' style={{ marginTop: 8 }}>
211
+ {message.attachments.map((a) => (
212
+ <ReqoreButton
213
+ key={a.attachment_id}
214
+ icon='Download2Line'
215
+ size='small'
216
+ flat
217
+ badge={formatBytes(a.size_bytes)}
218
+ tooltip={`${a.content_type} · download`}
219
+ onClick={() => onDownloadAttachment?.(a.attachment_id, a.filename)}
220
+ >
221
+ {a.filename}
222
+ </ReqoreButton>
223
+ ))}
224
+ </ReqoreControlGroup>
225
+ ) : null}
226
+ {message.referenced_interfaces?.length ? (
227
+ <div style={{ marginTop: 8 }}>
228
+ <InterfaceReferenceTags
229
+ references={message.referenced_interfaces}
230
+ resolveInterfaceIcon={resolveInterfaceIcon}
231
+ onInterfaceClick={onInterfaceClick}
232
+ />
233
+ </div>
234
+ ) : null}
235
+ </>
236
+ );
237
+ };
238
+
239
+ type TThreadSegment =
240
+ | { kind: 'system'; message: ITicketThreadMessage }
241
+ | { kind: 'bubbles'; messages: ITicketThreadMessage[] };
242
+
243
+ /** Split the thread into centered system lines and runs of consecutive
244
+ * non-system messages (each run becomes one auto-clustered bubble group). */
245
+ const toSegments = (messages: ITicketThreadMessage[]): TThreadSegment[] => {
246
+ const segments: TThreadSegment[] = [];
247
+ for (const message of messages) {
248
+ if (message.author_type === 'system') {
249
+ segments.push({ kind: 'system', message });
250
+ continue;
251
+ }
252
+ const last = segments[segments.length - 1];
253
+ if (last?.kind === 'bubbles') {
254
+ last.messages.push(message);
255
+ } else {
256
+ segments.push({ kind: 'bubbles', messages: [message] });
257
+ }
258
+ }
259
+ return segments;
260
+ };
261
+
262
+ export const TicketThread = ({
263
+ messages,
264
+ viewerRole = 'customer',
265
+ loading,
266
+ onDownloadAttachment,
267
+ resolveInterfaceIcon,
268
+ onInterfaceClick,
269
+ }: ITicketThreadProps) => {
270
+ if (loading) {
271
+ return (
272
+ <ReqoreControlGroup vertical fluid horizontalAlign='center' style={{ padding: '24px 0' }}>
273
+ <ReqoreSpinner type={5} iconColor='info' size='big'>
274
+ Loading conversation…
275
+ </ReqoreSpinner>
276
+ </ReqoreControlGroup>
277
+ );
278
+ }
279
+
280
+ if (!messages.length) {
281
+ return <ReqoreEmptyState icon='Chat3Line' title='No messages yet' />;
282
+ }
283
+
284
+ return (
285
+ <StyledThread>
286
+ {toSegments(messages).map((segment, index) =>
287
+ segment.kind === 'system' ? (
288
+ <StyledSystemRow key={segment.message.message_id}>
289
+ <ReqoreIcon icon='InformationLine' size='tiny' />
290
+ <ReactMarkdown>{segment.message.body}</ReactMarkdown>
291
+ </StyledSystemRow>
292
+ ) : (
293
+ <ReqoreBubbleGroup key={`group-${index}`}>
294
+ {segment.messages.map((message, i) => {
295
+ const previous = segment.messages[i - 1];
296
+ // Label once per same-author run; an internal note always gets its
297
+ // own label even from the same staff author.
298
+ const showLabel =
299
+ !previous ||
300
+ previous.author_type !== message.author_type ||
301
+ !!previous.internal !== !!message.internal;
302
+ const isSelf = message.author_type === viewerRole;
303
+ return (
304
+ <ReqoreBubble
305
+ key={message.message_id}
306
+ align={isSelf ? 'right' : 'left'}
307
+ intent={bubbleIntent(message)}
308
+ minimal={!message.internal}
309
+ flat={!message.internal}
310
+ timestamp={timeAgo(message.created) || undefined}
311
+ >
312
+ <BubbleContent
313
+ message={message}
314
+ showLabel={showLabel}
315
+ viewerRole={viewerRole}
316
+ onDownloadAttachment={onDownloadAttachment}
317
+ resolveInterfaceIcon={resolveInterfaceIcon}
318
+ onInterfaceClick={onInterfaceClick}
319
+ />
320
+ </ReqoreBubble>
321
+ );
322
+ })}
323
+ </ReqoreBubbleGroup>
324
+ )
325
+ )}
326
+ <ReqoreP size='tiny' effect={{ opacity: 0.5, textAlign: 'center' }}>
327
+ {messages.length} message{messages.length === 1 ? '' : 's'}
328
+ </ReqoreP>
329
+ </StyledThread>
330
+ );
331
+ };
@@ -0,0 +1,6 @@
1
+ export { TicketThread } from './TicketThread';
2
+ export type {
3
+ ITicketThreadAttachment,
4
+ ITicketThreadMessage,
5
+ ITicketThreadProps,
6
+ } from './TicketThread';
package/src/index.tsx CHANGED
@@ -55,3 +55,5 @@ export type {
55
55
  export * from './components/smartEditor';
56
56
  export * from './components/dpqlEditor';
57
57
  export * from './components/qonsoleSmartInput';
58
+ export * from './components/supportTicket';
59
+ export * from './components/ticketThread';