@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.
- package/dist/components/supportTicket/AttachmentChips.d.ts +7 -0
- package/dist/components/supportTicket/AttachmentChips.d.ts.map +1 -0
- package/dist/components/supportTicket/AttachmentChips.js +26 -0
- package/dist/components/supportTicket/AttachmentChips.js.map +1 -0
- package/dist/components/supportTicket/InterfaceReferenceTags.d.ts +25 -0
- package/dist/components/supportTicket/InterfaceReferenceTags.d.ts.map +1 -0
- package/dist/components/supportTicket/InterfaceReferenceTags.js +28 -0
- package/dist/components/supportTicket/InterfaceReferenceTags.js.map +1 -0
- package/dist/components/supportTicket/TicketMetaTags.d.ts +24 -0
- package/dist/components/supportTicket/TicketMetaTags.d.ts.map +1 -0
- package/dist/components/supportTicket/TicketMetaTags.js +21 -0
- package/dist/components/supportTicket/TicketMetaTags.js.map +1 -0
- package/dist/components/supportTicket/TicketReplyBox.d.ts +59 -0
- package/dist/components/supportTicket/TicketReplyBox.d.ts.map +1 -0
- package/dist/components/supportTicket/TicketReplyBox.js +169 -0
- package/dist/components/supportTicket/TicketReplyBox.js.map +1 -0
- package/dist/components/supportTicket/fileToAttachment.d.ts +9 -0
- package/dist/components/supportTicket/fileToAttachment.d.ts.map +1 -0
- package/dist/components/supportTicket/fileToAttachment.js +28 -0
- package/dist/components/supportTicket/fileToAttachment.js.map +1 -0
- package/dist/components/supportTicket/index.d.ts +7 -0
- package/dist/components/supportTicket/index.d.ts.map +1 -0
- package/dist/components/supportTicket/index.js +23 -0
- package/dist/components/supportTicket/index.js.map +1 -0
- package/dist/components/supportTicket/meta.d.ts +55 -0
- package/dist/components/supportTicket/meta.d.ts.map +1 -0
- package/dist/components/supportTicket/meta.js +77 -0
- package/dist/components/supportTicket/meta.js.map +1 -0
- package/dist/components/ticketThread/TicketThread.d.ts +36 -0
- package/dist/components/ticketThread/TicketThread.d.ts.map +1 -0
- package/dist/components/ticketThread/TicketThread.js +137 -0
- package/dist/components/ticketThread/TicketThread.js.map +1 -0
- package/dist/components/ticketThread/index.d.ts +3 -0
- package/dist/components/ticketThread/index.d.ts.map +1 -0
- package/dist/components/ticketThread/index.js +6 -0
- package/dist/components/ticketThread/index.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/supportTicket/AttachmentChips.tsx +50 -0
- package/src/components/supportTicket/InterfaceReferenceTags.stories.tsx +102 -0
- package/src/components/supportTicket/InterfaceReferenceTags.tsx +62 -0
- package/src/components/supportTicket/TicketMetaTags.stories.tsx +71 -0
- package/src/components/supportTicket/TicketMetaTags.tsx +58 -0
- package/src/components/supportTicket/TicketReplyBox.stories.tsx +111 -0
- package/src/components/supportTicket/TicketReplyBox.tsx +251 -0
- package/src/components/supportTicket/fileToAttachment.ts +24 -0
- package/src/components/supportTicket/index.ts +6 -0
- package/src/components/supportTicket/meta.ts +135 -0
- package/src/components/ticketThread/TicketThread.stories.tsx +192 -0
- package/src/components/ticketThread/TicketThread.tsx +331 -0
- package/src/components/ticketThread/index.ts +6 -0
- 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
|
+
};
|
package/src/index.tsx
CHANGED