@qoretechnologies/reqraft 0.10.20 → 0.10.26
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/.claude/CLAUDE.md +11 -8
- package/dist/components/composer/Composer.d.ts +108 -0
- package/dist/components/composer/Composer.d.ts.map +1 -0
- package/dist/components/composer/Composer.js +238 -0
- package/dist/components/composer/Composer.js.map +1 -0
- package/dist/components/composer/helpers.d.ts +21 -0
- package/dist/components/composer/helpers.d.ts.map +1 -0
- package/dist/components/composer/helpers.js +75 -0
- package/dist/components/composer/helpers.js.map +1 -0
- package/dist/components/composer/index.d.ts +3 -0
- package/dist/components/composer/index.d.ts.map +1 -0
- package/dist/components/composer/index.js +19 -0
- package/dist/components/composer/index.js.map +1 -0
- package/dist/components/form/engine/FormEngine.d.ts +1 -1
- package/dist/components/imageLightbox/ImageLightbox.d.ts +21 -0
- package/dist/components/imageLightbox/ImageLightbox.d.ts.map +1 -0
- package/dist/components/imageLightbox/ImageLightbox.js +169 -0
- package/dist/components/imageLightbox/ImageLightbox.js.map +1 -0
- package/dist/components/imageLightbox/index.d.ts +2 -0
- package/dist/components/imageLightbox/index.d.ts.map +1 -0
- package/dist/components/imageLightbox/index.js +18 -0
- package/dist/components/imageLightbox/index.js.map +1 -0
- package/dist/components/searchFilterBar/SearchFilterBar.d.ts +33 -0
- package/dist/components/searchFilterBar/SearchFilterBar.d.ts.map +1 -0
- package/dist/components/searchFilterBar/SearchFilterBar.js +76 -0
- package/dist/components/searchFilterBar/SearchFilterBar.js.map +1 -0
- package/dist/components/searchFilterBar/index.d.ts +2 -0
- package/dist/components/searchFilterBar/index.d.ts.map +1 -0
- package/dist/components/searchFilterBar/index.js +18 -0
- package/dist/components/searchFilterBar/index.js.map +1 -0
- package/dist/components/supportTicket/AttachmentChips.d.ts +10 -0
- package/dist/components/supportTicket/AttachmentChips.d.ts.map +1 -0
- package/dist/components/supportTicket/AttachmentChips.js +122 -0
- package/dist/components/supportTicket/AttachmentChips.js.map +1 -0
- package/dist/components/supportTicket/InterfaceReferenceTags.d.ts +43 -0
- package/dist/components/supportTicket/InterfaceReferenceTags.d.ts.map +1 -0
- package/dist/components/supportTicket/InterfaceReferenceTags.js +31 -0
- package/dist/components/supportTicket/InterfaceReferenceTags.js.map +1 -0
- package/dist/components/supportTicket/ReferencePicker.d.ts +87 -0
- package/dist/components/supportTicket/ReferencePicker.d.ts.map +1 -0
- package/dist/components/supportTicket/ReferencePicker.js +295 -0
- package/dist/components/supportTicket/ReferencePicker.js.map +1 -0
- package/dist/components/supportTicket/TicketHeader.d.ts +86 -0
- package/dist/components/supportTicket/TicketHeader.d.ts.map +1 -0
- package/dist/components/supportTicket/TicketHeader.js +149 -0
- package/dist/components/supportTicket/TicketHeader.js.map +1 -0
- package/dist/components/supportTicket/TicketMetaTags.d.ts +32 -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 +63 -0
- package/dist/components/supportTicket/TicketReplyBox.d.ts.map +1 -0
- package/dist/components/supportTicket/TicketReplyBox.js +115 -0
- package/dist/components/supportTicket/TicketReplyBox.js.map +1 -0
- package/dist/components/supportTicket/attachmentAccess.d.ts +37 -0
- package/dist/components/supportTicket/attachmentAccess.d.ts.map +1 -0
- package/dist/components/supportTicket/attachmentAccess.js +132 -0
- package/dist/components/supportTicket/attachmentAccess.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 +11 -0
- package/dist/components/supportTicket/index.d.ts.map +1 -0
- package/dist/components/supportTicket/index.js +27 -0
- package/dist/components/supportTicket/index.js.map +1 -0
- package/dist/components/supportTicket/meta.d.ts +71 -0
- package/dist/components/supportTicket/meta.d.ts.map +1 -0
- package/dist/components/supportTicket/meta.js +115 -0
- package/dist/components/supportTicket/meta.js.map +1 -0
- package/dist/components/supportTicket/ticketModel.d.ts +102 -0
- package/dist/components/supportTicket/ticketModel.d.ts.map +1 -0
- package/dist/components/supportTicket/ticketModel.js +3 -0
- package/dist/components/supportTicket/ticketModel.js.map +1 -0
- package/dist/components/ticketReferences/TicketReferences.d.ts +52 -0
- package/dist/components/ticketReferences/TicketReferences.d.ts.map +1 -0
- package/dist/components/ticketReferences/TicketReferences.js +492 -0
- package/dist/components/ticketReferences/TicketReferences.js.map +1 -0
- package/dist/components/ticketReferences/index.d.ts +3 -0
- package/dist/components/ticketReferences/index.d.ts.map +1 -0
- package/dist/components/ticketReferences/index.js +6 -0
- package/dist/components/ticketReferences/index.js.map +1 -0
- package/dist/components/ticketThread/TicketThread.d.ts +57 -0
- package/dist/components/ticketThread/TicketThread.d.ts.map +1 -0
- package/dist/components/ticketThread/TicketThread.js +299 -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 +7 -0
- package/dist/components/ticketThread/index.js.map +1 -0
- package/dist/helpers/common.d.ts +5 -0
- package/dist/helpers/common.d.ts.map +1 -1
- package/dist/helpers/common.js +20 -1
- package/dist/helpers/common.js.map +1 -1
- package/dist/helpers/scrollbar.d.ts +2 -0
- package/dist/helpers/scrollbar.d.ts.map +1 -0
- package/dist/helpers/scrollbar.js +25 -0
- package/dist/helpers/scrollbar.js.map +1 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/composer/Composer.stories.tsx +238 -0
- package/src/components/composer/Composer.tsx +545 -0
- package/src/components/composer/helpers.ts +74 -0
- package/src/components/composer/index.ts +2 -0
- package/src/components/imageLightbox/ImageLightbox.stories.tsx +238 -0
- package/src/components/imageLightbox/ImageLightbox.tsx +236 -0
- package/src/components/imageLightbox/index.ts +1 -0
- package/src/components/searchFilterBar/SearchFilterBar.tsx +131 -0
- package/src/components/searchFilterBar/index.ts +1 -0
- package/src/components/supportTicket/AttachmentChips.stories.tsx +171 -0
- package/src/components/supportTicket/AttachmentChips.tsx +147 -0
- package/src/components/supportTicket/InterfaceReferenceTags.stories.tsx +171 -0
- package/src/components/supportTicket/InterfaceReferenceTags.tsx +96 -0
- package/src/components/supportTicket/ReferencePicker.stories.tsx +519 -0
- package/src/components/supportTicket/ReferencePicker.tsx +563 -0
- package/src/components/supportTicket/TicketConversation.stories.tsx +121 -0
- package/src/components/supportTicket/TicketHeader.stories.tsx +183 -0
- package/src/components/supportTicket/TicketHeader.tsx +247 -0
- package/src/components/supportTicket/TicketMetaTags.stories.tsx +103 -0
- package/src/components/supportTicket/TicketMetaTags.tsx +85 -0
- package/src/components/supportTicket/TicketReplyBox.stories.tsx +351 -0
- package/src/components/supportTicket/TicketReplyBox.tsx +146 -0
- package/src/components/supportTicket/attachmentAccess.ts +97 -0
- package/src/components/supportTicket/fileToAttachment.ts +24 -0
- package/src/components/supportTicket/index.ts +10 -0
- package/src/components/supportTicket/meta.ts +185 -0
- package/src/components/supportTicket/ticketModel.ts +131 -0
- package/src/components/ticketReferences/TicketReferences.stories.tsx +315 -0
- package/src/components/ticketReferences/TicketReferences.tsx +886 -0
- package/src/components/ticketReferences/index.ts +2 -0
- package/src/components/ticketThread/TicketThread.stories.tsx +828 -0
- package/src/components/ticketThread/TicketThread.tsx +704 -0
- package/src/components/ticketThread/index.ts +6 -0
- package/src/helpers/common.ts +19 -0
- package/src/helpers/scrollbar.ts +32 -0
- package/src/index.tsx +8 -1
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ReqoreBubble,
|
|
3
|
+
ReqoreBubbleGroup,
|
|
4
|
+
ReqoreButton,
|
|
5
|
+
ReqoreControlGroup,
|
|
6
|
+
ReqoreEmptyState,
|
|
7
|
+
ReqoreIcon,
|
|
8
|
+
ReqoreP,
|
|
9
|
+
ReqoreSkeleton,
|
|
10
|
+
ReqoreTimeAgo,
|
|
11
|
+
useReqoreTheme,
|
|
12
|
+
} from '@qoretechnologies/reqore';
|
|
13
|
+
import { getReadableColor } from '@qoretechnologies/reqore/dist/helpers/colors';
|
|
14
|
+
import { IReqoreIconName } from '@qoretechnologies/reqore/dist/types/icons';
|
|
15
|
+
import { rgba } from 'polished';
|
|
16
|
+
import { ComponentProps, useEffect, useMemo, useRef, useState } from 'react';
|
|
17
|
+
import ReactMarkdown from 'react-markdown';
|
|
18
|
+
import styled from 'styled-components';
|
|
19
|
+
import { formatBytes, isImage } from '../../helpers/common';
|
|
20
|
+
import { ImageLightbox } from '../imageLightbox';
|
|
21
|
+
import {
|
|
22
|
+
IInterfaceReference,
|
|
23
|
+
InterfaceReferenceTags,
|
|
24
|
+
TTicketMessageAuthor,
|
|
25
|
+
TTicketViewerRole,
|
|
26
|
+
} from '../supportTicket';
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
* Shared support-ticket conversation thread, used by every Qorus surface that
|
|
30
|
+
* shows a ticket: the qorus-ide customer view, the admin-portal staff queue, and
|
|
31
|
+
* the Qonsole `support-ticket` renderer. Presentational — data in, callbacks out
|
|
32
|
+
* — so each consumer keeps its own fetch/websocket wiring.
|
|
33
|
+
*
|
|
34
|
+
* Rendered as a comment feed: an avatar beside a bordered, accent-tinted card per
|
|
35
|
+
* message, each hugging its own side. Built on Reqore's `ReqoreBubble` (which
|
|
36
|
+
* supplies the avatar, the bold author label, the alignment and the tint), so the
|
|
37
|
+
* accent follows whatever the host app themes `custom1` to rather than a colour
|
|
38
|
+
* baked in here. Interface references render via the shared
|
|
39
|
+
* `InterfaceReferenceTags`; the status/priority/category vocabularies live in
|
|
40
|
+
* `../supportTicket/meta`.
|
|
41
|
+
*
|
|
42
|
+
* Runs of consecutive messages are wrapped in a `ReqoreBubbleGroup`, which prints
|
|
43
|
+
* one time under the last card of each same-side run instead of one per card — a
|
|
44
|
+
* burst of replies reads as a single moment.
|
|
45
|
+
*
|
|
46
|
+
* The `viewerRole` decides which side "you" sit on and how staff-only internal
|
|
47
|
+
* notes render: the customer view never receives internal notes (the server
|
|
48
|
+
* filters them) and sees staff under a masked "Qorus Support" label; the staff
|
|
49
|
+
* view puts staff on the right and paints internal notes amber so they can never
|
|
50
|
+
* be mistaken for a customer-visible reply. System messages render as a centered
|
|
51
|
+
* muted line, not a card.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
export interface ITicketThreadAttachment {
|
|
55
|
+
attachment_id: string;
|
|
56
|
+
filename: string;
|
|
57
|
+
content_type: string;
|
|
58
|
+
size_bytes: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ITicketThreadMessage {
|
|
62
|
+
message_id: string;
|
|
63
|
+
author_type: TTicketMessageAuthor;
|
|
64
|
+
author_id: string;
|
|
65
|
+
body: string;
|
|
66
|
+
internal?: boolean;
|
|
67
|
+
created: string;
|
|
68
|
+
attachments?: ITicketThreadAttachment[];
|
|
69
|
+
/** Qorus interfaces this message references, as `{kind, name}` snapshots. */
|
|
70
|
+
referenced_interfaces?: IInterfaceReference[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ITicketThreadProps {
|
|
74
|
+
messages: ITicketThreadMessage[];
|
|
75
|
+
/** which side "you" sit on + how internal notes render. */
|
|
76
|
+
viewerRole?: TTicketViewerRole;
|
|
77
|
+
loading?: boolean;
|
|
78
|
+
/** download an attachment (the consumer wires this to its API). */
|
|
79
|
+
onDownloadAttachment?: (attachmentId: string, filename: string) => void;
|
|
80
|
+
/**
|
|
81
|
+
* Fetch an image attachment's bytes as an object URL, for the inline thumbnail +
|
|
82
|
+
* click-to-enlarge preview. Omitted ⇒ image attachments fall back to the same
|
|
83
|
+
* download chip as every other file, so the thread degrades rather than breaks
|
|
84
|
+
* where a consumer hasn't wired it. Non-image attachments never use it.
|
|
85
|
+
*/
|
|
86
|
+
fetchAttachmentUrl?: (attachmentId: string) => Promise<string>;
|
|
87
|
+
/** resolve a per-kind icon for a referenced interface. Consumers with their own
|
|
88
|
+
* icon vocabulary (e.g. the qorus-ide IDE) pass one; when omitted, a built-in
|
|
89
|
+
* per-kind default is used so every surface shows consistent chips. */
|
|
90
|
+
resolveInterfaceIcon?: (kind: string) => IReqoreIconName;
|
|
91
|
+
/** open a referenced interface; reference chips are static when omitted (e.g. the staff
|
|
92
|
+
* view, which can't reach the customer's instance, leaves this out). */
|
|
93
|
+
onInterfaceClick?: (reference: IInterfaceReference) => void;
|
|
94
|
+
/** The TICKET's own references (picked when filing). Echoed as chips on the opening
|
|
95
|
+
* message so the conversation carries them too — when they lived only in the header
|
|
96
|
+
* rail + References tab, readers looked for them in the thread and concluded the
|
|
97
|
+
* ticket had none. Deduped against the opening message's own message-level refs. */
|
|
98
|
+
ticketReferences?: IInterfaceReference[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface IAuthorMeta {
|
|
102
|
+
icon: IReqoreIconName;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const AUTHOR_META: Record<TTicketMessageAuthor, IAuthorMeta> = {
|
|
106
|
+
customer: { icon: 'User3Line' },
|
|
107
|
+
staff: { icon: 'CustomerService2Line' },
|
|
108
|
+
system: { icon: 'InformationLine' },
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Both sides share one accent: the side, the avatar and the author label already
|
|
112
|
+
// carry who is speaking, which frees colour to mean "internal note" and nothing
|
|
113
|
+
// else. `custom1` is a theme slot, so each host app supplies its own hue.
|
|
114
|
+
const ACCENT: ComponentProps<typeof ReqoreBubble>['customTheme'] = { main: 'custom1' };
|
|
115
|
+
|
|
116
|
+
/** Avatar glyph; an internal note carries the "not visible to the customer" eye. */
|
|
117
|
+
const authorIcon = (message: ITicketThreadMessage): IReqoreIconName =>
|
|
118
|
+
message.internal ? 'EyeOffLine' : AUTHOR_META[message.author_type].icon;
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
/** The label above a message. The customer view masks staff identity as "Qorus
|
|
122
|
+
* Support"; the staff view shows the real author with a role prefix. Internal
|
|
123
|
+
* notes always label as "Internal note" regardless of viewer. */
|
|
124
|
+
const authorLabel = (message: ITicketThreadMessage, viewerRole: TTicketViewerRole): string => {
|
|
125
|
+
if (message.internal) {
|
|
126
|
+
return 'Internal note';
|
|
127
|
+
}
|
|
128
|
+
if (viewerRole === 'customer') {
|
|
129
|
+
return message.author_type === 'customer' ? 'You' : 'Qorus Support';
|
|
130
|
+
}
|
|
131
|
+
return `${message.author_type === 'staff' ? 'Support' : 'Customer'} · ${message.author_id}`;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* DOM id of a message in the rendered thread.
|
|
136
|
+
*
|
|
137
|
+
* Exported rather than inlined so that anything wanting to reveal a message — a
|
|
138
|
+
* "show in chat" action on a reference, a deep link — derives the id from the same
|
|
139
|
+
* function the thread renders with. Two copies of the same template string is exactly
|
|
140
|
+
* how that contract silently breaks when one side is renamed.
|
|
141
|
+
*/
|
|
142
|
+
export const ticketMessageDomId = (messageId: string): string => `ticket-message-${messageId}`;
|
|
143
|
+
|
|
144
|
+
const StyledThread = styled.div`
|
|
145
|
+
display: flex;
|
|
146
|
+
flex-direction: column;
|
|
147
|
+
gap: 12px;
|
|
148
|
+
width: 100%;
|
|
149
|
+
|
|
150
|
+
/*
|
|
151
|
+
* A thread too short to fill its scroller sits at the BOTTOM, against the
|
|
152
|
+
* composer, the way every chat surface behaves — otherwise the first reply
|
|
153
|
+
* floats at the top of a tall drawer with dead space beneath it, and each new
|
|
154
|
+
* message appears further from the box you just typed in.
|
|
155
|
+
*
|
|
156
|
+
* The auto top margin does the pushing rather than 'justify-content: flex-end':
|
|
157
|
+
* with flex-end, once the thread outgrows the scroller the overflow spills past
|
|
158
|
+
* the top edge and those first messages cannot be scrolled back into view. An
|
|
159
|
+
* auto margin only consumes FREE space, so it collapses to zero the moment the
|
|
160
|
+
* content overflows and normal scrolling is preserved.
|
|
161
|
+
*
|
|
162
|
+
* min-height needs a definite height on the scroll parent to resolve; where a
|
|
163
|
+
* consumer doesn't provide one it simply computes to auto and the thread keeps
|
|
164
|
+
* its natural top-aligned flow.
|
|
165
|
+
*/
|
|
166
|
+
min-height: 100%;
|
|
167
|
+
|
|
168
|
+
> *:first-child {
|
|
169
|
+
margin-top: auto;
|
|
170
|
+
}
|
|
171
|
+
`;
|
|
172
|
+
|
|
173
|
+
// The loading placeholder: pulsing skeleton "bubbles" alternating sides, so the
|
|
174
|
+
// wait previews the thread it's about to become rather than a bare spinner. Built
|
|
175
|
+
// from reqore's `ReqoreSkeleton` — the shape (avatar circle + a short label line +
|
|
176
|
+
// N body lines) mirrors a real message. Sizes vary so it doesn't read as a grid.
|
|
177
|
+
const SKELETON_BUBBLES: { self: boolean; lines: number; width: number }[] = [
|
|
178
|
+
{ self: false, lines: 2, width: 58 },
|
|
179
|
+
{ self: true, lines: 1, width: 42 },
|
|
180
|
+
{ self: true, lines: 3, width: 64 },
|
|
181
|
+
{ self: false, lines: 2, width: 50 },
|
|
182
|
+
{ self: false, lines: 1, width: 46 },
|
|
183
|
+
{ self: true, lines: 2, width: 60 },
|
|
184
|
+
{ self: false, lines: 3, width: 54 },
|
|
185
|
+
];
|
|
186
|
+
|
|
187
|
+
/*
|
|
188
|
+
* The skeleton fills the height from the TOP, unlike a real thread, which sits at the
|
|
189
|
+
* bottom against the composer. The bottom-pin is right for real messages — a short
|
|
190
|
+
* conversation belongs next to the box you type in — but a placeholder inheriting it
|
|
191
|
+
* clusters a few bubbles at the bottom under a large empty gap, which reads as a
|
|
192
|
+
* broken render rather than "loading". A loading skeleton grows top-down, the
|
|
193
|
+
* universal convention, so it occupies the space it's given no matter how tall.
|
|
194
|
+
*/
|
|
195
|
+
const StyledSkeletonThread = styled.div`
|
|
196
|
+
display: flex;
|
|
197
|
+
flex-direction: column;
|
|
198
|
+
gap: 12px;
|
|
199
|
+
width: 100%;
|
|
200
|
+
min-height: 100%;
|
|
201
|
+
`;
|
|
202
|
+
|
|
203
|
+
const StyledSkeletonRow = styled.div<{ $self: boolean }>`
|
|
204
|
+
display: flex;
|
|
205
|
+
gap: 10px;
|
|
206
|
+
align-items: flex-start;
|
|
207
|
+
justify-content: ${({ $self }) => ($self ? 'flex-end' : 'flex-start')};
|
|
208
|
+
`;
|
|
209
|
+
|
|
210
|
+
// Content stays left-aligned even on a right-side row — like a real bubble, where
|
|
211
|
+
// the label sits top-left however the bubble is aligned; the row positions the
|
|
212
|
+
// whole block, this just lays the lines out within it.
|
|
213
|
+
const StyledSkeletonLines = styled.div`
|
|
214
|
+
display: flex;
|
|
215
|
+
flex-direction: column;
|
|
216
|
+
gap: 6px;
|
|
217
|
+
align-items: flex-start;
|
|
218
|
+
`;
|
|
219
|
+
|
|
220
|
+
const ConversationSkeleton = () => (
|
|
221
|
+
<StyledSkeletonThread aria-busy='true' aria-label='Loading conversation'>
|
|
222
|
+
{SKELETON_BUBBLES.map((bubble, index) => {
|
|
223
|
+
const avatar = <ReqoreSkeleton circle width='30px' height='30px' />;
|
|
224
|
+
return (
|
|
225
|
+
<StyledSkeletonRow key={index} $self={bubble.self}>
|
|
226
|
+
{!bubble.self && avatar}
|
|
227
|
+
<StyledSkeletonLines style={{ width: `${bubble.width}%` }}>
|
|
228
|
+
<ReqoreSkeleton width='38%' height='10px' />
|
|
229
|
+
{Array.from({ length: bubble.lines }).map((_, line) => (
|
|
230
|
+
<ReqoreSkeleton
|
|
231
|
+
key={line}
|
|
232
|
+
width={line === bubble.lines - 1 ? '72%' : '100%'}
|
|
233
|
+
height='8px'
|
|
234
|
+
/>
|
|
235
|
+
))}
|
|
236
|
+
</StyledSkeletonLines>
|
|
237
|
+
{bubble.self && avatar}
|
|
238
|
+
</StyledSkeletonRow>
|
|
239
|
+
);
|
|
240
|
+
})}
|
|
241
|
+
</StyledSkeletonThread>
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
// Debosses the empty state: washes the icon + title back with opacity (kept
|
|
245
|
+
// theme-agnostic) and adds a faint light edge below, so "No messages yet" reads
|
|
246
|
+
// as pressed into the surface rather than announced.
|
|
247
|
+
const StyledEmpty = styled.div`
|
|
248
|
+
/*
|
|
249
|
+
* Centre the placeholder in the space it's given rather than letting it sit at the
|
|
250
|
+
* top. Like the thread, this fills its scroll parent (min-height: 100%, which needs a
|
|
251
|
+
* definite height to resolve — the drawer body provides one; where a consumer doesn't,
|
|
252
|
+
* it collapses to auto and the state keeps its natural top-aligned flow).
|
|
253
|
+
*/
|
|
254
|
+
min-height: 100%;
|
|
255
|
+
display: flex;
|
|
256
|
+
flex-direction: column;
|
|
257
|
+
align-items: center;
|
|
258
|
+
justify-content: center;
|
|
259
|
+
|
|
260
|
+
.reqore-icon {
|
|
261
|
+
opacity: 0.28;
|
|
262
|
+
}
|
|
263
|
+
h1,
|
|
264
|
+
h2,
|
|
265
|
+
h3,
|
|
266
|
+
h4,
|
|
267
|
+
h5,
|
|
268
|
+
h6 {
|
|
269
|
+
opacity: 0.4;
|
|
270
|
+
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.07);
|
|
271
|
+
}
|
|
272
|
+
`;
|
|
273
|
+
|
|
274
|
+
// System messages read as a centered muted note between cards; the nested rule
|
|
275
|
+
// flattens react-markdown's own <p> margins so it sits on one line.
|
|
276
|
+
const StyledSystemRow = styled.div`
|
|
277
|
+
display: flex;
|
|
278
|
+
align-items: center;
|
|
279
|
+
justify-content: center;
|
|
280
|
+
gap: 6px;
|
|
281
|
+
font-size: 11px;
|
|
282
|
+
opacity: 0.55;
|
|
283
|
+
text-align: center;
|
|
284
|
+
p {
|
|
285
|
+
margin: 0;
|
|
286
|
+
}
|
|
287
|
+
`;
|
|
288
|
+
|
|
289
|
+
interface IStyledMarkdownProps {
|
|
290
|
+
$color: string;
|
|
291
|
+
$emphasis: string;
|
|
292
|
+
$code: string;
|
|
293
|
+
$link: string;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// The message body, washed a step below the bright author name in the card's
|
|
297
|
+
// header so the two read as different things; in-body **bold** lifts back toward
|
|
298
|
+
// full strength as emphasis. Every colour derives from the active Reqore theme
|
|
299
|
+
// rather than a hardcoded hex, so the thread reads correctly on any host app.
|
|
300
|
+
const StyledMarkdown = styled.div<IStyledMarkdownProps>`
|
|
301
|
+
color: ${({ $color }) => $color};
|
|
302
|
+
word-break: break-word;
|
|
303
|
+
|
|
304
|
+
> *:first-child {
|
|
305
|
+
margin-top: 0;
|
|
306
|
+
}
|
|
307
|
+
> *:last-child {
|
|
308
|
+
margin-bottom: 0;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
p {
|
|
312
|
+
margin: 0 0 6px;
|
|
313
|
+
}
|
|
314
|
+
strong {
|
|
315
|
+
color: ${({ $emphasis }) => $emphasis};
|
|
316
|
+
font-weight: 600;
|
|
317
|
+
}
|
|
318
|
+
ul,
|
|
319
|
+
ol {
|
|
320
|
+
margin: 4px 0;
|
|
321
|
+
padding-left: 18px;
|
|
322
|
+
}
|
|
323
|
+
li {
|
|
324
|
+
margin: 2px 0;
|
|
325
|
+
}
|
|
326
|
+
code {
|
|
327
|
+
font-family: monospace;
|
|
328
|
+
background: ${({ $code }) => $code};
|
|
329
|
+
padding: 1px 5px;
|
|
330
|
+
border-radius: 4px;
|
|
331
|
+
}
|
|
332
|
+
pre {
|
|
333
|
+
margin: 6px 0;
|
|
334
|
+
padding: 8px 10px;
|
|
335
|
+
background: rgba(0, 0, 0, 0.35);
|
|
336
|
+
border-radius: 4px;
|
|
337
|
+
overflow-x: auto;
|
|
338
|
+
}
|
|
339
|
+
pre code {
|
|
340
|
+
background: none;
|
|
341
|
+
padding: 0;
|
|
342
|
+
white-space: pre;
|
|
343
|
+
}
|
|
344
|
+
a {
|
|
345
|
+
color: ${({ $link }) => $link};
|
|
346
|
+
text-decoration: none;
|
|
347
|
+
}
|
|
348
|
+
a:hover {
|
|
349
|
+
text-decoration: underline;
|
|
350
|
+
}
|
|
351
|
+
`;
|
|
352
|
+
|
|
353
|
+
/** A message body rendered as markdown, tinted from the active theme. */
|
|
354
|
+
const MessageBody = ({ children }: { children: string }) => {
|
|
355
|
+
const theme = useReqoreTheme();
|
|
356
|
+
const readable = getReadableColor(theme);
|
|
357
|
+
const link = theme.intents?.info ?? readable;
|
|
358
|
+
return (
|
|
359
|
+
<StyledMarkdown
|
|
360
|
+
$color={rgba(readable, 0.72)}
|
|
361
|
+
$emphasis={rgba(readable, 0.95)}
|
|
362
|
+
$code={rgba(readable, 0.1)}
|
|
363
|
+
$link={link}
|
|
364
|
+
>
|
|
365
|
+
<ReactMarkdown>{children}</ReactMarkdown>
|
|
366
|
+
</StyledMarkdown>
|
|
367
|
+
);
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
/** A file attachment as a download chip — the fallback for every non-image, and for
|
|
371
|
+
* images when no `fetchAttachmentUrl` is wired. */
|
|
372
|
+
const AttachmentChip = ({
|
|
373
|
+
attachment,
|
|
374
|
+
onDownloadAttachment,
|
|
375
|
+
}: {
|
|
376
|
+
attachment: ITicketThreadAttachment;
|
|
377
|
+
onDownloadAttachment?: (attachmentId: string, filename: string) => void;
|
|
378
|
+
}) => (
|
|
379
|
+
<ReqoreButton
|
|
380
|
+
icon='Download2Line'
|
|
381
|
+
size='small'
|
|
382
|
+
flat
|
|
383
|
+
badge={formatBytes(attachment.size_bytes)}
|
|
384
|
+
tooltip={`${attachment.content_type} · download`}
|
|
385
|
+
onClick={() => onDownloadAttachment?.(attachment.attachment_id, attachment.filename)}
|
|
386
|
+
>
|
|
387
|
+
{attachment.filename}
|
|
388
|
+
</ReqoreButton>
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
const StyledThumb = styled.button`
|
|
392
|
+
appearance: none;
|
|
393
|
+
padding: 0;
|
|
394
|
+
border: 1px solid ${rgba('#ffffff', 0.12)};
|
|
395
|
+
border-radius: 8px;
|
|
396
|
+
overflow: hidden;
|
|
397
|
+
background: ${rgba('#000000', 0.25)};
|
|
398
|
+
cursor: zoom-in;
|
|
399
|
+
line-height: 0;
|
|
400
|
+
display: block;
|
|
401
|
+
|
|
402
|
+
img {
|
|
403
|
+
display: block;
|
|
404
|
+
max-width: 240px;
|
|
405
|
+
max-height: 200px;
|
|
406
|
+
object-fit: cover;
|
|
407
|
+
}
|
|
408
|
+
`;
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* An image attachment as a thumbnail; clicking it opens the shared `ImageLightbox`
|
|
412
|
+
* (owned by `AttachmentList`, which knows the whole image set for paging). The bytes
|
|
413
|
+
* come from `fetchAttachmentUrl`, so until they arrive a skeleton holds the space, and
|
|
414
|
+
* if the fetch fails the attachment degrades to the same download chip as any other
|
|
415
|
+
* file rather than showing a broken image.
|
|
416
|
+
*/
|
|
417
|
+
const ImageThumb = ({
|
|
418
|
+
attachment,
|
|
419
|
+
fetchAttachmentUrl,
|
|
420
|
+
onOpen,
|
|
421
|
+
onDownloadAttachment,
|
|
422
|
+
}: {
|
|
423
|
+
attachment: ITicketThreadAttachment;
|
|
424
|
+
fetchAttachmentUrl: (attachmentId: string) => Promise<string>;
|
|
425
|
+
onOpen: () => void;
|
|
426
|
+
onDownloadAttachment?: (attachmentId: string, filename: string) => void;
|
|
427
|
+
}) => {
|
|
428
|
+
const [url, setUrl] = useState<string | null>(null);
|
|
429
|
+
const [failed, setFailed] = useState<boolean>(false);
|
|
430
|
+
|
|
431
|
+
useEffect(() => {
|
|
432
|
+
let alive = true;
|
|
433
|
+
fetchAttachmentUrl(attachment.attachment_id)
|
|
434
|
+
.then((resolved) => alive && setUrl(resolved))
|
|
435
|
+
.catch(() => alive && setFailed(true));
|
|
436
|
+
return () => {
|
|
437
|
+
alive = false;
|
|
438
|
+
};
|
|
439
|
+
}, [attachment.attachment_id]);
|
|
440
|
+
|
|
441
|
+
if (failed) {
|
|
442
|
+
return <AttachmentChip attachment={attachment} onDownloadAttachment={onDownloadAttachment} />;
|
|
443
|
+
}
|
|
444
|
+
if (!url) {
|
|
445
|
+
return <ReqoreSkeleton width='180px' height='120px' />;
|
|
446
|
+
}
|
|
447
|
+
return (
|
|
448
|
+
<StyledThumb type='button' title={`${attachment.filename} — click to enlarge`} onClick={onOpen}>
|
|
449
|
+
<img src={url} alt={attachment.filename} />
|
|
450
|
+
</StyledThumb>
|
|
451
|
+
);
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* A message's attachments, split into two rows: previewable images as a row of
|
|
456
|
+
* thumbnails, and everything else as a row of download chips beneath.
|
|
457
|
+
*
|
|
458
|
+
* They can't share one wrapping row — a thumbnail is ~120px tall and a chip ~26px, so
|
|
459
|
+
* mixed together the chips wrap raggedly around the image and the group reads as
|
|
460
|
+
* broken. Grouping by kind keeps each row uniform. Images only preview when a
|
|
461
|
+
* `fetchAttachmentUrl` is wired; without it they're files like any other and fall into
|
|
462
|
+
* the chip row.
|
|
463
|
+
*/
|
|
464
|
+
const AttachmentList = ({
|
|
465
|
+
attachments,
|
|
466
|
+
fetchAttachmentUrl,
|
|
467
|
+
onDownloadAttachment,
|
|
468
|
+
}: {
|
|
469
|
+
attachments: ITicketThreadAttachment[];
|
|
470
|
+
fetchAttachmentUrl?: (attachmentId: string) => Promise<string>;
|
|
471
|
+
onDownloadAttachment?: (attachmentId: string, filename: string) => void;
|
|
472
|
+
}) => {
|
|
473
|
+
const previewable = Boolean(fetchAttachmentUrl);
|
|
474
|
+
const images = previewable ? attachments.filter((a) => isImage(a.content_type)) : [];
|
|
475
|
+
const imageIds = new Set(images.map((a) => a.attachment_id));
|
|
476
|
+
const files = attachments.filter((a) => !imageIds.has(a.attachment_id));
|
|
477
|
+
// which image the shared lightbox is showing; null = closed
|
|
478
|
+
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
|
479
|
+
|
|
480
|
+
return (
|
|
481
|
+
<ReqoreControlGroup vertical gapSize='small' style={{ marginTop: 8 }}>
|
|
482
|
+
{images.length ? (
|
|
483
|
+
<ReqoreControlGroup wrap gapSize='small' verticalAlign='flex-start'>
|
|
484
|
+
{images.map((attachment, index) => (
|
|
485
|
+
<ImageThumb
|
|
486
|
+
key={attachment.attachment_id}
|
|
487
|
+
attachment={attachment}
|
|
488
|
+
fetchAttachmentUrl={fetchAttachmentUrl!}
|
|
489
|
+
onOpen={() => setOpenIndex(index)}
|
|
490
|
+
onDownloadAttachment={onDownloadAttachment}
|
|
491
|
+
/>
|
|
492
|
+
))}
|
|
493
|
+
</ReqoreControlGroup>
|
|
494
|
+
) : null}
|
|
495
|
+
{files.length ? (
|
|
496
|
+
<ReqoreControlGroup wrap gapSize='small'>
|
|
497
|
+
{files.map((attachment) => (
|
|
498
|
+
<AttachmentChip
|
|
499
|
+
key={attachment.attachment_id}
|
|
500
|
+
attachment={attachment}
|
|
501
|
+
onDownloadAttachment={onDownloadAttachment}
|
|
502
|
+
/>
|
|
503
|
+
))}
|
|
504
|
+
</ReqoreControlGroup>
|
|
505
|
+
) : null}
|
|
506
|
+
{openIndex !== null && fetchAttachmentUrl ? (
|
|
507
|
+
<ImageLightbox
|
|
508
|
+
images={images}
|
|
509
|
+
index={openIndex}
|
|
510
|
+
onIndex={setOpenIndex}
|
|
511
|
+
onClose={() => setOpenIndex(null)}
|
|
512
|
+
fetchAttachmentUrl={fetchAttachmentUrl}
|
|
513
|
+
onDownload={onDownloadAttachment}
|
|
514
|
+
/>
|
|
515
|
+
) : null}
|
|
516
|
+
</ReqoreControlGroup>
|
|
517
|
+
);
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
type TThreadSegment =
|
|
521
|
+
| { kind: 'system'; message: ITicketThreadMessage }
|
|
522
|
+
| { kind: 'bubbles'; messages: ITicketThreadMessage[] };
|
|
523
|
+
|
|
524
|
+
/** Split the thread into centred system lines and runs of consecutive non-system
|
|
525
|
+
* messages — each run becomes one `ReqoreBubbleGroup`, which is what collapses a
|
|
526
|
+
* same-side burst onto a single timestamp. */
|
|
527
|
+
const toSegments = (messages: ITicketThreadMessage[]): TThreadSegment[] => {
|
|
528
|
+
const segments: TThreadSegment[] = [];
|
|
529
|
+
|
|
530
|
+
for (const message of messages) {
|
|
531
|
+
if (message.author_type === 'system') {
|
|
532
|
+
segments.push({ kind: 'system', message });
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
const last = segments[segments.length - 1];
|
|
536
|
+
if (last?.kind === 'bubbles') {
|
|
537
|
+
last.messages.push(message);
|
|
538
|
+
} else {
|
|
539
|
+
segments.push({ kind: 'bubbles', messages: [message] });
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return segments;
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
export const TicketThread = ({
|
|
547
|
+
messages,
|
|
548
|
+
viewerRole = 'customer',
|
|
549
|
+
loading,
|
|
550
|
+
onDownloadAttachment,
|
|
551
|
+
fetchAttachmentUrl,
|
|
552
|
+
resolveInterfaceIcon,
|
|
553
|
+
onInterfaceClick,
|
|
554
|
+
ticketReferences,
|
|
555
|
+
}: ITicketThreadProps) => {
|
|
556
|
+
// Open scrolled to the newest message, and follow new arrivals down — a thread is read
|
|
557
|
+
// from the bottom, like any chat. Keyed on the message COUNT so it fires on open and when
|
|
558
|
+
// a reply lands, not on every re-render (which would fight a user who has scrolled up to
|
|
559
|
+
// read history). Scrolls the nearest scrollable ancestor (the drawer body) straight to its
|
|
560
|
+
// end rather than scrollIntoView on a sentinel — scrollIntoView also drags any outer scroll
|
|
561
|
+
// container around and stops one bottom-padding short of the real end.
|
|
562
|
+
const threadRef = useRef<HTMLDivElement>(null);
|
|
563
|
+
useEffect(() => {
|
|
564
|
+
// Find the thread's OWN scroll container — the nearest ancestor that actually scrolls
|
|
565
|
+
// (overflow-y auto/scroll), not merely the first one that currently overflows. A short
|
|
566
|
+
// thread's container fits its content, so a "does it overflow?" test would walk straight
|
|
567
|
+
// past it and scroll the whole page/panel to the bottom instead.
|
|
568
|
+
let scroller = threadRef.current?.parentElement ?? null;
|
|
569
|
+
while (scroller) {
|
|
570
|
+
const overflowY = getComputedStyle(scroller).overflowY;
|
|
571
|
+
if (overflowY === 'auto' || overflowY === 'scroll') {
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
scroller = scroller.parentElement;
|
|
575
|
+
}
|
|
576
|
+
if (scroller) {
|
|
577
|
+
scroller.scrollTop = scroller.scrollHeight;
|
|
578
|
+
}
|
|
579
|
+
}, [messages.length]);
|
|
580
|
+
|
|
581
|
+
// Segment the flat message list into system rows + author-grouped bubble runs once per
|
|
582
|
+
// message change — not on every re-render (scroll, hover and resize all re-render this).
|
|
583
|
+
const segments = useMemo(() => toSegments(messages), [messages]);
|
|
584
|
+
|
|
585
|
+
// The opening (description) message — the ticket's own references echo on it, deduped
|
|
586
|
+
// against any refs that message carries itself, so the intent still has ONE authoritative
|
|
587
|
+
// home (the References tab) but the conversation no longer reads as reference-free.
|
|
588
|
+
const openingMessageId = useMemo(
|
|
589
|
+
() => messages.find((m) => m.author_type !== 'system')?.message_id,
|
|
590
|
+
[messages]
|
|
591
|
+
);
|
|
592
|
+
const openingRefs = useMemo(() => {
|
|
593
|
+
if (!ticketReferences?.length) {
|
|
594
|
+
return undefined;
|
|
595
|
+
}
|
|
596
|
+
const opening = messages.find((m) => m.message_id === openingMessageId);
|
|
597
|
+
const seen = new Set<string>();
|
|
598
|
+
return [...ticketReferences, ...(opening?.referenced_interfaces ?? [])].filter((ref) => {
|
|
599
|
+
const key = `${ref.interface_kind}:${ref.interface_name}`;
|
|
600
|
+
if (seen.has(key)) {
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
seen.add(key);
|
|
604
|
+
return true;
|
|
605
|
+
});
|
|
606
|
+
}, [ticketReferences, messages, openingMessageId]);
|
|
607
|
+
|
|
608
|
+
const refsFor = (message: ITicketThreadMessage): IInterfaceReference[] | undefined =>
|
|
609
|
+
message.message_id === openingMessageId && openingRefs
|
|
610
|
+
? openingRefs
|
|
611
|
+
: message.referenced_interfaces;
|
|
612
|
+
|
|
613
|
+
// Only the FIRST load gets the skeleton. A refetch while messages are already on
|
|
614
|
+
// screen must leave them there: every send triggers one (and, where the thread is
|
|
615
|
+
// live, so does the other party sending), and blanking a conversation you are
|
|
616
|
+
// reading — replacing it with pulsing placeholders — is the single worst moment to
|
|
617
|
+
// do it. The new message simply appears when the refetch resolves.
|
|
618
|
+
if (loading && !messages.length) {
|
|
619
|
+
return <ConversationSkeleton />;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (!messages.length) {
|
|
623
|
+
return (
|
|
624
|
+
<StyledEmpty>
|
|
625
|
+
<ReqoreEmptyState icon='Chat3Line' title='No messages yet' flat transparent />
|
|
626
|
+
</StyledEmpty>
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return (
|
|
631
|
+
<StyledThread ref={threadRef}>
|
|
632
|
+
{segments.map((segment, index) =>
|
|
633
|
+
segment.kind === 'system' ? (
|
|
634
|
+
<StyledSystemRow
|
|
635
|
+
key={segment.message.message_id}
|
|
636
|
+
id={ticketMessageDomId(segment.message.message_id)}
|
|
637
|
+
>
|
|
638
|
+
<ReqoreIcon icon='InformationLine' size='tiny' />
|
|
639
|
+
<ReactMarkdown>{segment.message.body}</ReactMarkdown>
|
|
640
|
+
</StyledSystemRow>
|
|
641
|
+
) : (
|
|
642
|
+
<ReqoreBubbleGroup key={`group-${index}`}>
|
|
643
|
+
{segment.messages.map((message) => (
|
|
644
|
+
<ReqoreBubble
|
|
645
|
+
key={message.message_id}
|
|
646
|
+
// anchor for "show in chat"; ReqoreBubble forwards unknown props to
|
|
647
|
+
// its DOM node, so this lands on the rendered element
|
|
648
|
+
id={ticketMessageDomId(message.message_id)}
|
|
649
|
+
size='small'
|
|
650
|
+
maxWidth='76%'
|
|
651
|
+
align={message.author_type === viewerRole ? 'right' : 'left'}
|
|
652
|
+
avatar={{ icon: authorIcon(message) }}
|
|
653
|
+
label={authorLabel(message, viewerRole)}
|
|
654
|
+
timestamp={message.created ? <ReqoreTimeAgo time={message.created} /> : undefined}
|
|
655
|
+
// Own messages read as info-blue, the other party's as the purple
|
|
656
|
+
// accent — so the two sides of the conversation are told apart by
|
|
657
|
+
// colour as well as by side, and "which of these did I write?" is
|
|
658
|
+
// answerable at a glance. Deliberately keyed off `viewerRole`, so it
|
|
659
|
+
// stays correct on the staff surface too (staff replies are the blue
|
|
660
|
+
// ones there). Internal notes keep their warning intent and are not
|
|
661
|
+
// re-tinted — they are neither side of the conversation.
|
|
662
|
+
intent={
|
|
663
|
+
message.internal
|
|
664
|
+
? 'warning'
|
|
665
|
+
: message.author_type === viewerRole
|
|
666
|
+
? 'info'
|
|
667
|
+
: undefined
|
|
668
|
+
}
|
|
669
|
+
customTheme={
|
|
670
|
+
message.internal || message.author_type === viewerRole ? undefined : ACCENT
|
|
671
|
+
}
|
|
672
|
+
flat={false}
|
|
673
|
+
minimal
|
|
674
|
+
>
|
|
675
|
+
<MessageBody>{message.body}</MessageBody>
|
|
676
|
+
{message.attachments?.length ? (
|
|
677
|
+
<AttachmentList
|
|
678
|
+
attachments={message.attachments}
|
|
679
|
+
fetchAttachmentUrl={fetchAttachmentUrl}
|
|
680
|
+
onDownloadAttachment={onDownloadAttachment}
|
|
681
|
+
/>
|
|
682
|
+
) : null}
|
|
683
|
+
{refsFor(message)?.length ? (
|
|
684
|
+
<div style={{ marginTop: 8 }}>
|
|
685
|
+
<InterfaceReferenceTags
|
|
686
|
+
size='tiny'
|
|
687
|
+
customTheme={ACCENT}
|
|
688
|
+
references={refsFor(message)}
|
|
689
|
+
resolveInterfaceIcon={resolveInterfaceIcon}
|
|
690
|
+
onInterfaceClick={onInterfaceClick}
|
|
691
|
+
/>
|
|
692
|
+
</div>
|
|
693
|
+
) : null}
|
|
694
|
+
</ReqoreBubble>
|
|
695
|
+
))}
|
|
696
|
+
</ReqoreBubbleGroup>
|
|
697
|
+
)
|
|
698
|
+
)}
|
|
699
|
+
<ReqoreP size='tiny' effect={{ opacity: 0.5, textAlign: 'center' }}>
|
|
700
|
+
{messages.length} message{messages.length === 1 ? '' : 's'}
|
|
701
|
+
</ReqoreP>
|
|
702
|
+
</StyledThread>
|
|
703
|
+
);
|
|
704
|
+
};
|