@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,886 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ReqoreButton,
|
|
3
|
+
ReqoreColumns,
|
|
4
|
+
ReqoreControlGroup,
|
|
5
|
+
ReqoreDropdown,
|
|
6
|
+
ReqoreEmptyState,
|
|
7
|
+
ReqoreEntityRow,
|
|
8
|
+
ReqorePanel,
|
|
9
|
+
ReqoreSpan,
|
|
10
|
+
ReqoreSpinner,
|
|
11
|
+
ReqoreTextEffect,
|
|
12
|
+
} from '@qoretechnologies/reqore';
|
|
13
|
+
import { IReqoreIconName } from '@qoretechnologies/reqore/dist/types/icons';
|
|
14
|
+
import { TReqoreEffectColor } from '@qoretechnologies/reqore/dist/components/Effect';
|
|
15
|
+
import type { IReqoreEntityRowAction } from '@qoretechnologies/reqore/dist/components/EntityRow';
|
|
16
|
+
import type { IReqorePanelSubAction } from '@qoretechnologies/reqore/dist/components/Panel';
|
|
17
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
18
|
+
import timeago from 'epoch-timeago';
|
|
19
|
+
import { SearchFilterBar } from '../searchFilterBar';
|
|
20
|
+
import styled from 'styled-components';
|
|
21
|
+
import { formatBytes, isImage } from '../../helpers/common';
|
|
22
|
+
import { ImageLightbox } from '../imageLightbox';
|
|
23
|
+
import {
|
|
24
|
+
defaultInterfaceIcon,
|
|
25
|
+
IInterfaceReference,
|
|
26
|
+
TTicketViewerRole,
|
|
27
|
+
} from '../supportTicket/meta';
|
|
28
|
+
import type { ITicketThreadAttachment, ITicketThreadMessage } from '../ticketThread/TicketThread';
|
|
29
|
+
|
|
30
|
+
/*
|
|
31
|
+
* TicketReferences — the consolidated "everything attached to or linked from a
|
|
32
|
+
* ticket" surface. It gathers, across the ticket description and every reply:
|
|
33
|
+
* • Screenshots — image attachments as a thumbnail grid → a zoom/paging lightbox.
|
|
34
|
+
* First: the visual evidence is what a reader scans for.
|
|
35
|
+
* • Linked interfaces — the {kind, name} snapshots as rows (kind as metadata),
|
|
36
|
+
* sorted by kind then name, matching the file rows below.
|
|
37
|
+
* • Files — every other attachment as a type-iconed row with provenance + download.
|
|
38
|
+
* None of the mainstream desks (Zendesk/Front/Intercom/…) ship this consolidated
|
|
39
|
+
* view, so it's the drawer's differentiator. Rendered as a tab in the staff drawer
|
|
40
|
+
* and a panel in the customer modal; only the data + handlers differ by viewer.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/** Attachment metadata as returned by the list endpoint — carries the provenance
|
|
44
|
+
* (uploader + timestamp + source message) the message-embedded metadata omits. */
|
|
45
|
+
export interface ITicketReferenceAttachment extends ITicketThreadAttachment {
|
|
46
|
+
/** the reply this file was attached to; null/absent = ticket-level */
|
|
47
|
+
message_id?: string | null;
|
|
48
|
+
/** the uploader, resolved + masked like the thread's message author (a staff
|
|
49
|
+
* uploader reads as "Qorus Support" on the customer plane, never the raw identity) */
|
|
50
|
+
uploaded_by?: string;
|
|
51
|
+
/** the kind of party that uploaded the file — lets the viewer label it */
|
|
52
|
+
uploader_type?: 'customer' | 'staff';
|
|
53
|
+
/** ISO timestamp the file was attached */
|
|
54
|
+
created?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ITicketReferencesProps {
|
|
58
|
+
/** ticket-level interface references (from `ticket.referenced_interfaces`) */
|
|
59
|
+
references?: IInterfaceReference[];
|
|
60
|
+
/** the thread — source of message-level references and, as a fallback, attachments */
|
|
61
|
+
messages?: ITicketThreadMessage[];
|
|
62
|
+
/** the ticket's attachments from the list endpoint; preferred over the message-embedded
|
|
63
|
+
* ones because it carries provenance and any ticket-level files. Falls back to `messages`. */
|
|
64
|
+
attachments?: ITicketReferenceAttachment[];
|
|
65
|
+
viewerRole?: TTicketViewerRole;
|
|
66
|
+
/** fetch an attachment's bytes as an object URL, for inline image preview + the lightbox.
|
|
67
|
+
* Omitted ⇒ screenshots show as download tiles instead of thumbnails. */
|
|
68
|
+
fetchAttachmentUrl?: (attachmentId: string) => Promise<string>;
|
|
69
|
+
/** trigger a browser download of an attachment */
|
|
70
|
+
onDownloadAttachment?: (attachmentId: string, filename: string) => void;
|
|
71
|
+
/** open a referenced interface; reference chips are static (informational) when omitted */
|
|
72
|
+
onInterfaceClick?: (reference: IInterfaceReference) => void;
|
|
73
|
+
/** Reveal the message a reference or file came from, in the conversation.
|
|
74
|
+
* Omitted ⇒ the action is not offered. It is also omitted per-row for anything filed
|
|
75
|
+
* against the ticket itself rather than a reply, which has no message to reveal. */
|
|
76
|
+
onShowInChat?: (messageId: string) => void;
|
|
77
|
+
/** resolve a per-kind icon for a referenced interface (the IDE passes its own vocabulary) */
|
|
78
|
+
resolveInterfaceIcon?: (kind: string) => IReqoreIconName;
|
|
79
|
+
loading?: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// helpers
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// `epoch-timeago` — the same library reqore's `ReqoreTimeAgo` and the IDE's ticket
|
|
86
|
+
// list use. This is a string context (a metadata run), so a helper rather than the
|
|
87
|
+
// component; it stays consistent with them instead of a bespoke Intl reimplementation.
|
|
88
|
+
const timeAgo = (iso?: string): string => {
|
|
89
|
+
if (!iso) {
|
|
90
|
+
return '';
|
|
91
|
+
}
|
|
92
|
+
const ms = Date.parse(iso);
|
|
93
|
+
return Number.isNaN(ms) ? '' : timeago(ms);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
interface IFileType {
|
|
97
|
+
icon: IReqoreIconName;
|
|
98
|
+
color: TReqoreEffectColor;
|
|
99
|
+
label: string;
|
|
100
|
+
}
|
|
101
|
+
// MIME → a type icon (glyph) + a category tint, Fluent-style: colour distinguishes
|
|
102
|
+
// the file family, the glyph its shape. Tints stay moderate for a dark surface.
|
|
103
|
+
const fileType = (contentType: string): IFileType => {
|
|
104
|
+
const ct = contentType.toLowerCase();
|
|
105
|
+
if (ct === 'application/pdf') {
|
|
106
|
+
return { icon: 'FilePdf2Line', color: '#f56059', label: 'PDF' };
|
|
107
|
+
}
|
|
108
|
+
if (ct.includes('zip') || ct.includes('gzip')) {
|
|
109
|
+
return { icon: 'FileZipLine', color: '#f2a63c', label: 'Archive' };
|
|
110
|
+
}
|
|
111
|
+
if (ct === 'text/csv') {
|
|
112
|
+
return { icon: 'FileExcel2Line', color: '#46c96f', label: 'CSV' };
|
|
113
|
+
}
|
|
114
|
+
if (ct.includes('json') || ct.includes('yaml') || ct.includes('xml')) {
|
|
115
|
+
return { icon: 'CodeSLine', color: '#55a0ff', label: 'Data' };
|
|
116
|
+
}
|
|
117
|
+
if (ct.includes('markdown')) {
|
|
118
|
+
return { icon: 'MarkdownLine', color: '#55a0ff', label: 'Markdown' };
|
|
119
|
+
}
|
|
120
|
+
if (ct === 'text/x-log') {
|
|
121
|
+
return { icon: 'FileList2Line', color: '#8b8a97', label: 'Log' };
|
|
122
|
+
}
|
|
123
|
+
if (ct.startsWith('text/')) {
|
|
124
|
+
return { icon: 'FileTextLine', color: '#8b8a97', label: 'Text' };
|
|
125
|
+
}
|
|
126
|
+
return { icon: 'FileLine', color: '#8b8a97', label: 'File' };
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// A coarse file family for the type filter. Images are their own section, so this
|
|
130
|
+
// only buckets the non-image "Files"; it mirrors fileType's grouping.
|
|
131
|
+
type TFileCategory = 'documents' | 'data' | 'logs' | 'archives';
|
|
132
|
+
const fileCategory = (contentType: string): TFileCategory => {
|
|
133
|
+
const ct = contentType.toLowerCase();
|
|
134
|
+
if (ct.includes('zip') || ct.includes('gzip')) {
|
|
135
|
+
return 'archives';
|
|
136
|
+
}
|
|
137
|
+
if (ct === 'text/x-log') {
|
|
138
|
+
return 'logs';
|
|
139
|
+
}
|
|
140
|
+
if (ct === 'text/csv' || ct.includes('json') || ct.includes('yaml') || ct.includes('xml')) {
|
|
141
|
+
return 'data';
|
|
142
|
+
}
|
|
143
|
+
return 'documents';
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// The type filter: 'all' plus one option per section / file-family. Empty families
|
|
147
|
+
// are hidden from the menu (a ticket with no archives never offers "Archives").
|
|
148
|
+
type TRefFilter = 'all' | 'links' | 'screenshots' | TFileCategory;
|
|
149
|
+
type TRefSort = 'newest' | 'oldest' | 'name' | 'size';
|
|
150
|
+
|
|
151
|
+
const FILTER_META: Record<TRefFilter, { label: string; icon: IReqoreIconName }> = {
|
|
152
|
+
all: { label: 'All types', icon: 'Apps2Line' },
|
|
153
|
+
links: { label: 'Linked interfaces', icon: 'LinksLine' },
|
|
154
|
+
screenshots: { label: 'Screenshots', icon: 'ImageLine' },
|
|
155
|
+
documents: { label: 'Documents', icon: 'FileTextLine' },
|
|
156
|
+
data: { label: 'Data', icon: 'CodeSLine' },
|
|
157
|
+
logs: { label: 'Logs', icon: 'FileList2Line' },
|
|
158
|
+
archives: { label: 'Archives', icon: 'FileZipLine' },
|
|
159
|
+
};
|
|
160
|
+
// the four filter keys that live inside the "Files" section
|
|
161
|
+
const FILE_FILTERS: TFileCategory[] = ['documents', 'data', 'logs', 'archives'];
|
|
162
|
+
// menu order mirrors the section order on screen: screenshots, links, then files
|
|
163
|
+
const FILTER_ORDER: TRefFilter[] = ['all', 'screenshots', 'links', ...FILE_FILTERS];
|
|
164
|
+
|
|
165
|
+
const SORT_META: Record<TRefSort, { label: string; icon: IReqoreIconName }> = {
|
|
166
|
+
newest: { label: 'Newest first', icon: 'SortDesc' },
|
|
167
|
+
oldest: { label: 'Oldest first', icon: 'SortAsc' },
|
|
168
|
+
name: { label: 'Name (A–Z)', icon: 'SortAlphabetAsc' },
|
|
169
|
+
size: { label: 'Largest first', icon: 'ScalesLine' },
|
|
170
|
+
};
|
|
171
|
+
const SORT_ORDER: TRefSort[] = ['newest', 'oldest', 'name', 'size'];
|
|
172
|
+
|
|
173
|
+
const createdMs = (a: ITicketReferenceAttachment): number =>
|
|
174
|
+
a.created ? new Date(a.created).getTime() || 0 : 0;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A reference plus where it was cited.
|
|
178
|
+
*
|
|
179
|
+
* `message_id` is absent for a ticket-level reference — those are filed against the
|
|
180
|
+
* ticket itself (`message_id IS NULL` server-side), so there is no message to reveal
|
|
181
|
+
* and "show in chat" must not be offered for them.
|
|
182
|
+
*/
|
|
183
|
+
export interface IResolvedInterfaceReference extends IInterfaceReference {
|
|
184
|
+
message_id?: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// dedupe a mixed run of references (ticket + every message) by identity — the same
|
|
188
|
+
// interface referenced ticket-level and in a reply is one entry, not two.
|
|
189
|
+
const dedupeRefs = (refs: IResolvedInterfaceReference[]): IResolvedInterfaceReference[] => {
|
|
190
|
+
const byKey = new Map<string, IResolvedInterfaceReference>();
|
|
191
|
+
for (const r of refs) {
|
|
192
|
+
const key = `${r.interface_kind}:${r.interface_name}`;
|
|
193
|
+
const kept = byKey.get(key);
|
|
194
|
+
if (!kept) {
|
|
195
|
+
byKey.set(key, r);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
// The ticket-level copy is merged first and carries no message. When the same
|
|
199
|
+
// interface also appears in a reply, adopt that linkage instead of discarding it —
|
|
200
|
+
// otherwise an interface that demonstrably IS in the thread offers no way to jump
|
|
201
|
+
// to it, purely because it happened to be cited on the ticket as well.
|
|
202
|
+
if (!kept.message_id && r.message_id) {
|
|
203
|
+
byKey.set(key, { ...kept, message_id: r.message_id });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return Array.from(byKey.values());
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const META_EFFECT = { textSize: 'small', opacity: 0.55 } as const;
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// image URLs — fetch every screenshot's bytes once as an object URL, cached, and
|
|
213
|
+
// revoked on unmount / when the set changes. Shared by the grid and the lightbox.
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
const useImageUrls = (
|
|
216
|
+
ids: string[],
|
|
217
|
+
fetchAttachmentUrl?: (attachmentId: string) => Promise<string>
|
|
218
|
+
): Record<string, string | null> => {
|
|
219
|
+
const [urls, setUrls] = useState<Record<string, string | null>>({});
|
|
220
|
+
const key = ids.join(',');
|
|
221
|
+
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
if (!ids.length) {
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
// No fetcher wired means there is no preview to wait for — resolve every id
|
|
227
|
+
// straight to the "no preview" state (null) so tiles fall through to the
|
|
228
|
+
// filename / download fallback instead of a forever-pending "…" (undefined
|
|
229
|
+
// is reserved for a wired fetcher's genuinely in-flight requests).
|
|
230
|
+
if (!fetchAttachmentUrl) {
|
|
231
|
+
setUrls(Object.fromEntries(ids.map((id) => [id, null])));
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
let active = true;
|
|
235
|
+
const created: string[] = [];
|
|
236
|
+
Promise.allSettled(ids.map((id) => fetchAttachmentUrl(id).then((url) => ({ id, url })))).then(
|
|
237
|
+
(results) => {
|
|
238
|
+
if (!active) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const next: Record<string, string | null> = {};
|
|
242
|
+
results.forEach((r, i) => {
|
|
243
|
+
if (r.status === 'fulfilled') {
|
|
244
|
+
next[r.value.id] = r.value.url;
|
|
245
|
+
created.push(r.value.url);
|
|
246
|
+
} else {
|
|
247
|
+
next[ids[i]] = null;
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
setUrls(next);
|
|
251
|
+
}
|
|
252
|
+
);
|
|
253
|
+
return () => {
|
|
254
|
+
active = false;
|
|
255
|
+
created.forEach((url) => URL.revokeObjectURL(url));
|
|
256
|
+
};
|
|
257
|
+
// `key` is the stable signature of `ids`; re-run when the image set changes.
|
|
258
|
+
}, [key, fetchAttachmentUrl]);
|
|
259
|
+
|
|
260
|
+
return urls;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// styled bits (reqore covers the rest)
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
/*
|
|
267
|
+
* The search/filter/sort strip reads as part of the list it filters, not as a stray
|
|
268
|
+
* row above it: it gets its own tinted, bordered surface and sticks to the top of the
|
|
269
|
+
* scroll container. Free-floating, it was easy to lose track of — and scrolling the
|
|
270
|
+
* sections took the search box away with them, which is exactly when you want it.
|
|
271
|
+
*/
|
|
272
|
+
/*
|
|
273
|
+
* A screenshot tile: the image is the button, and the overflow menu is a sibling
|
|
274
|
+
* layered over it — a <button> cannot legally contain another button, so the menu
|
|
275
|
+
* cannot live inside Thumb. It stays hidden until the tile is hovered or something
|
|
276
|
+
* inside it takes focus, so a wall of screenshots still reads as images rather than
|
|
277
|
+
* a grid of controls. Hidden by opacity rather than display, so it remains reachable
|
|
278
|
+
* by keyboard.
|
|
279
|
+
*/
|
|
280
|
+
const ThumbTile = styled.div`
|
|
281
|
+
position: relative;
|
|
282
|
+
/*
|
|
283
|
+
* A single-cell grid, not flex: a grid child stretches to fill by default, so the
|
|
284
|
+
* thumbnail spans the whole tile and the overlay's top-right corner IS the image's
|
|
285
|
+
* top-right corner. Under flex the button sized itself from its aspect-ratio, left a
|
|
286
|
+
* strip of empty tile beside it, and the menu — anchored to the tile — landed in that
|
|
287
|
+
* strip instead of over the image.
|
|
288
|
+
*/
|
|
289
|
+
display: grid;
|
|
290
|
+
|
|
291
|
+
> .screenshot-menu {
|
|
292
|
+
position: absolute;
|
|
293
|
+
top: 5px;
|
|
294
|
+
right: 5px;
|
|
295
|
+
opacity: 0;
|
|
296
|
+
transition: opacity 0.12s ease-out;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
&:hover > .screenshot-menu,
|
|
300
|
+
&:focus-within > .screenshot-menu {
|
|
301
|
+
opacity: 1;
|
|
302
|
+
}
|
|
303
|
+
`;
|
|
304
|
+
|
|
305
|
+
const Thumb = styled.button`
|
|
306
|
+
appearance: none;
|
|
307
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
308
|
+
border-radius: 8px;
|
|
309
|
+
overflow: hidden;
|
|
310
|
+
padding: 0;
|
|
311
|
+
cursor: pointer;
|
|
312
|
+
aspect-ratio: 1 / 1;
|
|
313
|
+
background: rgba(255, 255, 255, 0.03);
|
|
314
|
+
display: flex;
|
|
315
|
+
align-items: center;
|
|
316
|
+
justify-content: center;
|
|
317
|
+
position: relative;
|
|
318
|
+
transition: border-color 0.15s;
|
|
319
|
+
&:hover {
|
|
320
|
+
border-color: rgba(169, 116, 255, 0.6);
|
|
321
|
+
}
|
|
322
|
+
&:focus-visible {
|
|
323
|
+
outline: 2px solid #a974ff;
|
|
324
|
+
outline-offset: 2px;
|
|
325
|
+
}
|
|
326
|
+
img {
|
|
327
|
+
width: 100%;
|
|
328
|
+
height: 100%;
|
|
329
|
+
object-fit: cover;
|
|
330
|
+
display: block;
|
|
331
|
+
}
|
|
332
|
+
`;
|
|
333
|
+
const ThumbCaption = styled.span`
|
|
334
|
+
position: absolute;
|
|
335
|
+
left: 0;
|
|
336
|
+
right: 0;
|
|
337
|
+
bottom: 0;
|
|
338
|
+
padding: 4px 6px;
|
|
339
|
+
font-size: 10px;
|
|
340
|
+
color: #fff;
|
|
341
|
+
background: linear-gradient(0deg, rgba(0, 0, 0, 0.75), transparent);
|
|
342
|
+
white-space: nowrap;
|
|
343
|
+
overflow: hidden;
|
|
344
|
+
text-overflow: ellipsis;
|
|
345
|
+
text-align: left;
|
|
346
|
+
`;
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// screenshot thumbnail
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
const Screenshot = ({
|
|
351
|
+
att,
|
|
352
|
+
url,
|
|
353
|
+
onOpen,
|
|
354
|
+
menu,
|
|
355
|
+
}: {
|
|
356
|
+
att: ITicketReferenceAttachment;
|
|
357
|
+
url: string | null | undefined;
|
|
358
|
+
onOpen: () => void;
|
|
359
|
+
menu?: IReqorePanelSubAction[];
|
|
360
|
+
}) => (
|
|
361
|
+
<ThumbTile>
|
|
362
|
+
<Thumb type='button' title={att.filename} onClick={onOpen}>
|
|
363
|
+
{url ?
|
|
364
|
+
<img src={url} alt={att.filename} />
|
|
365
|
+
: <ReqoreSpan effect={{ opacity: 0.5, textSize: 'small' }}>
|
|
366
|
+
{url === null ? att.filename : '…'}
|
|
367
|
+
</ReqoreSpan>
|
|
368
|
+
}
|
|
369
|
+
<ThumbCaption>{att.filename}</ThumbCaption>
|
|
370
|
+
</Thumb>
|
|
371
|
+
{/* One action doesn't earn a menu. With a single entry the overlay IS that
|
|
372
|
+
action, so a tile and a file row offer the same thing the same way instead of
|
|
373
|
+
hiding a download behind a caret on one surface and not the other. */}
|
|
374
|
+
{menu?.length === 1 ?
|
|
375
|
+
<ReqoreButton
|
|
376
|
+
className='screenshot-menu'
|
|
377
|
+
icon={menu[0].icon}
|
|
378
|
+
tooltip={menu[0].label as string}
|
|
379
|
+
size='small'
|
|
380
|
+
flat
|
|
381
|
+
minimal
|
|
382
|
+
fixed
|
|
383
|
+
onClick={(event: React.MouseEvent<HTMLElement>) => {
|
|
384
|
+
event.stopPropagation();
|
|
385
|
+
menu[0].onClick?.(menu[0]);
|
|
386
|
+
}}
|
|
387
|
+
/>
|
|
388
|
+
: menu?.length ?
|
|
389
|
+
<ReqoreDropdown
|
|
390
|
+
className='screenshot-menu'
|
|
391
|
+
icon='More2Line'
|
|
392
|
+
tooltip='More actions'
|
|
393
|
+
size='small'
|
|
394
|
+
flat
|
|
395
|
+
minimal
|
|
396
|
+
fixed
|
|
397
|
+
useTargetWidth={false}
|
|
398
|
+
items={menu}
|
|
399
|
+
onClick={(event: React.MouseEvent<HTMLElement>) => event.stopPropagation()}
|
|
400
|
+
/>
|
|
401
|
+
: null}
|
|
402
|
+
</ThumbTile>
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
// main
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
export const TicketReferences = ({
|
|
409
|
+
references = [],
|
|
410
|
+
messages = [],
|
|
411
|
+
attachments,
|
|
412
|
+
viewerRole,
|
|
413
|
+
fetchAttachmentUrl,
|
|
414
|
+
onDownloadAttachment,
|
|
415
|
+
onInterfaceClick,
|
|
416
|
+
onShowInChat,
|
|
417
|
+
resolveInterfaceIcon,
|
|
418
|
+
loading,
|
|
419
|
+
}: ITicketReferencesProps) => {
|
|
420
|
+
// references: ticket-level + every message's, deduped. Rendered as rows carrying
|
|
421
|
+
// their kind as metadata — the same treatment as the files below — so the tab
|
|
422
|
+
// reads as one consistent list rather than chips above rows.
|
|
423
|
+
const allRefs = useMemo(
|
|
424
|
+
() =>
|
|
425
|
+
dedupeRefs([
|
|
426
|
+
// ticket-level first (no message), then each message's own citations tagged
|
|
427
|
+
// with the message they came from
|
|
428
|
+
...references,
|
|
429
|
+
...messages.flatMap((m) =>
|
|
430
|
+
(m.referenced_interfaces ?? []).map((r) => ({ ...r, message_id: m.message_id }))
|
|
431
|
+
),
|
|
432
|
+
]),
|
|
433
|
+
[references, messages]
|
|
434
|
+
);
|
|
435
|
+
const resolveIcon = resolveInterfaceIcon ?? defaultInterfaceIcon;
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The trailing controls for a row: its primary action, plus a "three dots" menu when
|
|
439
|
+
* there is anything else to offer. Built in one place so the interface rows and the
|
|
440
|
+
* file rows can't drift into different affordances.
|
|
441
|
+
*
|
|
442
|
+
* The menu is omitted entirely when it would be empty — a row filed against the
|
|
443
|
+
* ticket rather than a reply has no message to reveal, and an always-present menu
|
|
444
|
+
* that is sometimes empty is worse than no menu.
|
|
445
|
+
*/
|
|
446
|
+
/**
|
|
447
|
+
* The reveal action. One definition, used by the reference rows, the file rows and
|
|
448
|
+
* the screenshot tiles.
|
|
449
|
+
*
|
|
450
|
+
* Present on every row, but DISABLED for anything filed against the ticket itself
|
|
451
|
+
* rather than a reply — those have no message to jump to. Showing the menu on some
|
|
452
|
+
* rows and not others read as breakage rather than as a distinction, and the
|
|
453
|
+
* disabled entry says why instead of leaving the reader to infer it.
|
|
454
|
+
*/
|
|
455
|
+
const showInChatItem = (messageId?: string | null): IReqorePanelSubAction[] => {
|
|
456
|
+
if (!onShowInChat) {
|
|
457
|
+
return [];
|
|
458
|
+
}
|
|
459
|
+
return messageId ?
|
|
460
|
+
[{ label: 'Show in chat', icon: 'Chat1Line', onClick: () => onShowInChat(messageId) }]
|
|
461
|
+
: [
|
|
462
|
+
{
|
|
463
|
+
label: 'Show in chat',
|
|
464
|
+
icon: 'Chat1Line',
|
|
465
|
+
disabled: true,
|
|
466
|
+
description: 'Filed on the ticket, not in a reply',
|
|
467
|
+
},
|
|
468
|
+
];
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const rowActions = (
|
|
472
|
+
primary: IReqoreEntityRowAction | undefined,
|
|
473
|
+
messageId?: string | null
|
|
474
|
+
): IReqoreEntityRowAction[] => {
|
|
475
|
+
const menu = showInChatItem(messageId);
|
|
476
|
+
|
|
477
|
+
return [
|
|
478
|
+
...(primary ? [primary] : []),
|
|
479
|
+
...(menu.length ?
|
|
480
|
+
[
|
|
481
|
+
{
|
|
482
|
+
icon: 'More2Line' as IReqoreIconName,
|
|
483
|
+
tooltip: 'More actions',
|
|
484
|
+
flat: true,
|
|
485
|
+
minimal: true,
|
|
486
|
+
actions: menu,
|
|
487
|
+
},
|
|
488
|
+
]
|
|
489
|
+
: []),
|
|
490
|
+
];
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
// attachments: the list-endpoint set (with provenance) or, failing that, the
|
|
494
|
+
// ones embedded per message (deriving provenance from the carrying message)
|
|
495
|
+
const allAttachments = useMemo<ITicketReferenceAttachment[]>(() => {
|
|
496
|
+
const source: ITicketReferenceAttachment[] =
|
|
497
|
+
attachments ??
|
|
498
|
+
messages.flatMap((m) =>
|
|
499
|
+
(m.attachments ?? []).map((a) => ({
|
|
500
|
+
...a,
|
|
501
|
+
message_id: m.message_id,
|
|
502
|
+
uploaded_by: m.author_id,
|
|
503
|
+
created: m.created,
|
|
504
|
+
}))
|
|
505
|
+
);
|
|
506
|
+
const seen = new Set<string>();
|
|
507
|
+
return source.filter((a) => {
|
|
508
|
+
if (seen.has(a.attachment_id)) {
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
seen.add(a.attachment_id);
|
|
512
|
+
return true;
|
|
513
|
+
});
|
|
514
|
+
}, [attachments, messages]);
|
|
515
|
+
|
|
516
|
+
const images = useMemo(
|
|
517
|
+
() => allAttachments.filter((a) => isImage(a.content_type)),
|
|
518
|
+
[allAttachments]
|
|
519
|
+
);
|
|
520
|
+
const files = useMemo(
|
|
521
|
+
() => allAttachments.filter((a) => !isImage(a.content_type)),
|
|
522
|
+
[allAttachments]
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
const imageIds = useMemo(() => images.map((a) => a.attachment_id), [images]);
|
|
526
|
+
const urls = useImageUrls(imageIds, fetchAttachmentUrl);
|
|
527
|
+
|
|
528
|
+
const [lightbox, setLightbox] = useState<number | null>(null);
|
|
529
|
+
|
|
530
|
+
// --- search / filter / sort ------------------------------------------------
|
|
531
|
+
const [search, setSearch] = useState('');
|
|
532
|
+
const [filter, setFilter] = useState<TRefFilter>('all');
|
|
533
|
+
const [sort, setSort] = useState<TRefSort>('newest');
|
|
534
|
+
|
|
535
|
+
const query = search.trim().toLowerCase();
|
|
536
|
+
const matchesText = useCallback(
|
|
537
|
+
(...fields: (string | undefined)[]) =>
|
|
538
|
+
!query || fields.some((f) => f?.toLowerCase().includes(query)),
|
|
539
|
+
[query]
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
const sortAttachments = useCallback(
|
|
543
|
+
(arr: ITicketReferenceAttachment[]): ITicketReferenceAttachment[] => {
|
|
544
|
+
const next = [...arr];
|
|
545
|
+
switch (sort) {
|
|
546
|
+
case 'oldest':
|
|
547
|
+
return next.sort((a, b) => createdMs(a) - createdMs(b));
|
|
548
|
+
case 'name':
|
|
549
|
+
return next.sort((a, b) => a.filename.localeCompare(b.filename));
|
|
550
|
+
case 'size':
|
|
551
|
+
return next.sort((a, b) => (b.size_bytes ?? 0) - (a.size_bytes ?? 0));
|
|
552
|
+
case 'newest':
|
|
553
|
+
default:
|
|
554
|
+
return next.sort((a, b) => createdMs(b) - createdMs(a));
|
|
555
|
+
}
|
|
556
|
+
},
|
|
557
|
+
[sort]
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
// per-family counts feed the filter menu: empty families are dropped, and the
|
|
561
|
+
// surviving ones carry a count badge in their label.
|
|
562
|
+
const counts = useMemo(() => {
|
|
563
|
+
const byCategory: Record<TFileCategory, number> = {
|
|
564
|
+
documents: 0,
|
|
565
|
+
data: 0,
|
|
566
|
+
logs: 0,
|
|
567
|
+
archives: 0,
|
|
568
|
+
};
|
|
569
|
+
for (const f of files) {
|
|
570
|
+
byCategory[fileCategory(f.content_type)] += 1;
|
|
571
|
+
}
|
|
572
|
+
return {
|
|
573
|
+
all: allRefs.length + allAttachments.length,
|
|
574
|
+
links: allRefs.length,
|
|
575
|
+
screenshots: images.length,
|
|
576
|
+
...byCategory,
|
|
577
|
+
} as Record<TRefFilter, number>;
|
|
578
|
+
}, [files, images.length, allRefs.length, allAttachments.length]);
|
|
579
|
+
|
|
580
|
+
// the three sections after search + type filter are applied. References sort by
|
|
581
|
+
// kind then name so like sits with like; "Name" sorts them purely by name.
|
|
582
|
+
const visibleRefs = useMemo(() => {
|
|
583
|
+
if (filter !== 'all' && filter !== 'links') {
|
|
584
|
+
return [] as IResolvedInterfaceReference[];
|
|
585
|
+
}
|
|
586
|
+
return allRefs
|
|
587
|
+
.filter((r) => matchesText(r.interface_name, r.interface_kind))
|
|
588
|
+
.sort((a, b) =>
|
|
589
|
+
sort === 'name' ?
|
|
590
|
+
a.interface_name.localeCompare(b.interface_name)
|
|
591
|
+
: a.interface_kind.localeCompare(b.interface_kind) ||
|
|
592
|
+
a.interface_name.localeCompare(b.interface_name)
|
|
593
|
+
);
|
|
594
|
+
}, [allRefs, filter, matchesText, sort]);
|
|
595
|
+
|
|
596
|
+
const visibleImages = useMemo(() => {
|
|
597
|
+
if (filter !== 'all' && filter !== 'screenshots') {
|
|
598
|
+
return [] as ITicketReferenceAttachment[];
|
|
599
|
+
}
|
|
600
|
+
return sortAttachments(images.filter((a) => matchesText(a.filename)));
|
|
601
|
+
}, [images, filter, matchesText, sortAttachments]);
|
|
602
|
+
|
|
603
|
+
const visibleFiles = useMemo(() => {
|
|
604
|
+
const fileFilter = FILE_FILTERS.includes(filter as TFileCategory);
|
|
605
|
+
if (filter !== 'all' && !fileFilter) {
|
|
606
|
+
return [] as ITicketReferenceAttachment[];
|
|
607
|
+
}
|
|
608
|
+
let list = files.filter((a) => matchesText(a.filename));
|
|
609
|
+
if (fileFilter) {
|
|
610
|
+
list = list.filter((a) => fileCategory(a.content_type) === filter);
|
|
611
|
+
}
|
|
612
|
+
return sortAttachments(list);
|
|
613
|
+
}, [files, filter, matchesText, sortAttachments]);
|
|
614
|
+
|
|
615
|
+
// a filtered-out lightbox target would dangle — close it if its index falls away
|
|
616
|
+
useEffect(() => {
|
|
617
|
+
if (lightbox !== null && lightbox >= visibleImages.length) {
|
|
618
|
+
setLightbox(null);
|
|
619
|
+
}
|
|
620
|
+
}, [visibleImages.length, lightbox]);
|
|
621
|
+
|
|
622
|
+
if (loading) {
|
|
623
|
+
return (
|
|
624
|
+
<ReqoreControlGroup fluid horizontalAlign='center' style={{ padding: '32px 0' }}>
|
|
625
|
+
<ReqoreSpinner iconColor='info' size='big'>
|
|
626
|
+
Loading references…
|
|
627
|
+
</ReqoreSpinner>
|
|
628
|
+
</ReqoreControlGroup>
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (!allRefs.length && !allAttachments.length) {
|
|
633
|
+
return (
|
|
634
|
+
<ReqoreEmptyState
|
|
635
|
+
icon='FolderInfoLine'
|
|
636
|
+
title='No references or files yet'
|
|
637
|
+
description='Interface references and any screenshots, documents, or files attached to this ticket will appear here.'
|
|
638
|
+
flat
|
|
639
|
+
transparent
|
|
640
|
+
/>
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// A search + type-filter + sort strip, shown once a ticket carries enough to be
|
|
645
|
+
// worth narrowing (any files, or 3+ linked interfaces). Empty type-families are
|
|
646
|
+
// dropped from the filter menu so it only ever offers what's actually present.
|
|
647
|
+
const showControls = allAttachments.length > 0 || allRefs.length > 2;
|
|
648
|
+
const filterOptions = FILTER_ORDER.filter((key) => key === 'all' || counts[key] > 0);
|
|
649
|
+
const nothingVisible = !visibleRefs.length && !visibleImages.length && !visibleFiles.length;
|
|
650
|
+
|
|
651
|
+
const controlBar =
|
|
652
|
+
showControls ?
|
|
653
|
+
<SearchFilterBar
|
|
654
|
+
sticky
|
|
655
|
+
value={search}
|
|
656
|
+
onChange={setSearch}
|
|
657
|
+
placeholder='Search files and references…'
|
|
658
|
+
filters={[
|
|
659
|
+
{
|
|
660
|
+
icon: FILTER_META[filter].icon,
|
|
661
|
+
label: FILTER_META[filter].label,
|
|
662
|
+
items: filterOptions.map((key) => ({
|
|
663
|
+
selected: filter === key,
|
|
664
|
+
icon: FILTER_META[key].icon,
|
|
665
|
+
label:
|
|
666
|
+
key === 'all' ?
|
|
667
|
+
FILTER_META.all.label
|
|
668
|
+
: `${FILTER_META[key].label} (${counts[key]})`,
|
|
669
|
+
onClick: () => setFilter(key),
|
|
670
|
+
})),
|
|
671
|
+
},
|
|
672
|
+
{
|
|
673
|
+
icon: 'ArrowUpDownLine',
|
|
674
|
+
label: SORT_META[sort].label,
|
|
675
|
+
items: SORT_ORDER.map((key) => ({
|
|
676
|
+
selected: sort === key,
|
|
677
|
+
icon: SORT_META[key].icon,
|
|
678
|
+
label: SORT_META[key].label,
|
|
679
|
+
onClick: () => setSort(key),
|
|
680
|
+
})),
|
|
681
|
+
},
|
|
682
|
+
]}
|
|
683
|
+
/>
|
|
684
|
+
: null;
|
|
685
|
+
|
|
686
|
+
return (
|
|
687
|
+
<ReqoreControlGroup vertical fluid gapSize='big'>
|
|
688
|
+
{controlBar}
|
|
689
|
+
|
|
690
|
+
{nothingVisible ?
|
|
691
|
+
<ReqoreEmptyState
|
|
692
|
+
icon='Search2Line'
|
|
693
|
+
title='No matching references or files'
|
|
694
|
+
description={
|
|
695
|
+
query ?
|
|
696
|
+
`Nothing here matches “${search.trim()}”.`
|
|
697
|
+
: 'Nothing matches the current filter.'
|
|
698
|
+
}
|
|
699
|
+
flat
|
|
700
|
+
transparent
|
|
701
|
+
actions={
|
|
702
|
+
<ReqoreButton
|
|
703
|
+
icon='CloseLine'
|
|
704
|
+
minimal
|
|
705
|
+
onClick={() => {
|
|
706
|
+
setSearch('');
|
|
707
|
+
setFilter('all');
|
|
708
|
+
}}
|
|
709
|
+
>
|
|
710
|
+
Clear filters
|
|
711
|
+
</ReqoreButton>
|
|
712
|
+
}
|
|
713
|
+
/>
|
|
714
|
+
: null}
|
|
715
|
+
|
|
716
|
+
{visibleImages.length ?
|
|
717
|
+
<ReqorePanel
|
|
718
|
+
label='Screenshots'
|
|
719
|
+
icon='ImageLine'
|
|
720
|
+
badge={visibleImages.length}
|
|
721
|
+
flat
|
|
722
|
+
transparent
|
|
723
|
+
size='small'
|
|
724
|
+
labelSize={4}
|
|
725
|
+
collapsible
|
|
726
|
+
>
|
|
727
|
+
<ReqoreColumns columns='auto-fill' minColumnWidth='140px' columnsGap='10px'>
|
|
728
|
+
{visibleImages.map((att, i) => (
|
|
729
|
+
<Screenshot
|
|
730
|
+
key={att.attachment_id}
|
|
731
|
+
att={att}
|
|
732
|
+
url={urls[att.attachment_id]}
|
|
733
|
+
onOpen={() =>
|
|
734
|
+
urls[att.attachment_id] ?
|
|
735
|
+
setLightbox(i)
|
|
736
|
+
: onDownloadAttachment?.(att.attachment_id, att.filename)
|
|
737
|
+
}
|
|
738
|
+
menu={[
|
|
739
|
+
...(onDownloadAttachment ?
|
|
740
|
+
[
|
|
741
|
+
{
|
|
742
|
+
label: 'Download',
|
|
743
|
+
icon: 'DownloadLine' as IReqoreIconName,
|
|
744
|
+
onClick: () => onDownloadAttachment(att.attachment_id, att.filename),
|
|
745
|
+
},
|
|
746
|
+
]
|
|
747
|
+
: []),
|
|
748
|
+
...showInChatItem(att.message_id),
|
|
749
|
+
]}
|
|
750
|
+
/>
|
|
751
|
+
))}
|
|
752
|
+
</ReqoreColumns>
|
|
753
|
+
</ReqorePanel>
|
|
754
|
+
: null}
|
|
755
|
+
|
|
756
|
+
{visibleRefs.length ?
|
|
757
|
+
<ReqorePanel
|
|
758
|
+
label='Linked interfaces'
|
|
759
|
+
icon='LinksLine'
|
|
760
|
+
badge={visibleRefs.length}
|
|
761
|
+
flat
|
|
762
|
+
transparent
|
|
763
|
+
size='small'
|
|
764
|
+
labelSize={4}
|
|
765
|
+
collapsible
|
|
766
|
+
>
|
|
767
|
+
{/* small rows: a reference list is scanned, so density beats presence —
|
|
768
|
+
the group's size reaches every row (ControlGroup/index.tsx:436) */}
|
|
769
|
+
<ReqoreControlGroup vertical fluid gapSize='small' size='small'>
|
|
770
|
+
{visibleRefs.map((reference) => {
|
|
771
|
+
// an app-triggered qog carries its trigger app's logo; everything else
|
|
772
|
+
// shows its kind glyph — the same vocabulary the reference chips use.
|
|
773
|
+
const image = reference.logo;
|
|
774
|
+
return (
|
|
775
|
+
<ReqoreEntityRow
|
|
776
|
+
key={
|
|
777
|
+
reference.reference_id ??
|
|
778
|
+
`${reference.interface_kind}:${reference.interface_name}`
|
|
779
|
+
}
|
|
780
|
+
icon={image ? undefined : resolveIcon(reference.interface_kind)}
|
|
781
|
+
iconImage={image}
|
|
782
|
+
label={reference.interface_name}
|
|
783
|
+
metadata={
|
|
784
|
+
<ReqoreTextEffect effect={META_EFFECT}>
|
|
785
|
+
{reference.interface_kind}
|
|
786
|
+
</ReqoreTextEffect>
|
|
787
|
+
}
|
|
788
|
+
flat
|
|
789
|
+
tooltip={
|
|
790
|
+
onInterfaceClick ?
|
|
791
|
+
`Open ${reference.interface_kind} ${reference.interface_name}`
|
|
792
|
+
: undefined
|
|
793
|
+
}
|
|
794
|
+
actions={rowActions(
|
|
795
|
+
onInterfaceClick ?
|
|
796
|
+
{
|
|
797
|
+
icon: 'ArrowRightUpLine',
|
|
798
|
+
tooltip: 'Open',
|
|
799
|
+
onClick: () => onInterfaceClick(reference),
|
|
800
|
+
}
|
|
801
|
+
: undefined,
|
|
802
|
+
reference.message_id
|
|
803
|
+
)}
|
|
804
|
+
/>
|
|
805
|
+
);
|
|
806
|
+
})}
|
|
807
|
+
</ReqoreControlGroup>
|
|
808
|
+
</ReqorePanel>
|
|
809
|
+
: null}
|
|
810
|
+
|
|
811
|
+
{visibleFiles.length ?
|
|
812
|
+
<ReqorePanel
|
|
813
|
+
label='Files'
|
|
814
|
+
icon='FileList3Line'
|
|
815
|
+
badge={visibleFiles.length}
|
|
816
|
+
flat
|
|
817
|
+
transparent
|
|
818
|
+
size='small'
|
|
819
|
+
labelSize={4}
|
|
820
|
+
collapsible
|
|
821
|
+
actions={
|
|
822
|
+
visibleFiles.length > 1 && onDownloadAttachment ?
|
|
823
|
+
[
|
|
824
|
+
{
|
|
825
|
+
icon: 'DownloadCloud2Line',
|
|
826
|
+
label: 'Download all',
|
|
827
|
+
onClick: () =>
|
|
828
|
+
visibleFiles.forEach((f) => onDownloadAttachment(f.attachment_id, f.filename)),
|
|
829
|
+
},
|
|
830
|
+
]
|
|
831
|
+
: []
|
|
832
|
+
}
|
|
833
|
+
>
|
|
834
|
+
{/* matches the interface rows above — the two lists read as one surface */}
|
|
835
|
+
<ReqoreControlGroup vertical fluid gapSize='small' size='small'>
|
|
836
|
+
{visibleFiles.map((f) => {
|
|
837
|
+
const type = fileType(f.content_type);
|
|
838
|
+
// a customer's own upload reads "you"; everything else shows the
|
|
839
|
+
// (already server-masked) uploader — "Qorus Support" for staff replies.
|
|
840
|
+
const uploader =
|
|
841
|
+
viewerRole === 'customer' && f.uploader_type === 'customer' ? 'you' : f.uploaded_by;
|
|
842
|
+
const provenance = [
|
|
843
|
+
formatBytes(f.size_bytes),
|
|
844
|
+
uploader ? `added by ${uploader}` : undefined,
|
|
845
|
+
timeAgo(f.created) || undefined,
|
|
846
|
+
]
|
|
847
|
+
.filter(Boolean)
|
|
848
|
+
.join(' · ');
|
|
849
|
+
return (
|
|
850
|
+
<ReqoreEntityRow
|
|
851
|
+
key={f.attachment_id}
|
|
852
|
+
icon={type.icon}
|
|
853
|
+
iconColor={type.color}
|
|
854
|
+
label={f.filename}
|
|
855
|
+
metadata={<ReqoreTextEffect effect={META_EFFECT}>{provenance}</ReqoreTextEffect>}
|
|
856
|
+
flat
|
|
857
|
+
actions={rowActions(
|
|
858
|
+
onDownloadAttachment ?
|
|
859
|
+
{
|
|
860
|
+
icon: 'DownloadLine',
|
|
861
|
+
tooltip: 'Download',
|
|
862
|
+
onClick: () => onDownloadAttachment(f.attachment_id, f.filename),
|
|
863
|
+
}
|
|
864
|
+
: undefined,
|
|
865
|
+
f.message_id
|
|
866
|
+
)}
|
|
867
|
+
/>
|
|
868
|
+
);
|
|
869
|
+
})}
|
|
870
|
+
</ReqoreControlGroup>
|
|
871
|
+
</ReqorePanel>
|
|
872
|
+
: null}
|
|
873
|
+
|
|
874
|
+
{lightbox !== null && fetchAttachmentUrl ?
|
|
875
|
+
<ImageLightbox
|
|
876
|
+
images={visibleImages}
|
|
877
|
+
index={lightbox}
|
|
878
|
+
onIndex={setLightbox}
|
|
879
|
+
onClose={() => setLightbox(null)}
|
|
880
|
+
fetchAttachmentUrl={fetchAttachmentUrl}
|
|
881
|
+
onDownload={onDownloadAttachment}
|
|
882
|
+
/>
|
|
883
|
+
: null}
|
|
884
|
+
</ReqoreControlGroup>
|
|
885
|
+
);
|
|
886
|
+
};
|