@masterteam/discussion 0.0.1
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.
|
@@ -0,0 +1,1651 @@
|
|
|
1
|
+
import * as i1 from '@angular/common';
|
|
2
|
+
import { CommonModule, DatePipe } from '@angular/common';
|
|
3
|
+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
4
|
+
import * as i0 from '@angular/core';
|
|
5
|
+
import { inject, Injectable, DestroyRef, input, output, viewChild, signal, computed, effect, untracked, ChangeDetectionStrategy, Component } from '@angular/core';
|
|
6
|
+
import * as i2 from '@angular/forms';
|
|
7
|
+
import { FormsModule } from '@angular/forms';
|
|
8
|
+
import { Button } from '@masterteam/components/button';
|
|
9
|
+
import { Card } from '@masterteam/components/card';
|
|
10
|
+
import { EntitiesPreview } from '@masterteam/components/entities';
|
|
11
|
+
import * as i3 from 'primeng/dialog';
|
|
12
|
+
import { DialogModule } from 'primeng/dialog';
|
|
13
|
+
import { map, filter, Subject, firstValueFrom, debounceTime, distinctUntilChanged, switchMap, of, catchError, finalize } from 'rxjs';
|
|
14
|
+
import { HttpClient, HttpContext, HttpParams, HttpEventType, HttpHeaders } from '@angular/common/http';
|
|
15
|
+
import { REQUEST_CONTEXT } from '@masterteam/components';
|
|
16
|
+
|
|
17
|
+
const MODULE_TYPE_ALIASES = {
|
|
18
|
+
leveldata: 'Level',
|
|
19
|
+
level: 'Level',
|
|
20
|
+
processrequest: 'ProcessRequest',
|
|
21
|
+
escalationinstance: 'EscalationInstance',
|
|
22
|
+
};
|
|
23
|
+
function normalizeDiscussionModuleType(moduleType) {
|
|
24
|
+
const value = moduleType.trim();
|
|
25
|
+
if (!value) {
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
const alias = MODULE_TYPE_ALIASES[value.toLowerCase()];
|
|
29
|
+
return alias ?? value;
|
|
30
|
+
}
|
|
31
|
+
function normalizeDiscussionRecordId(recordId) {
|
|
32
|
+
const value = Number(recordId);
|
|
33
|
+
if (!Number.isFinite(value)) {
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
return Math.floor(value);
|
|
37
|
+
}
|
|
38
|
+
function isValidDiscussionThread(moduleType, recordId) {
|
|
39
|
+
return !!moduleType.trim() && normalizeDiscussionRecordId(recordId) > 0;
|
|
40
|
+
}
|
|
41
|
+
function buildDiscussionThreadKey(moduleType, recordId) {
|
|
42
|
+
const normalizedModuleType = normalizeDiscussionModuleType(moduleType);
|
|
43
|
+
const normalizedRecordId = normalizeDiscussionRecordId(recordId);
|
|
44
|
+
return normalizedModuleType && normalizedRecordId > 0
|
|
45
|
+
? `${normalizedModuleType}/${normalizedRecordId}`
|
|
46
|
+
: '';
|
|
47
|
+
}
|
|
48
|
+
function unwrapAppResponseViewModel(response, fallbackMessage) {
|
|
49
|
+
if (response.code === 2) {
|
|
50
|
+
throw new Error(response.errors?.message || response.message || fallbackMessage);
|
|
51
|
+
}
|
|
52
|
+
return response.data;
|
|
53
|
+
}
|
|
54
|
+
function toDiscussionErrorMessage(error, fallbackMessage) {
|
|
55
|
+
if (typeof error === 'string') {
|
|
56
|
+
return error;
|
|
57
|
+
}
|
|
58
|
+
if (error instanceof Error) {
|
|
59
|
+
return error.message || fallbackMessage;
|
|
60
|
+
}
|
|
61
|
+
const nestedMessage = error?.error?.message ??
|
|
62
|
+
error?.message;
|
|
63
|
+
if (nestedMessage && typeof nestedMessage === 'string') {
|
|
64
|
+
return nestedMessage;
|
|
65
|
+
}
|
|
66
|
+
return fallbackMessage;
|
|
67
|
+
}
|
|
68
|
+
function readPathValue(source, path, fallback) {
|
|
69
|
+
if (!path.trim()) {
|
|
70
|
+
return source ?? fallback;
|
|
71
|
+
}
|
|
72
|
+
const keys = path.split('.').map((item) => item.trim());
|
|
73
|
+
let cursor = source;
|
|
74
|
+
for (const key of keys) {
|
|
75
|
+
if (!key || cursor == null || typeof cursor !== 'object') {
|
|
76
|
+
return fallback;
|
|
77
|
+
}
|
|
78
|
+
cursor = cursor[key];
|
|
79
|
+
}
|
|
80
|
+
return cursor ?? fallback;
|
|
81
|
+
}
|
|
82
|
+
function normalizeMentionUser(value) {
|
|
83
|
+
if (!value || typeof value !== 'object') {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const item = value;
|
|
87
|
+
const userIdRaw = item['userId'] ??
|
|
88
|
+
item['id'] ??
|
|
89
|
+
item['value'] ??
|
|
90
|
+
item['userID'] ??
|
|
91
|
+
item['user_id'];
|
|
92
|
+
const userId = String(userIdRaw ?? '').trim();
|
|
93
|
+
if (!userId) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const displayNameRaw = item['displayName'] ??
|
|
97
|
+
item['name'] ??
|
|
98
|
+
item['fullName'] ??
|
|
99
|
+
item['userName'] ??
|
|
100
|
+
item['username'] ??
|
|
101
|
+
userId;
|
|
102
|
+
const displayName = String(displayNameRaw ?? userId).trim() || userId;
|
|
103
|
+
const userNameRaw = item['userName'] ?? item['username'] ?? null;
|
|
104
|
+
const avatarRaw = item['avatarUrl'] ?? item['photo'] ?? item['image'] ?? null;
|
|
105
|
+
return {
|
|
106
|
+
userId,
|
|
107
|
+
displayName,
|
|
108
|
+
userName: userNameRaw ? String(userNameRaw) : null,
|
|
109
|
+
avatarUrl: avatarRaw ? String(avatarRaw) : null,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function dedupeMentionUsers(users) {
|
|
113
|
+
const seen = new Set();
|
|
114
|
+
const result = [];
|
|
115
|
+
for (const user of users) {
|
|
116
|
+
const key = user.userId.toLowerCase();
|
|
117
|
+
if (seen.has(key)) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
seen.add(key);
|
|
121
|
+
result.push(user);
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
function sanitizeMentions(mentions, commentText) {
|
|
126
|
+
const textLength = commentText.length;
|
|
127
|
+
const unique = new Set();
|
|
128
|
+
const normalized = mentions
|
|
129
|
+
.map((mention) => ({
|
|
130
|
+
userId: mention.userId?.trim(),
|
|
131
|
+
startIndex: Math.floor(Number(mention.startIndex)),
|
|
132
|
+
length: Math.floor(Number(mention.length)),
|
|
133
|
+
}))
|
|
134
|
+
.filter((mention) => !!mention.userId &&
|
|
135
|
+
mention.startIndex >= 0 &&
|
|
136
|
+
mention.length > 0 &&
|
|
137
|
+
mention.startIndex + mention.length <= textLength)
|
|
138
|
+
.sort((left, right) => {
|
|
139
|
+
if (left.startIndex !== right.startIndex) {
|
|
140
|
+
return left.startIndex - right.startIndex;
|
|
141
|
+
}
|
|
142
|
+
return left.length - right.length;
|
|
143
|
+
})
|
|
144
|
+
.filter((mention) => {
|
|
145
|
+
const key = `${mention.userId}::${mention.startIndex}::${mention.length}`;
|
|
146
|
+
if (unique.has(key)) {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
unique.add(key);
|
|
150
|
+
return true;
|
|
151
|
+
});
|
|
152
|
+
return normalized;
|
|
153
|
+
}
|
|
154
|
+
function mapSummaryToReadState(summary) {
|
|
155
|
+
return {
|
|
156
|
+
lastReadCommentId: summary.lastReadCommentId,
|
|
157
|
+
lastReadCommentCreatedAt: summary.lastReadCommentCreatedAt,
|
|
158
|
+
unreadCount: summary.unreadCount,
|
|
159
|
+
hasUnread: summary.hasUnread,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function mapReadStateToLike(readState) {
|
|
163
|
+
if (!readState) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
lastReadCommentId: readState.lastReadCommentId,
|
|
168
|
+
lastReadCommentCreatedAt: readState.lastReadCommentCreatedAt,
|
|
169
|
+
unreadCount: readState.unreadCount,
|
|
170
|
+
hasUnread: readState.hasUnread,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
class DiscussionApiService {
|
|
175
|
+
http = inject(HttpClient);
|
|
176
|
+
defaultContext = new HttpContext().set(REQUEST_CONTEXT, {
|
|
177
|
+
useBaseUrl: false,
|
|
178
|
+
});
|
|
179
|
+
baseRoute = 'discussions';
|
|
180
|
+
getThreadSummary(moduleType, recordId, context) {
|
|
181
|
+
return this.http
|
|
182
|
+
.get(this.threadRoute(moduleType, recordId), {
|
|
183
|
+
context: this.resolveContext(context),
|
|
184
|
+
})
|
|
185
|
+
.pipe(map((response) => unwrapAppResponseViewModel(response, 'Failed to load discussion.')));
|
|
186
|
+
}
|
|
187
|
+
getComments(moduleType, recordId, take = 30, cursor, context) {
|
|
188
|
+
let params = new HttpParams().set('take', String(take));
|
|
189
|
+
if (cursor) {
|
|
190
|
+
params = params.set('cursor', cursor);
|
|
191
|
+
}
|
|
192
|
+
return this.http
|
|
193
|
+
.get(`${this.threadRoute(moduleType, recordId)}/comments`, {
|
|
194
|
+
params,
|
|
195
|
+
context: this.resolveContext(context),
|
|
196
|
+
})
|
|
197
|
+
.pipe(map((response) => unwrapAppResponseViewModel(response, 'Failed to load discussion comments.')));
|
|
198
|
+
}
|
|
199
|
+
createComment(moduleType, recordId, request, context) {
|
|
200
|
+
return this.http
|
|
201
|
+
.post(`${this.threadRoute(moduleType, recordId)}/comments`, request, {
|
|
202
|
+
context: this.resolveContext(context),
|
|
203
|
+
headers: this.mutationHeaders('discussion-create-comment'),
|
|
204
|
+
})
|
|
205
|
+
.pipe(map((response) => unwrapAppResponseViewModel(response, 'Failed to create comment.')));
|
|
206
|
+
}
|
|
207
|
+
updateComment(moduleType, recordId, commentId, request, context) {
|
|
208
|
+
return this.http
|
|
209
|
+
.patch(`${this.threadRoute(moduleType, recordId)}/comments/${commentId}`, request, {
|
|
210
|
+
context: this.resolveContext(context),
|
|
211
|
+
headers: this.mutationHeaders('discussion-update-comment'),
|
|
212
|
+
})
|
|
213
|
+
.pipe(map((response) => unwrapAppResponseViewModel(response, 'Failed to update comment.')));
|
|
214
|
+
}
|
|
215
|
+
deleteComment(moduleType, recordId, commentId, context) {
|
|
216
|
+
return this.http
|
|
217
|
+
.delete(`${this.threadRoute(moduleType, recordId)}/comments/${commentId}`, {
|
|
218
|
+
context: this.resolveContext(context),
|
|
219
|
+
headers: this.mutationHeaders('discussion-delete-comment'),
|
|
220
|
+
})
|
|
221
|
+
.pipe(map((response) => unwrapAppResponseViewModel(response, 'Failed to delete comment.')));
|
|
222
|
+
}
|
|
223
|
+
getCommentRevisions(moduleType, recordId, commentId, context) {
|
|
224
|
+
return this.http
|
|
225
|
+
.get(`${this.threadRoute(moduleType, recordId)}/comments/${commentId}/revisions`, {
|
|
226
|
+
context: this.resolveContext(context),
|
|
227
|
+
})
|
|
228
|
+
.pipe(map((response) => unwrapAppResponseViewModel(response, 'Failed to load comment revisions.')));
|
|
229
|
+
}
|
|
230
|
+
getParticipants(moduleType, recordId, context) {
|
|
231
|
+
return this.http
|
|
232
|
+
.get(`${this.threadRoute(moduleType, recordId)}/participants`, {
|
|
233
|
+
context: this.resolveContext(context),
|
|
234
|
+
})
|
|
235
|
+
.pipe(map((response) => unwrapAppResponseViewModel(response, 'Failed to load discussion participants.')));
|
|
236
|
+
}
|
|
237
|
+
addParticipant(moduleType, recordId, request, context) {
|
|
238
|
+
return this.http
|
|
239
|
+
.post(`${this.threadRoute(moduleType, recordId)}/participants`, request, {
|
|
240
|
+
context: this.resolveContext(context),
|
|
241
|
+
headers: this.mutationHeaders('discussion-add-participant'),
|
|
242
|
+
})
|
|
243
|
+
.pipe(map((response) => unwrapAppResponseViewModel(response, 'Failed to add participant.')));
|
|
244
|
+
}
|
|
245
|
+
getReadState(moduleType, recordId, context) {
|
|
246
|
+
return this.http
|
|
247
|
+
.get(`${this.threadRoute(moduleType, recordId)}/read-state`, {
|
|
248
|
+
context: this.resolveContext(context),
|
|
249
|
+
})
|
|
250
|
+
.pipe(map((response) => unwrapAppResponseViewModel(response, 'Failed to load read state.')));
|
|
251
|
+
}
|
|
252
|
+
markReadState(moduleType, recordId, request, context) {
|
|
253
|
+
return this.http
|
|
254
|
+
.put(`${this.threadRoute(moduleType, recordId)}/read-state`, request?.lastReadCommentId ? request : {}, {
|
|
255
|
+
context: this.resolveContext(context),
|
|
256
|
+
headers: this.mutationHeaders('discussion-mark-read'),
|
|
257
|
+
})
|
|
258
|
+
.pipe(map((response) => unwrapAppResponseViewModel(response, 'Failed to mark read state.')));
|
|
259
|
+
}
|
|
260
|
+
clearReadState(moduleType, recordId, context) {
|
|
261
|
+
return this.http
|
|
262
|
+
.delete(`${this.threadRoute(moduleType, recordId)}/read-state`, {
|
|
263
|
+
context: this.resolveContext(context),
|
|
264
|
+
headers: this.mutationHeaders('discussion-clear-read'),
|
|
265
|
+
})
|
|
266
|
+
.pipe(map((response) => unwrapAppResponseViewModel(response, 'Failed to clear read state.')));
|
|
267
|
+
}
|
|
268
|
+
uploadAttachment(file, uploadEndpoint, context) {
|
|
269
|
+
const formData = new FormData();
|
|
270
|
+
formData.append('file', file);
|
|
271
|
+
return this.http
|
|
272
|
+
.post(uploadEndpoint, formData, {
|
|
273
|
+
reportProgress: true,
|
|
274
|
+
observe: 'events',
|
|
275
|
+
context: this.resolveContext(context),
|
|
276
|
+
})
|
|
277
|
+
.pipe(map((event) => {
|
|
278
|
+
if (event.type === HttpEventType.UploadProgress) {
|
|
279
|
+
return {
|
|
280
|
+
progress: event.total
|
|
281
|
+
? Math.round((event.loaded / event.total) * 100)
|
|
282
|
+
: 0,
|
|
283
|
+
completed: false,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
if (event.type === HttpEventType.Response && event.body) {
|
|
287
|
+
return {
|
|
288
|
+
progress: 100,
|
|
289
|
+
completed: true,
|
|
290
|
+
file: this.mapUploadPayload(event.body, file),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
}), filter((value) => value != null));
|
|
295
|
+
}
|
|
296
|
+
searchMentionUsers(endpoint, query, paramName, dataPath, context) {
|
|
297
|
+
const params = new HttpParams().set(paramName, query);
|
|
298
|
+
return this.http
|
|
299
|
+
.get(endpoint, {
|
|
300
|
+
params,
|
|
301
|
+
context: this.resolveContext(context),
|
|
302
|
+
})
|
|
303
|
+
.pipe(map((response) => {
|
|
304
|
+
const payload = this.unwrapUnknownResponse(response);
|
|
305
|
+
const candidates = readPathValue(payload, dataPath, Array.isArray(payload) ? payload : []);
|
|
306
|
+
return candidates
|
|
307
|
+
.map((item) => normalizeMentionUser(item))
|
|
308
|
+
.filter((item) => item != null);
|
|
309
|
+
}));
|
|
310
|
+
}
|
|
311
|
+
downloadAttachment(endpoint, fileId, fileName, context) {
|
|
312
|
+
const url = this.resolveDownloadEndpoint(endpoint, fileId);
|
|
313
|
+
return this.http
|
|
314
|
+
.get(url, {
|
|
315
|
+
responseType: 'blob',
|
|
316
|
+
context: this.resolveContext(context),
|
|
317
|
+
})
|
|
318
|
+
.pipe(map((blob) => {
|
|
319
|
+
const objectUrl = window.URL.createObjectURL(blob);
|
|
320
|
+
const link = document.createElement('a');
|
|
321
|
+
link.href = objectUrl;
|
|
322
|
+
link.download = fileName;
|
|
323
|
+
link.click();
|
|
324
|
+
window.URL.revokeObjectURL(objectUrl);
|
|
325
|
+
}));
|
|
326
|
+
}
|
|
327
|
+
threadRoute(moduleType, recordId) {
|
|
328
|
+
return `${this.baseRoute}/${encodeURIComponent(moduleType)}/${recordId}`;
|
|
329
|
+
}
|
|
330
|
+
resolveContext(context) {
|
|
331
|
+
return context ?? this.defaultContext;
|
|
332
|
+
}
|
|
333
|
+
mutationHeaders(seed) {
|
|
334
|
+
return new HttpHeaders({
|
|
335
|
+
'X-Idempotency-Key': this.idempotencyKey(seed),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
idempotencyKey(seed) {
|
|
339
|
+
if (typeof crypto !== 'undefined' &&
|
|
340
|
+
typeof crypto.randomUUID === 'function') {
|
|
341
|
+
return `${seed}-${crypto.randomUUID()}`;
|
|
342
|
+
}
|
|
343
|
+
return `${seed}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
344
|
+
}
|
|
345
|
+
unwrapUnknownResponse(response) {
|
|
346
|
+
if (!response || typeof response !== 'object') {
|
|
347
|
+
return response;
|
|
348
|
+
}
|
|
349
|
+
const asResponse = response;
|
|
350
|
+
if (typeof asResponse.code === 'number' && asResponse.code === 2) {
|
|
351
|
+
throw new Error(asResponse.errors?.message ||
|
|
352
|
+
asResponse.message ||
|
|
353
|
+
'Request failed while loading mention candidates.');
|
|
354
|
+
}
|
|
355
|
+
if ('data' in asResponse) {
|
|
356
|
+
return asResponse.data;
|
|
357
|
+
}
|
|
358
|
+
return response;
|
|
359
|
+
}
|
|
360
|
+
mapUploadPayload(response, file) {
|
|
361
|
+
const payload = this.unwrapUnknownResponse(response);
|
|
362
|
+
// Uploader integrations usually expose:
|
|
363
|
+
// - fileName: storage key / retrievable id (for API payloads)
|
|
364
|
+
// - name: original file name (for UI display)
|
|
365
|
+
const fileId = String(payload?.fileId ??
|
|
366
|
+
payload?.fileName ??
|
|
367
|
+
payload?.id ??
|
|
368
|
+
payload?.name ??
|
|
369
|
+
file.name).trim();
|
|
370
|
+
const displayFileName = String(payload?.name ??
|
|
371
|
+
payload?.originalFileName ??
|
|
372
|
+
payload?.fileName ??
|
|
373
|
+
file.name).trim();
|
|
374
|
+
return {
|
|
375
|
+
fileId,
|
|
376
|
+
fileName: displayFileName || file.name,
|
|
377
|
+
contentType: payload?.contentType ?? file.type,
|
|
378
|
+
size: payload?.size ?? file.size,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
resolveDownloadEndpoint(endpoint, fileId) {
|
|
382
|
+
const cleanEndpoint = endpoint.trim() || 'uploader';
|
|
383
|
+
if (cleanEndpoint.includes('{fileId}')) {
|
|
384
|
+
return cleanEndpoint.replace('{fileId}', encodeURIComponent(fileId));
|
|
385
|
+
}
|
|
386
|
+
return `${cleanEndpoint.replace(/\/$/, '')}/${encodeURIComponent(fileId)}`;
|
|
387
|
+
}
|
|
388
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: DiscussionApiService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
389
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: DiscussionApiService, providedIn: 'root' });
|
|
390
|
+
}
|
|
391
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: DiscussionApiService, decorators: [{
|
|
392
|
+
type: Injectable,
|
|
393
|
+
args: [{ providedIn: 'root' }]
|
|
394
|
+
}] });
|
|
395
|
+
|
|
396
|
+
class DiscussionThread {
|
|
397
|
+
api = inject(DiscussionApiService);
|
|
398
|
+
destroyRef = inject(DestroyRef);
|
|
399
|
+
moduleType = input.required(...(ngDevMode ? [{ debugName: "moduleType" }] : []));
|
|
400
|
+
recordId = input.required(...(ngDevMode ? [{ debugName: "recordId" }] : []));
|
|
401
|
+
title = input('Discussion', ...(ngDevMode ? [{ debugName: "title" }] : []));
|
|
402
|
+
subtitle = input('', ...(ngDevMode ? [{ debugName: "subtitle" }] : []));
|
|
403
|
+
placeholder = input('Write a message', ...(ngDevMode ? [{ debugName: "placeholder" }] : []));
|
|
404
|
+
pageSize = input(30, ...(ngDevMode ? [{ debugName: "pageSize" }] : []));
|
|
405
|
+
currentUserId = input('', ...(ngDevMode ? [{ debugName: "currentUserId" }] : []));
|
|
406
|
+
requestContext = input(undefined, ...(ngDevMode ? [{ debugName: "requestContext" }] : []));
|
|
407
|
+
mentionableUsers = input([], ...(ngDevMode ? [{ debugName: "mentionableUsers" }] : []));
|
|
408
|
+
mentionSearchEndpoint = input('', ...(ngDevMode ? [{ debugName: "mentionSearchEndpoint" }] : []));
|
|
409
|
+
mentionSearchParam = input('query', ...(ngDevMode ? [{ debugName: "mentionSearchParam" }] : []));
|
|
410
|
+
mentionSearchDataPath = input('data', ...(ngDevMode ? [{ debugName: "mentionSearchDataPath" }] : []));
|
|
411
|
+
allowAttachments = input(true, ...(ngDevMode ? [{ debugName: "allowAttachments" }] : []));
|
|
412
|
+
uploadEndpoint = input('uploader', ...(ngDevMode ? [{ debugName: "uploadEndpoint" }] : []));
|
|
413
|
+
attachmentDownloadEndpoint = input('uploader', ...(ngDevMode ? [{ debugName: "attachmentDownloadEndpoint" }] : []));
|
|
414
|
+
showParticipants = input(true, ...(ngDevMode ? [{ debugName: "showParticipants" }] : []));
|
|
415
|
+
autoMarkRead = input(true, ...(ngDevMode ? [{ debugName: "autoMarkRead" }] : []));
|
|
416
|
+
refreshIntervalMs = input(0, ...(ngDevMode ? [{ debugName: "refreshIntervalMs" }] : []));
|
|
417
|
+
styleClass = input('', ...(ngDevMode ? [{ debugName: "styleClass" }] : []));
|
|
418
|
+
disabled = input(false, ...(ngDevMode ? [{ debugName: "disabled" }] : []));
|
|
419
|
+
loaded = output();
|
|
420
|
+
errored = output();
|
|
421
|
+
commentCreated = output();
|
|
422
|
+
commentUpdated = output();
|
|
423
|
+
commentDeleted = output();
|
|
424
|
+
readStateChanged = output();
|
|
425
|
+
viewportRef = viewChild('viewport', ...(ngDevMode ? [{ debugName: "viewportRef" }] : []));
|
|
426
|
+
composerInputRef = viewChild('composerInput', ...(ngDevMode ? [{ debugName: "composerInputRef" }] : []));
|
|
427
|
+
attachmentInputRef = viewChild('attachmentInput', ...(ngDevMode ? [{ debugName: "attachmentInputRef" }] : []));
|
|
428
|
+
loadingInitial = signal(false, ...(ngDevMode ? [{ debugName: "loadingInitial" }] : []));
|
|
429
|
+
loadingMore = signal(false, ...(ngDevMode ? [{ debugName: "loadingMore" }] : []));
|
|
430
|
+
posting = signal(false, ...(ngDevMode ? [{ debugName: "posting" }] : []));
|
|
431
|
+
refreshing = signal(false, ...(ngDevMode ? [{ debugName: "refreshing" }] : []));
|
|
432
|
+
markingRead = signal(false, ...(ngDevMode ? [{ debugName: "markingRead" }] : []));
|
|
433
|
+
participantsLoading = signal(false, ...(ngDevMode ? [{ debugName: "participantsLoading" }] : []));
|
|
434
|
+
savingEdit = signal(false, ...(ngDevMode ? [{ debugName: "savingEdit" }] : []));
|
|
435
|
+
revisionLoading = signal(false, ...(ngDevMode ? [{ debugName: "revisionLoading" }] : []));
|
|
436
|
+
errorMessage = signal(null, ...(ngDevMode ? [{ debugName: "errorMessage" }] : []));
|
|
437
|
+
threadSummary = signal(null, ...(ngDevMode ? [{ debugName: "threadSummary" }] : []));
|
|
438
|
+
commentsDesc = signal([], ...(ngDevMode ? [{ debugName: "commentsDesc" }] : []));
|
|
439
|
+
participants = signal([], ...(ngDevMode ? [{ debugName: "participants" }] : []));
|
|
440
|
+
readState = signal(null, ...(ngDevMode ? [{ debugName: "readState" }] : []));
|
|
441
|
+
nextCursor = signal(null, ...(ngDevMode ? [{ debugName: "nextCursor" }] : []));
|
|
442
|
+
hasMore = signal(false, ...(ngDevMode ? [{ debugName: "hasMore" }] : []));
|
|
443
|
+
composerText = signal('', ...(ngDevMode ? [{ debugName: "composerText" }] : []));
|
|
444
|
+
composerMentions = signal([], ...(ngDevMode ? [{ debugName: "composerMentions" }] : []));
|
|
445
|
+
composerAttachments = signal([], ...(ngDevMode ? [{ debugName: "composerAttachments" }] : []));
|
|
446
|
+
replyToCommentId = signal(null, ...(ngDevMode ? [{ debugName: "replyToCommentId" }] : []));
|
|
447
|
+
editingCommentId = signal(null, ...(ngDevMode ? [{ debugName: "editingCommentId" }] : []));
|
|
448
|
+
editText = signal('', ...(ngDevMode ? [{ debugName: "editText" }] : []));
|
|
449
|
+
editMentions = signal([], ...(ngDevMode ? [{ debugName: "editMentions" }] : []));
|
|
450
|
+
participantsExpanded = signal(false, ...(ngDevMode ? [{ debugName: "participantsExpanded" }] : []));
|
|
451
|
+
mentionSession = signal(null, ...(ngDevMode ? [{ debugName: "mentionSession" }] : []));
|
|
452
|
+
mentionCandidates = signal([], ...(ngDevMode ? [{ debugName: "mentionCandidates" }] : []));
|
|
453
|
+
mentionLoading = signal(false, ...(ngDevMode ? [{ debugName: "mentionLoading" }] : []));
|
|
454
|
+
mentionActiveIndex = signal(0, ...(ngDevMode ? [{ debugName: "mentionActiveIndex" }] : []));
|
|
455
|
+
mentionMenuPosition = signal(null, ...(ngDevMode ? [{ debugName: "mentionMenuPosition" }] : []));
|
|
456
|
+
mentionMenuStyle = computed(() => {
|
|
457
|
+
const position = this.mentionMenuPosition();
|
|
458
|
+
if (!position) {
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
return {
|
|
462
|
+
left: `${position.left}px`,
|
|
463
|
+
top: `${position.top}px`,
|
|
464
|
+
width: `${position.width}px`,
|
|
465
|
+
maxHeight: `${position.maxHeight}px`,
|
|
466
|
+
};
|
|
467
|
+
}, ...(ngDevMode ? [{ debugName: "mentionMenuStyle" }] : []));
|
|
468
|
+
revisionsDialogVisible = signal(false, ...(ngDevMode ? [{ debugName: "revisionsDialogVisible" }] : []));
|
|
469
|
+
selectedRevisionComment = signal(null, ...(ngDevMode ? [{ debugName: "selectedRevisionComment" }] : []));
|
|
470
|
+
revisionsByComment = signal(new Map(), ...(ngDevMode ? [{ debugName: "revisionsByComment" }] : []));
|
|
471
|
+
deletingCommentIds = signal(new Set(), ...(ngDevMode ? [{ debugName: "deletingCommentIds" }] : []));
|
|
472
|
+
atBottom = signal(true, ...(ngDevMode ? [{ debugName: "atBottom" }] : []));
|
|
473
|
+
resolvedModuleType = computed(() => normalizeDiscussionModuleType(this.moduleType()), ...(ngDevMode ? [{ debugName: "resolvedModuleType" }] : []));
|
|
474
|
+
resolvedRecordId = computed(() => normalizeDiscussionRecordId(this.recordId()), ...(ngDevMode ? [{ debugName: "resolvedRecordId" }] : []));
|
|
475
|
+
resolvedPageSize = computed(() => this.normalizePageSize(this.pageSize()), ...(ngDevMode ? [{ debugName: "resolvedPageSize" }] : []));
|
|
476
|
+
threadKey = computed(() => buildDiscussionThreadKey(this.resolvedModuleType(), this.resolvedRecordId()), ...(ngDevMode ? [{ debugName: "threadKey" }] : []));
|
|
477
|
+
commentsAsc = computed(() => [...this.commentsDesc()].reverse(), ...(ngDevMode ? [{ debugName: "commentsAsc" }] : []));
|
|
478
|
+
commentsById = computed(() => {
|
|
479
|
+
const map = new Map();
|
|
480
|
+
for (const comment of this.commentsDesc()) {
|
|
481
|
+
map.set(comment.id, comment);
|
|
482
|
+
}
|
|
483
|
+
return map;
|
|
484
|
+
}, ...(ngDevMode ? [{ debugName: "commentsById" }] : []));
|
|
485
|
+
replyToComment = computed(() => {
|
|
486
|
+
const parentId = this.replyToCommentId();
|
|
487
|
+
if (parentId == null) {
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
return this.commentsById().get(parentId) ?? null;
|
|
491
|
+
}, ...(ngDevMode ? [{ debugName: "replyToComment" }] : []));
|
|
492
|
+
readStateLike = computed(() => {
|
|
493
|
+
const currentReadState = this.readState();
|
|
494
|
+
if (currentReadState) {
|
|
495
|
+
return mapReadStateToLike(currentReadState);
|
|
496
|
+
}
|
|
497
|
+
const summary = this.threadSummary();
|
|
498
|
+
return summary ? mapSummaryToReadState(summary) : null;
|
|
499
|
+
}, ...(ngDevMode ? [{ debugName: "readStateLike" }] : []));
|
|
500
|
+
hasUnread = computed(() => this.readStateLike()?.hasUnread ?? false, ...(ngDevMode ? [{ debugName: "hasUnread" }] : []));
|
|
501
|
+
unreadCount = computed(() => this.readStateLike()?.unreadCount ?? 0, ...(ngDevMode ? [{ debugName: "unreadCount" }] : []));
|
|
502
|
+
firstUnreadCommentId = computed(() => this.resolveFirstUnreadCommentId(this.commentsAsc(), this.readStateLike()), ...(ngDevMode ? [{ debugName: "firstUnreadCommentId" }] : []));
|
|
503
|
+
mentionPool = computed(() => dedupeMentionUsers([
|
|
504
|
+
...this.mentionableUsers(),
|
|
505
|
+
...this.participants().map((participant) => ({
|
|
506
|
+
userId: participant.userId,
|
|
507
|
+
displayName: this.getParticipantDisplayName(participant),
|
|
508
|
+
userName: participant.user?.userName ?? null,
|
|
509
|
+
avatarUrl: participant.user?.photoUrl ?? null,
|
|
510
|
+
})),
|
|
511
|
+
]), ...(ngDevMode ? [{ debugName: "mentionPool" }] : []));
|
|
512
|
+
participantEntities = computed(() => this.participants().map((participant, index) => ({
|
|
513
|
+
id: participant.id,
|
|
514
|
+
key: `participant-${participant.id}`,
|
|
515
|
+
name: 'Participant',
|
|
516
|
+
order: index + 1,
|
|
517
|
+
value: this.mapParticipantToEntityUserValue(participant),
|
|
518
|
+
viewType: 'User',
|
|
519
|
+
configuration: {
|
|
520
|
+
size: 24,
|
|
521
|
+
showBorder: true,
|
|
522
|
+
showDisplayName: true,
|
|
523
|
+
},
|
|
524
|
+
})), ...(ngDevMode ? [{ debugName: "participantEntities" }] : []));
|
|
525
|
+
uploadInProgress = computed(() => this.composerAttachments().some((item) => item.status === 'uploading'), ...(ngDevMode ? [{ debugName: "uploadInProgress" }] : []));
|
|
526
|
+
readyAttachmentFileIds = computed(() => this.composerAttachments()
|
|
527
|
+
.filter((item) => item.status === 'ready' && !!item.fileId)
|
|
528
|
+
.map((item) => item.fileId)
|
|
529
|
+
.filter((item) => !!item), ...(ngDevMode ? [{ debugName: "readyAttachmentFileIds" }] : []));
|
|
530
|
+
canSend = computed(() => {
|
|
531
|
+
if (this.disabled() || this.posting() || this.uploadInProgress()) {
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
const commentText = this.composerText();
|
|
535
|
+
return commentText.trim().length > 0 && commentText.length <= 10000;
|
|
536
|
+
}, ...(ngDevMode ? [{ debugName: "canSend" }] : []));
|
|
537
|
+
canSaveEdit = computed(() => {
|
|
538
|
+
if (this.disabled() || this.savingEdit()) {
|
|
539
|
+
return false;
|
|
540
|
+
}
|
|
541
|
+
const text = this.editText();
|
|
542
|
+
return text.trim().length > 0 && text.length <= 10000;
|
|
543
|
+
}, ...(ngDevMode ? [{ debugName: "canSaveEdit" }] : []));
|
|
544
|
+
charactersLeft = computed(() => 10000 - this.composerText().length, ...(ngDevMode ? [{ debugName: "charactersLeft" }] : []));
|
|
545
|
+
visibleRevisions = computed(() => {
|
|
546
|
+
const commentId = this.selectedRevisionComment()?.id;
|
|
547
|
+
if (commentId == null) {
|
|
548
|
+
return [];
|
|
549
|
+
}
|
|
550
|
+
return this.revisionsByComment().get(commentId) ?? [];
|
|
551
|
+
}, ...(ngDevMode ? [{ debugName: "visibleRevisions" }] : []));
|
|
552
|
+
mentionSearchTerms = new Subject();
|
|
553
|
+
mentionMenuMaxWidthPx = 352;
|
|
554
|
+
mentionMenuPreferredHeightPx = 260;
|
|
555
|
+
mentionMenuViewportPaddingPx = 8;
|
|
556
|
+
autoReadTimer = null;
|
|
557
|
+
constructor() {
|
|
558
|
+
this.bindMentionSearch();
|
|
559
|
+
effect(() => {
|
|
560
|
+
const moduleType = this.resolvedModuleType();
|
|
561
|
+
const recordId = this.resolvedRecordId();
|
|
562
|
+
this.requestContext();
|
|
563
|
+
if (!isValidDiscussionThread(moduleType, recordId)) {
|
|
564
|
+
this.resetData();
|
|
565
|
+
if (moduleType || recordId) {
|
|
566
|
+
this.errorMessage.set('Invalid thread identity.');
|
|
567
|
+
}
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
untracked(() => {
|
|
571
|
+
void this.loadThread();
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
effect((onCleanup) => {
|
|
575
|
+
const interval = this.refreshIntervalMs();
|
|
576
|
+
const key = this.threadKey();
|
|
577
|
+
if (!key || interval <= 0) {
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const timer = setInterval(() => {
|
|
581
|
+
void this.refreshThread();
|
|
582
|
+
}, interval);
|
|
583
|
+
onCleanup(() => clearInterval(timer));
|
|
584
|
+
});
|
|
585
|
+
effect((onCleanup) => {
|
|
586
|
+
if (typeof window === 'undefined') {
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const onViewportChange = () => this.positionMentionMenuForCurrentSession();
|
|
590
|
+
window.addEventListener('resize', onViewportChange);
|
|
591
|
+
onCleanup(() => {
|
|
592
|
+
window.removeEventListener('resize', onViewportChange);
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
trackComment(_, comment) {
|
|
597
|
+
return comment.id;
|
|
598
|
+
}
|
|
599
|
+
isOwnComment(comment) {
|
|
600
|
+
const currentUserId = this.currentUserId().trim().toLowerCase();
|
|
601
|
+
return !!currentUserId && comment.createdBy.toLowerCase() === currentUserId;
|
|
602
|
+
}
|
|
603
|
+
canEditComment(comment) {
|
|
604
|
+
return this.isOwnComment(comment) && !comment.isSystem;
|
|
605
|
+
}
|
|
606
|
+
canDeleteComment(comment) {
|
|
607
|
+
return this.isOwnComment(comment) && !comment.isSystem;
|
|
608
|
+
}
|
|
609
|
+
isDeleting(commentId) {
|
|
610
|
+
return this.deletingCommentIds().has(commentId);
|
|
611
|
+
}
|
|
612
|
+
getAvatarText(comment) {
|
|
613
|
+
const source = comment.createdBy || '?';
|
|
614
|
+
return source.slice(0, 1).toUpperCase();
|
|
615
|
+
}
|
|
616
|
+
getMentionAvatarText(user) {
|
|
617
|
+
const source = user.displayName || user.userName || user.userId || '?';
|
|
618
|
+
return source.slice(0, 1).toUpperCase();
|
|
619
|
+
}
|
|
620
|
+
getCommentSegments(comment) {
|
|
621
|
+
const text = comment.comment ?? '';
|
|
622
|
+
const mentions = sanitizeMentions(comment.mentions ?? [], text);
|
|
623
|
+
if (!mentions.length) {
|
|
624
|
+
return [{ text, isMention: false }];
|
|
625
|
+
}
|
|
626
|
+
const segments = [];
|
|
627
|
+
let cursor = 0;
|
|
628
|
+
for (const mention of mentions) {
|
|
629
|
+
if (mention.startIndex > cursor) {
|
|
630
|
+
segments.push({
|
|
631
|
+
text: text.slice(cursor, mention.startIndex),
|
|
632
|
+
isMention: false,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
const mentionText = text.slice(mention.startIndex, mention.startIndex + mention.length);
|
|
636
|
+
if (mentionText) {
|
|
637
|
+
segments.push({
|
|
638
|
+
text: mentionText,
|
|
639
|
+
isMention: true,
|
|
640
|
+
userId: mention.userId,
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
cursor = mention.startIndex + mention.length;
|
|
644
|
+
}
|
|
645
|
+
if (cursor < text.length) {
|
|
646
|
+
segments.push({
|
|
647
|
+
text: text.slice(cursor),
|
|
648
|
+
isMention: false,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
return segments;
|
|
652
|
+
}
|
|
653
|
+
getParentComment(comment) {
|
|
654
|
+
if (comment.parentCommentId == null) {
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
return this.commentsById().get(comment.parentCommentId) ?? null;
|
|
658
|
+
}
|
|
659
|
+
openReply(comment) {
|
|
660
|
+
if (comment.isSystem) {
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
this.replyToCommentId.set(comment.id);
|
|
664
|
+
this.focusComposer();
|
|
665
|
+
}
|
|
666
|
+
clearReply() {
|
|
667
|
+
this.replyToCommentId.set(null);
|
|
668
|
+
}
|
|
669
|
+
startEdit(comment) {
|
|
670
|
+
if (!this.canEditComment(comment)) {
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
this.editingCommentId.set(comment.id);
|
|
674
|
+
this.editText.set(comment.comment);
|
|
675
|
+
this.editMentions.set(sanitizeMentions(comment.mentions ?? [], comment.comment));
|
|
676
|
+
this.closeMentionMenu();
|
|
677
|
+
}
|
|
678
|
+
cancelEdit() {
|
|
679
|
+
this.editingCommentId.set(null);
|
|
680
|
+
this.editText.set('');
|
|
681
|
+
this.editMentions.set([]);
|
|
682
|
+
this.closeMentionMenu();
|
|
683
|
+
}
|
|
684
|
+
async saveEdit() {
|
|
685
|
+
const commentId = this.editingCommentId();
|
|
686
|
+
if (commentId == null || !this.canSaveEdit()) {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
const commentText = this.editText();
|
|
690
|
+
const mentions = sanitizeMentions(this.editMentions(), commentText);
|
|
691
|
+
this.savingEdit.set(true);
|
|
692
|
+
try {
|
|
693
|
+
const updatedComment = await firstValueFrom(this.api.updateComment(this.resolvedModuleType(), this.resolvedRecordId(), commentId, {
|
|
694
|
+
comment: commentText,
|
|
695
|
+
mentions: mentions.length ? mentions : undefined,
|
|
696
|
+
}, this.requestContext()));
|
|
697
|
+
this.commentsDesc.update((comments) => comments.map((comment) => comment.id === updatedComment.id ? updatedComment : comment));
|
|
698
|
+
this.commentUpdated.emit(updatedComment);
|
|
699
|
+
this.cancelEdit();
|
|
700
|
+
}
|
|
701
|
+
catch (error) {
|
|
702
|
+
this.handleError(error, 'Unable to update comment.');
|
|
703
|
+
}
|
|
704
|
+
finally {
|
|
705
|
+
this.savingEdit.set(false);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
async deleteComment(comment) {
|
|
709
|
+
if (!this.canDeleteComment(comment)) {
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
const accepted = window.confirm('Delete this comment?');
|
|
713
|
+
if (!accepted) {
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
this.deletingCommentIds.update((state) => {
|
|
717
|
+
const next = new Set(state);
|
|
718
|
+
next.add(comment.id);
|
|
719
|
+
return next;
|
|
720
|
+
});
|
|
721
|
+
try {
|
|
722
|
+
await firstValueFrom(this.api.deleteComment(this.resolvedModuleType(), this.resolvedRecordId(), comment.id, this.requestContext()));
|
|
723
|
+
this.commentsDesc.update((comments) => comments.filter((item) => item.id !== comment.id));
|
|
724
|
+
this.commentDeleted.emit(comment.id);
|
|
725
|
+
if (this.replyToCommentId() === comment.id) {
|
|
726
|
+
this.replyToCommentId.set(null);
|
|
727
|
+
}
|
|
728
|
+
if (this.editingCommentId() === comment.id) {
|
|
729
|
+
this.cancelEdit();
|
|
730
|
+
}
|
|
731
|
+
this.patchSummaryTotal(-1);
|
|
732
|
+
await this.refreshReadState();
|
|
733
|
+
}
|
|
734
|
+
catch (error) {
|
|
735
|
+
this.handleError(error, 'Unable to delete comment.');
|
|
736
|
+
}
|
|
737
|
+
finally {
|
|
738
|
+
this.deletingCommentIds.update((state) => {
|
|
739
|
+
const next = new Set(state);
|
|
740
|
+
next.delete(comment.id);
|
|
741
|
+
return next;
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
async sendComment() {
|
|
746
|
+
if (!this.canSend()) {
|
|
747
|
+
if (this.composerText().length > 10000) {
|
|
748
|
+
this.errorMessage.set('Comment cannot exceed 10000 characters.');
|
|
749
|
+
}
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
const commentText = this.composerText();
|
|
753
|
+
const mentions = sanitizeMentions(this.composerMentions(), commentText);
|
|
754
|
+
this.posting.set(true);
|
|
755
|
+
try {
|
|
756
|
+
const createdComment = await firstValueFrom(this.api.createComment(this.resolvedModuleType(), this.resolvedRecordId(), {
|
|
757
|
+
comment: commentText,
|
|
758
|
+
parentCommentId: this.replyToCommentId(),
|
|
759
|
+
attachmentFileIds: this.readyAttachmentFileIds().length > 0
|
|
760
|
+
? this.readyAttachmentFileIds()
|
|
761
|
+
: undefined,
|
|
762
|
+
mentions: mentions.length ? mentions : undefined,
|
|
763
|
+
}, this.requestContext()));
|
|
764
|
+
this.commentsDesc.update((comments) => [
|
|
765
|
+
createdComment,
|
|
766
|
+
...comments.filter((comment) => comment.id !== createdComment.id),
|
|
767
|
+
]);
|
|
768
|
+
this.patchSummaryTotal(1, createdComment.createdAt);
|
|
769
|
+
this.commentCreated.emit(createdComment);
|
|
770
|
+
this.resetComposer();
|
|
771
|
+
this.scheduleScrollToBottom('smooth');
|
|
772
|
+
this.scheduleAutoMarkRead();
|
|
773
|
+
if (this.showParticipants()) {
|
|
774
|
+
void this.reloadParticipantsQuiet();
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
catch (error) {
|
|
778
|
+
this.handleError(error, 'Unable to send comment.');
|
|
779
|
+
}
|
|
780
|
+
finally {
|
|
781
|
+
this.posting.set(false);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
async loadOlder() {
|
|
785
|
+
if (!this.hasMore() || this.loadingMore()) {
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
const cursor = this.nextCursor();
|
|
789
|
+
if (!cursor) {
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
this.loadingMore.set(true);
|
|
793
|
+
try {
|
|
794
|
+
const page = await firstValueFrom(this.api.getComments(this.resolvedModuleType(), this.resolvedRecordId(), this.resolvedPageSize(), cursor, this.requestContext()));
|
|
795
|
+
const normalized = this.normalizeComments(page.items);
|
|
796
|
+
const existingIds = new Set(this.commentsDesc().map((item) => item.id));
|
|
797
|
+
const older = normalized.filter((item) => !existingIds.has(item.id));
|
|
798
|
+
this.commentsDesc.update((comments) => [...comments, ...older]);
|
|
799
|
+
this.applyCommentsPageMeta(page);
|
|
800
|
+
}
|
|
801
|
+
catch (error) {
|
|
802
|
+
if (this.isCursorError(error)) {
|
|
803
|
+
await this.loadThread({ quiet: true, preserveDraft: true });
|
|
804
|
+
this.errorMessage.set('Timeline cursor expired. Comments were reloaded from latest.');
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
this.handleError(error, 'Unable to load older comments.');
|
|
808
|
+
}
|
|
809
|
+
finally {
|
|
810
|
+
this.loadingMore.set(false);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
async refreshThread() {
|
|
814
|
+
if (this.loadingInitial() || this.refreshing()) {
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
this.refreshing.set(true);
|
|
818
|
+
try {
|
|
819
|
+
await this.loadThread({ quiet: true, preserveDraft: true });
|
|
820
|
+
}
|
|
821
|
+
finally {
|
|
822
|
+
this.refreshing.set(false);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
async markRead() {
|
|
826
|
+
if (this.markingRead()) {
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
this.markingRead.set(true);
|
|
830
|
+
try {
|
|
831
|
+
const state = await firstValueFrom(this.api.markReadState(this.resolvedModuleType(), this.resolvedRecordId(), undefined, this.requestContext()));
|
|
832
|
+
this.readState.set(state);
|
|
833
|
+
this.readStateChanged.emit(state);
|
|
834
|
+
this.patchSummaryRead(state);
|
|
835
|
+
}
|
|
836
|
+
catch (error) {
|
|
837
|
+
this.handleError(error, 'Unable to update read state.', false);
|
|
838
|
+
}
|
|
839
|
+
finally {
|
|
840
|
+
this.markingRead.set(false);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
async markUnread() {
|
|
844
|
+
if (this.markingRead()) {
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
this.markingRead.set(true);
|
|
848
|
+
try {
|
|
849
|
+
await firstValueFrom(this.api.clearReadState(this.resolvedModuleType(), this.resolvedRecordId(), this.requestContext()));
|
|
850
|
+
await this.refreshReadState();
|
|
851
|
+
}
|
|
852
|
+
catch (error) {
|
|
853
|
+
this.handleError(error, 'Unable to update read state.');
|
|
854
|
+
}
|
|
855
|
+
finally {
|
|
856
|
+
this.markingRead.set(false);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
onViewportScroll() {
|
|
860
|
+
const viewport = this.viewportRef()?.nativeElement;
|
|
861
|
+
if (!viewport) {
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
const threshold = 40;
|
|
865
|
+
const isBottom = viewport.scrollTop + viewport.clientHeight >=
|
|
866
|
+
viewport.scrollHeight - threshold;
|
|
867
|
+
this.atBottom.set(isBottom);
|
|
868
|
+
if (isBottom) {
|
|
869
|
+
this.scheduleAutoMarkRead();
|
|
870
|
+
}
|
|
871
|
+
this.positionMentionMenuForCurrentSession();
|
|
872
|
+
}
|
|
873
|
+
onComposerInput(event) {
|
|
874
|
+
const textarea = event.target;
|
|
875
|
+
if (!textarea) {
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
const before = this.composerText();
|
|
879
|
+
const after = textarea.value;
|
|
880
|
+
this.composerText.set(after);
|
|
881
|
+
this.composerMentions.set(this.rebaseMentions(before, after, this.composerMentions()));
|
|
882
|
+
this.updateMentionSession('composer', textarea, after);
|
|
883
|
+
}
|
|
884
|
+
onComposerCaretEvent(event) {
|
|
885
|
+
const textarea = event.target;
|
|
886
|
+
if (!textarea) {
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
this.updateMentionSession('composer', textarea, this.composerText());
|
|
890
|
+
}
|
|
891
|
+
onComposerKeydown(event) {
|
|
892
|
+
if (this.handleMentionKeyboard(event)) {
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
896
|
+
event.preventDefault();
|
|
897
|
+
void this.sendComment();
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
onEditInput(event) {
|
|
901
|
+
const textarea = event.target;
|
|
902
|
+
if (!textarea) {
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
const before = this.editText();
|
|
906
|
+
const after = textarea.value;
|
|
907
|
+
this.editText.set(after);
|
|
908
|
+
this.editMentions.set(this.rebaseMentions(before, after, this.editMentions()));
|
|
909
|
+
this.updateMentionSession('edit', textarea, after);
|
|
910
|
+
}
|
|
911
|
+
onEditCaretEvent(event) {
|
|
912
|
+
const textarea = event.target;
|
|
913
|
+
if (!textarea) {
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
this.updateMentionSession('edit', textarea, this.editText());
|
|
917
|
+
}
|
|
918
|
+
onEditKeydown(event) {
|
|
919
|
+
if (this.handleMentionKeyboard(event)) {
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
if (event.key === 'Escape') {
|
|
923
|
+
event.preventDefault();
|
|
924
|
+
this.cancelEdit();
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
selectMention(user) {
|
|
928
|
+
const session = this.mentionSession();
|
|
929
|
+
if (!session) {
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
if (session.mode === 'edit' &&
|
|
933
|
+
session.editCommentId !== this.editingCommentId()) {
|
|
934
|
+
this.closeMentionMenu();
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
const mentionToken = `@${user.displayName}`;
|
|
938
|
+
const replacement = `${mentionToken} `;
|
|
939
|
+
const currentText = session.mode === 'composer' ? this.composerText() : this.editText();
|
|
940
|
+
const currentMentions = session.mode === 'composer'
|
|
941
|
+
? this.composerMentions()
|
|
942
|
+
: this.editMentions();
|
|
943
|
+
const start = session.triggerIndex;
|
|
944
|
+
const end = session.caretIndex;
|
|
945
|
+
const nextText = currentText.slice(0, start) + replacement + currentText.slice(end);
|
|
946
|
+
const delta = replacement.length - (end - start);
|
|
947
|
+
const shiftedMentions = currentMentions
|
|
948
|
+
.filter((mention) => {
|
|
949
|
+
const mentionEnd = mention.startIndex + mention.length;
|
|
950
|
+
return mentionEnd <= start || mention.startIndex >= end;
|
|
951
|
+
})
|
|
952
|
+
.map((mention) => mention.startIndex >= end
|
|
953
|
+
? {
|
|
954
|
+
...mention,
|
|
955
|
+
startIndex: mention.startIndex + delta,
|
|
956
|
+
}
|
|
957
|
+
: mention);
|
|
958
|
+
const nextMentions = sanitizeMentions([
|
|
959
|
+
...shiftedMentions,
|
|
960
|
+
{
|
|
961
|
+
userId: user.userId,
|
|
962
|
+
startIndex: start,
|
|
963
|
+
length: mentionToken.length,
|
|
964
|
+
},
|
|
965
|
+
], nextText);
|
|
966
|
+
if (session.mode === 'composer') {
|
|
967
|
+
this.composerText.set(nextText);
|
|
968
|
+
this.composerMentions.set(nextMentions);
|
|
969
|
+
}
|
|
970
|
+
else {
|
|
971
|
+
this.editText.set(nextText);
|
|
972
|
+
this.editMentions.set(nextMentions);
|
|
973
|
+
}
|
|
974
|
+
const nextCaret = start + replacement.length;
|
|
975
|
+
this.closeMentionMenu();
|
|
976
|
+
queueMicrotask(() => {
|
|
977
|
+
session.textarea.focus();
|
|
978
|
+
session.textarea.setSelectionRange(nextCaret, nextCaret);
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
toggleParticipantsPanel() {
|
|
982
|
+
this.participantsExpanded.update((value) => !value);
|
|
983
|
+
}
|
|
984
|
+
async openRevisions(comment) {
|
|
985
|
+
this.selectedRevisionComment.set(comment);
|
|
986
|
+
this.revisionsDialogVisible.set(true);
|
|
987
|
+
const existing = this.revisionsByComment().get(comment.id);
|
|
988
|
+
if (existing) {
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
this.revisionLoading.set(true);
|
|
992
|
+
try {
|
|
993
|
+
const revisions = await firstValueFrom(this.api.getCommentRevisions(this.resolvedModuleType(), this.resolvedRecordId(), comment.id, this.requestContext()));
|
|
994
|
+
this.revisionsByComment.update((state) => {
|
|
995
|
+
const next = new Map(state);
|
|
996
|
+
next.set(comment.id, revisions);
|
|
997
|
+
return next;
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
catch (error) {
|
|
1001
|
+
this.handleError(error, 'Unable to load revisions.');
|
|
1002
|
+
}
|
|
1003
|
+
finally {
|
|
1004
|
+
this.revisionLoading.set(false);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
async downloadAttachment(comment, attachment) {
|
|
1008
|
+
try {
|
|
1009
|
+
await firstValueFrom(this.api.downloadAttachment(this.attachmentDownloadEndpoint(), attachment.fileId, attachment.fileName, this.requestContext()));
|
|
1010
|
+
}
|
|
1011
|
+
catch (error) {
|
|
1012
|
+
this.handleError(error, `Unable to download attachment from comment #${comment.id}.`);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
browseAttachments() {
|
|
1016
|
+
if (!this.allowAttachments() || this.disabled()) {
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
this.attachmentInputRef()?.nativeElement.click();
|
|
1020
|
+
}
|
|
1021
|
+
onAttachmentSelected(event) {
|
|
1022
|
+
const target = event.target;
|
|
1023
|
+
const files = target?.files;
|
|
1024
|
+
if (!files?.length) {
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
Array.from(files).forEach((file) => {
|
|
1028
|
+
this.uploadAttachment(file);
|
|
1029
|
+
});
|
|
1030
|
+
if (target) {
|
|
1031
|
+
target.value = '';
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
removeAttachment(draftId) {
|
|
1035
|
+
this.composerAttachments.update((attachments) => attachments.filter((attachment) => attachment.id !== draftId));
|
|
1036
|
+
}
|
|
1037
|
+
uploadAttachment(file) {
|
|
1038
|
+
const draftId = this.generateDraftId(file.name);
|
|
1039
|
+
const draft = {
|
|
1040
|
+
id: draftId,
|
|
1041
|
+
fileId: null,
|
|
1042
|
+
fileName: file.name,
|
|
1043
|
+
contentType: file.type,
|
|
1044
|
+
size: file.size,
|
|
1045
|
+
progress: 0,
|
|
1046
|
+
status: 'uploading',
|
|
1047
|
+
error: null,
|
|
1048
|
+
};
|
|
1049
|
+
this.composerAttachments.update((attachments) => [...attachments, draft]);
|
|
1050
|
+
this.api
|
|
1051
|
+
.uploadAttachment(file, this.uploadEndpoint(), this.requestContext())
|
|
1052
|
+
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
1053
|
+
.subscribe({
|
|
1054
|
+
next: (uploadEvent) => {
|
|
1055
|
+
this.composerAttachments.update((attachments) => attachments.map((item) => {
|
|
1056
|
+
if (item.id !== draftId) {
|
|
1057
|
+
return item;
|
|
1058
|
+
}
|
|
1059
|
+
if (!uploadEvent.completed) {
|
|
1060
|
+
return {
|
|
1061
|
+
...item,
|
|
1062
|
+
progress: uploadEvent.progress,
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
return {
|
|
1066
|
+
...item,
|
|
1067
|
+
progress: 100,
|
|
1068
|
+
status: 'ready',
|
|
1069
|
+
fileId: uploadEvent.file?.fileId ?? item.fileId,
|
|
1070
|
+
fileName: uploadEvent.file?.fileName ?? item.fileName,
|
|
1071
|
+
contentType: uploadEvent.file?.contentType ?? item.contentType ?? '',
|
|
1072
|
+
size: uploadEvent.file?.size ?? item.size,
|
|
1073
|
+
};
|
|
1074
|
+
}));
|
|
1075
|
+
},
|
|
1076
|
+
error: (error) => {
|
|
1077
|
+
const errorMessage = toDiscussionErrorMessage(error, 'Attachment upload failed.');
|
|
1078
|
+
this.composerAttachments.update((attachments) => attachments.map((item) => item.id === draftId
|
|
1079
|
+
? {
|
|
1080
|
+
...item,
|
|
1081
|
+
status: 'failed',
|
|
1082
|
+
error: errorMessage,
|
|
1083
|
+
}
|
|
1084
|
+
: item));
|
|
1085
|
+
this.handleError(error, 'Attachment upload failed.');
|
|
1086
|
+
},
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
async loadThread(options) {
|
|
1090
|
+
if (!options?.quiet) {
|
|
1091
|
+
this.loadingInitial.set(true);
|
|
1092
|
+
}
|
|
1093
|
+
this.errorMessage.set(null);
|
|
1094
|
+
try {
|
|
1095
|
+
const moduleType = this.resolvedModuleType();
|
|
1096
|
+
const recordId = this.resolvedRecordId();
|
|
1097
|
+
const [summary, comments] = await Promise.all([
|
|
1098
|
+
firstValueFrom(this.api.getThreadSummary(moduleType, recordId, this.requestContext())),
|
|
1099
|
+
firstValueFrom(this.api.getComments(moduleType, recordId, this.resolvedPageSize(), undefined, this.requestContext())),
|
|
1100
|
+
]);
|
|
1101
|
+
this.threadSummary.set(summary);
|
|
1102
|
+
this.loaded.emit(summary);
|
|
1103
|
+
this.applyInitialComments(comments);
|
|
1104
|
+
if (this.showParticipants()) {
|
|
1105
|
+
void this.reloadParticipantsQuiet();
|
|
1106
|
+
}
|
|
1107
|
+
else {
|
|
1108
|
+
this.participants.set([]);
|
|
1109
|
+
}
|
|
1110
|
+
await this.refreshReadState(comments);
|
|
1111
|
+
if (!options?.preserveDraft) {
|
|
1112
|
+
this.resetComposer();
|
|
1113
|
+
}
|
|
1114
|
+
this.scheduleScrollToBottom('auto');
|
|
1115
|
+
this.scheduleAutoMarkRead();
|
|
1116
|
+
}
|
|
1117
|
+
catch (error) {
|
|
1118
|
+
this.handleError(error, 'Unable to load discussion.');
|
|
1119
|
+
}
|
|
1120
|
+
finally {
|
|
1121
|
+
this.loadingInitial.set(false);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
async reloadParticipantsQuiet() {
|
|
1125
|
+
this.participantsLoading.set(true);
|
|
1126
|
+
try {
|
|
1127
|
+
const participants = await firstValueFrom(this.api.getParticipants(this.resolvedModuleType(), this.resolvedRecordId(), this.requestContext()));
|
|
1128
|
+
this.participants.set(this.mergeParticipants([], participants));
|
|
1129
|
+
}
|
|
1130
|
+
catch (error) {
|
|
1131
|
+
this.handleError(error, 'Unable to load participants.', false);
|
|
1132
|
+
}
|
|
1133
|
+
finally {
|
|
1134
|
+
this.participantsLoading.set(false);
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
async refreshReadState(commentsPage) {
|
|
1138
|
+
try {
|
|
1139
|
+
const state = await firstValueFrom(this.api.getReadState(this.resolvedModuleType(), this.resolvedRecordId(), this.requestContext()));
|
|
1140
|
+
this.readState.set(state);
|
|
1141
|
+
this.readStateChanged.emit(state);
|
|
1142
|
+
this.patchSummaryRead(state);
|
|
1143
|
+
}
|
|
1144
|
+
catch {
|
|
1145
|
+
if (commentsPage) {
|
|
1146
|
+
const fallback = this.toReadStateFromPage(commentsPage);
|
|
1147
|
+
this.readState.set(fallback);
|
|
1148
|
+
this.readStateChanged.emit(fallback);
|
|
1149
|
+
this.patchSummaryRead(fallback);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
resetData() {
|
|
1154
|
+
this.threadSummary.set(null);
|
|
1155
|
+
this.commentsDesc.set([]);
|
|
1156
|
+
this.participants.set([]);
|
|
1157
|
+
this.readState.set(null);
|
|
1158
|
+
this.nextCursor.set(null);
|
|
1159
|
+
this.hasMore.set(false);
|
|
1160
|
+
this.cancelEdit();
|
|
1161
|
+
this.resetComposer();
|
|
1162
|
+
this.participantsExpanded.set(false);
|
|
1163
|
+
this.revisionsDialogVisible.set(false);
|
|
1164
|
+
this.selectedRevisionComment.set(null);
|
|
1165
|
+
this.revisionsByComment.set(new Map());
|
|
1166
|
+
}
|
|
1167
|
+
resetComposer() {
|
|
1168
|
+
this.composerText.set('');
|
|
1169
|
+
this.composerMentions.set([]);
|
|
1170
|
+
this.composerAttachments.set([]);
|
|
1171
|
+
this.replyToCommentId.set(null);
|
|
1172
|
+
this.closeMentionMenu();
|
|
1173
|
+
}
|
|
1174
|
+
applyInitialComments(page) {
|
|
1175
|
+
this.commentsDesc.set(this.normalizeComments(page.items));
|
|
1176
|
+
this.applyCommentsPageMeta(page);
|
|
1177
|
+
}
|
|
1178
|
+
applyCommentsPageMeta(page) {
|
|
1179
|
+
this.nextCursor.set(page.nextCursor ?? null);
|
|
1180
|
+
this.hasMore.set(page.hasMore);
|
|
1181
|
+
this.readState.update((state) => {
|
|
1182
|
+
if (!state) {
|
|
1183
|
+
return state;
|
|
1184
|
+
}
|
|
1185
|
+
return {
|
|
1186
|
+
...state,
|
|
1187
|
+
lastReadCommentId: page.lastReadCommentId,
|
|
1188
|
+
lastReadCommentCreatedAt: page.lastReadCommentCreatedAt,
|
|
1189
|
+
lastReadAt: page.lastReadAt,
|
|
1190
|
+
unreadCount: page.unreadCount,
|
|
1191
|
+
hasUnread: page.hasUnread,
|
|
1192
|
+
};
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
normalizeComments(comments) {
|
|
1196
|
+
return [...comments]
|
|
1197
|
+
.map((comment) => ({
|
|
1198
|
+
...comment,
|
|
1199
|
+
attachments: [...(comment.attachments ?? [])].sort((left, right) => left.id - right.id),
|
|
1200
|
+
mentions: sanitizeMentions(comment.mentions ?? [], comment.comment),
|
|
1201
|
+
}))
|
|
1202
|
+
.sort((left, right) => {
|
|
1203
|
+
if (left.createdAt !== right.createdAt) {
|
|
1204
|
+
return left.createdAt < right.createdAt ? 1 : -1;
|
|
1205
|
+
}
|
|
1206
|
+
return right.id - left.id;
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
rebaseMentions(previousText, currentText, mentions) {
|
|
1210
|
+
if (previousText === currentText) {
|
|
1211
|
+
return sanitizeMentions(mentions, currentText);
|
|
1212
|
+
}
|
|
1213
|
+
const mutation = this.computeTextMutation(previousText, currentText);
|
|
1214
|
+
const adjusted = mentions
|
|
1215
|
+
.map((mention) => {
|
|
1216
|
+
const mentionStart = mention.startIndex;
|
|
1217
|
+
const mentionEnd = mention.startIndex + mention.length;
|
|
1218
|
+
if (mentionEnd <= mutation.start) {
|
|
1219
|
+
return mention;
|
|
1220
|
+
}
|
|
1221
|
+
if (mentionStart >= mutation.beforeEnd) {
|
|
1222
|
+
return {
|
|
1223
|
+
...mention,
|
|
1224
|
+
startIndex: mentionStart + mutation.delta,
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
return null;
|
|
1228
|
+
})
|
|
1229
|
+
.filter((mention) => mention != null);
|
|
1230
|
+
return sanitizeMentions(adjusted, currentText);
|
|
1231
|
+
}
|
|
1232
|
+
computeTextMutation(before, after) {
|
|
1233
|
+
let start = 0;
|
|
1234
|
+
while (start < before.length &&
|
|
1235
|
+
start < after.length &&
|
|
1236
|
+
before[start] === after[start]) {
|
|
1237
|
+
start += 1;
|
|
1238
|
+
}
|
|
1239
|
+
let beforeEnd = before.length;
|
|
1240
|
+
let afterEnd = after.length;
|
|
1241
|
+
while (beforeEnd > start &&
|
|
1242
|
+
afterEnd > start &&
|
|
1243
|
+
before[beforeEnd - 1] === after[afterEnd - 1]) {
|
|
1244
|
+
beforeEnd -= 1;
|
|
1245
|
+
afterEnd -= 1;
|
|
1246
|
+
}
|
|
1247
|
+
return {
|
|
1248
|
+
start,
|
|
1249
|
+
beforeEnd,
|
|
1250
|
+
delta: afterEnd - beforeEnd,
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
normalizePageSize(value) {
|
|
1254
|
+
const parsed = Number(value);
|
|
1255
|
+
if (!Number.isFinite(parsed)) {
|
|
1256
|
+
return 30;
|
|
1257
|
+
}
|
|
1258
|
+
return Math.min(100, Math.max(1, Math.floor(parsed)));
|
|
1259
|
+
}
|
|
1260
|
+
isCursorError(error) {
|
|
1261
|
+
const message = toDiscussionErrorMessage(error, '').toLowerCase();
|
|
1262
|
+
return message.includes('cursor');
|
|
1263
|
+
}
|
|
1264
|
+
updateMentionSession(mode, textarea, text) {
|
|
1265
|
+
const caretIndex = textarea.selectionStart ?? text.length;
|
|
1266
|
+
const textBeforeCaret = text.slice(0, caretIndex);
|
|
1267
|
+
const mentionMatch = textBeforeCaret.match(/(^|\s)@([^\s@]{0,64})$/);
|
|
1268
|
+
if (!mentionMatch) {
|
|
1269
|
+
if (this.mentionSession()?.mode === mode) {
|
|
1270
|
+
this.closeMentionMenu();
|
|
1271
|
+
}
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
const query = mentionMatch[2] ?? '';
|
|
1275
|
+
const triggerIndex = caretIndex - query.length - 1;
|
|
1276
|
+
this.mentionSession.set({
|
|
1277
|
+
mode,
|
|
1278
|
+
triggerIndex,
|
|
1279
|
+
caretIndex,
|
|
1280
|
+
query,
|
|
1281
|
+
textarea,
|
|
1282
|
+
editCommentId: mode === 'edit' ? this.editingCommentId() : null,
|
|
1283
|
+
});
|
|
1284
|
+
this.mentionActiveIndex.set(0);
|
|
1285
|
+
this.positionMentionMenu(textarea, caretIndex);
|
|
1286
|
+
this.searchMentionCandidates(query);
|
|
1287
|
+
}
|
|
1288
|
+
searchMentionCandidates(query) {
|
|
1289
|
+
const normalizedQuery = query.trim().toLowerCase();
|
|
1290
|
+
const localUsers = this.mentionPool()
|
|
1291
|
+
.filter((user) => this.matchesMentionQuery(user, normalizedQuery))
|
|
1292
|
+
.slice(0, 12);
|
|
1293
|
+
this.mentionCandidates.set(localUsers);
|
|
1294
|
+
const endpoint = this.mentionSearchEndpoint().trim();
|
|
1295
|
+
if (!endpoint || !normalizedQuery) {
|
|
1296
|
+
this.mentionLoading.set(false);
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
this.mentionSearchTerms.next({
|
|
1300
|
+
query: normalizedQuery,
|
|
1301
|
+
localUsers,
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
matchesMentionQuery(user, query) {
|
|
1305
|
+
if (!query) {
|
|
1306
|
+
return true;
|
|
1307
|
+
}
|
|
1308
|
+
const haystack = [user.userId, user.displayName, user.userName ?? '']
|
|
1309
|
+
.join(' ')
|
|
1310
|
+
.toLowerCase();
|
|
1311
|
+
return haystack.includes(query);
|
|
1312
|
+
}
|
|
1313
|
+
bindMentionSearch() {
|
|
1314
|
+
this.mentionSearchTerms
|
|
1315
|
+
.pipe(debounceTime(220), distinctUntilChanged((left, right) => left.query === right.query), switchMap((value) => {
|
|
1316
|
+
const endpoint = this.mentionSearchEndpoint().trim();
|
|
1317
|
+
if (!endpoint || !value.query) {
|
|
1318
|
+
return of(value.localUsers);
|
|
1319
|
+
}
|
|
1320
|
+
this.mentionLoading.set(true);
|
|
1321
|
+
return this.api
|
|
1322
|
+
.searchMentionUsers(endpoint, value.query, this.mentionSearchParam(), this.mentionSearchDataPath(), this.requestContext())
|
|
1323
|
+
.pipe(map((remoteUsers) => dedupeMentionUsers([...value.localUsers, ...remoteUsers]).slice(0, 12)), catchError(() => of(value.localUsers)), finalize(() => this.mentionLoading.set(false)));
|
|
1324
|
+
}), takeUntilDestroyed(this.destroyRef))
|
|
1325
|
+
.subscribe((candidates) => {
|
|
1326
|
+
if (!this.mentionSession()) {
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
this.mentionCandidates.set(candidates);
|
|
1330
|
+
this.mentionActiveIndex.set(0);
|
|
1331
|
+
this.positionMentionMenuForCurrentSession();
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
handleMentionKeyboard(event) {
|
|
1335
|
+
const session = this.mentionSession();
|
|
1336
|
+
if (!session) {
|
|
1337
|
+
return false;
|
|
1338
|
+
}
|
|
1339
|
+
const candidates = this.mentionCandidates();
|
|
1340
|
+
if (!candidates.length) {
|
|
1341
|
+
if (event.key === 'Escape') {
|
|
1342
|
+
event.preventDefault();
|
|
1343
|
+
this.closeMentionMenu();
|
|
1344
|
+
return true;
|
|
1345
|
+
}
|
|
1346
|
+
return false;
|
|
1347
|
+
}
|
|
1348
|
+
if (event.key === 'ArrowDown') {
|
|
1349
|
+
event.preventDefault();
|
|
1350
|
+
this.mentionActiveIndex.update((index) => (index + 1) % this.mentionCandidates().length);
|
|
1351
|
+
return true;
|
|
1352
|
+
}
|
|
1353
|
+
if (event.key === 'ArrowUp') {
|
|
1354
|
+
event.preventDefault();
|
|
1355
|
+
this.mentionActiveIndex.update((index) => {
|
|
1356
|
+
const total = this.mentionCandidates().length;
|
|
1357
|
+
return (index - 1 + total) % total;
|
|
1358
|
+
});
|
|
1359
|
+
return true;
|
|
1360
|
+
}
|
|
1361
|
+
if (event.key === 'Enter' || event.key === 'Tab') {
|
|
1362
|
+
event.preventDefault();
|
|
1363
|
+
const candidate = this.mentionCandidates()[this.mentionActiveIndex()];
|
|
1364
|
+
if (candidate) {
|
|
1365
|
+
this.selectMention(candidate);
|
|
1366
|
+
}
|
|
1367
|
+
return true;
|
|
1368
|
+
}
|
|
1369
|
+
if (event.key === 'Escape') {
|
|
1370
|
+
event.preventDefault();
|
|
1371
|
+
this.closeMentionMenu();
|
|
1372
|
+
return true;
|
|
1373
|
+
}
|
|
1374
|
+
return false;
|
|
1375
|
+
}
|
|
1376
|
+
closeMentionMenu() {
|
|
1377
|
+
this.mentionSession.set(null);
|
|
1378
|
+
this.mentionCandidates.set([]);
|
|
1379
|
+
this.mentionActiveIndex.set(0);
|
|
1380
|
+
this.mentionLoading.set(false);
|
|
1381
|
+
this.mentionMenuPosition.set(null);
|
|
1382
|
+
}
|
|
1383
|
+
positionMentionMenuForCurrentSession() {
|
|
1384
|
+
const session = this.mentionSession();
|
|
1385
|
+
if (!session) {
|
|
1386
|
+
this.mentionMenuPosition.set(null);
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
this.positionMentionMenu(session.textarea, session.caretIndex);
|
|
1390
|
+
}
|
|
1391
|
+
positionMentionMenu(textarea, caretIndex) {
|
|
1392
|
+
const wrapper = textarea.parentElement;
|
|
1393
|
+
if (!wrapper) {
|
|
1394
|
+
this.mentionMenuPosition.set(null);
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
const caret = this.measureMentionCaret(textarea, caretIndex);
|
|
1398
|
+
if (!caret) {
|
|
1399
|
+
this.mentionMenuPosition.set(null);
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
const wrapperRect = wrapper.getBoundingClientRect();
|
|
1403
|
+
const textareaRect = textarea.getBoundingClientRect();
|
|
1404
|
+
const anchorViewportX = textareaRect.left + caret.x - textarea.scrollLeft;
|
|
1405
|
+
const anchorViewportY = textareaRect.top + caret.y - textarea.scrollTop;
|
|
1406
|
+
const menuWidth = Math.min(this.mentionMenuMaxWidthPx, window.innerWidth * 0.9);
|
|
1407
|
+
const belowTopViewport = anchorViewportY + caret.lineHeight + 6;
|
|
1408
|
+
const availableBelow = window.innerHeight - belowTopViewport - this.mentionMenuViewportPaddingPx;
|
|
1409
|
+
const availableAbove = anchorViewportY - this.mentionMenuViewportPaddingPx;
|
|
1410
|
+
const placeAbove = availableBelow < 180 && availableAbove > availableBelow;
|
|
1411
|
+
const resolvedMaxHeight = Math.max(120, Math.min(this.mentionMenuPreferredHeightPx, (placeAbove ? availableAbove : availableBelow) - 10));
|
|
1412
|
+
const desiredLeft = anchorViewportX;
|
|
1413
|
+
const clampedLeft = Math.min(Math.max(this.mentionMenuViewportPaddingPx, desiredLeft), window.innerWidth - menuWidth - this.mentionMenuViewportPaddingPx);
|
|
1414
|
+
const topBelow = belowTopViewport;
|
|
1415
|
+
const topAbove = anchorViewportY - resolvedMaxHeight - 8;
|
|
1416
|
+
const clampedTop = placeAbove
|
|
1417
|
+
? Math.max(this.mentionMenuViewportPaddingPx, topAbove)
|
|
1418
|
+
: Math.max(this.mentionMenuViewportPaddingPx, topBelow);
|
|
1419
|
+
this.mentionMenuPosition.set({
|
|
1420
|
+
left: clampedLeft - wrapperRect.left,
|
|
1421
|
+
top: clampedTop - wrapperRect.top,
|
|
1422
|
+
width: menuWidth,
|
|
1423
|
+
maxHeight: resolvedMaxHeight,
|
|
1424
|
+
placement: placeAbove ? 'above' : 'below',
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
measureMentionCaret(textarea, caretIndex) {
|
|
1428
|
+
const style = window.getComputedStyle(textarea);
|
|
1429
|
+
const mirror = document.createElement('div');
|
|
1430
|
+
const marker = document.createElement('span');
|
|
1431
|
+
mirror.style.position = 'fixed';
|
|
1432
|
+
mirror.style.left = '-9999px';
|
|
1433
|
+
mirror.style.top = '0';
|
|
1434
|
+
mirror.style.visibility = 'hidden';
|
|
1435
|
+
mirror.style.pointerEvents = 'none';
|
|
1436
|
+
mirror.style.whiteSpace = 'pre-wrap';
|
|
1437
|
+
mirror.style.overflowWrap = 'break-word';
|
|
1438
|
+
mirror.style.wordWrap = 'break-word';
|
|
1439
|
+
mirror.style.boxSizing = style.boxSizing;
|
|
1440
|
+
mirror.style.width = `${textarea.clientWidth}px`;
|
|
1441
|
+
mirror.style.padding = style.padding;
|
|
1442
|
+
mirror.style.border = style.border;
|
|
1443
|
+
mirror.style.font = style.font;
|
|
1444
|
+
mirror.style.fontSize = style.fontSize;
|
|
1445
|
+
mirror.style.fontFamily = style.fontFamily;
|
|
1446
|
+
mirror.style.fontWeight = style.fontWeight;
|
|
1447
|
+
mirror.style.letterSpacing = style.letterSpacing;
|
|
1448
|
+
mirror.style.lineHeight = style.lineHeight;
|
|
1449
|
+
mirror.style.textAlign = style.textAlign;
|
|
1450
|
+
mirror.style.textTransform = style.textTransform;
|
|
1451
|
+
mirror.style.textIndent = style.textIndent;
|
|
1452
|
+
mirror.style.tabSize = style.tabSize;
|
|
1453
|
+
const safeCaret = Math.max(0, Math.min(caretIndex, textarea.value.length));
|
|
1454
|
+
const beforeCaret = textarea.value.slice(0, safeCaret);
|
|
1455
|
+
mirror.textContent = beforeCaret;
|
|
1456
|
+
marker.textContent = '\u200b';
|
|
1457
|
+
mirror.appendChild(marker);
|
|
1458
|
+
document.body.appendChild(mirror);
|
|
1459
|
+
try {
|
|
1460
|
+
const mirrorRect = mirror.getBoundingClientRect();
|
|
1461
|
+
const markerRect = marker.getBoundingClientRect();
|
|
1462
|
+
return {
|
|
1463
|
+
x: markerRect.left - mirrorRect.left,
|
|
1464
|
+
y: markerRect.top - mirrorRect.top,
|
|
1465
|
+
lineHeight: this.resolveMentionLineHeight(style),
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
finally {
|
|
1469
|
+
document.body.removeChild(mirror);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
resolveMentionLineHeight(style) {
|
|
1473
|
+
const lineHeightRaw = style.lineHeight.trim().toLowerCase();
|
|
1474
|
+
if (lineHeightRaw.endsWith('px')) {
|
|
1475
|
+
const parsed = Number.parseFloat(lineHeightRaw);
|
|
1476
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
1477
|
+
return parsed;
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
const fontSizeRaw = style.fontSize.trim().toLowerCase();
|
|
1481
|
+
const fontSize = fontSizeRaw.endsWith('px')
|
|
1482
|
+
? Number.parseFloat(fontSizeRaw)
|
|
1483
|
+
: 14;
|
|
1484
|
+
return Number.isFinite(fontSize) && fontSize > 0 ? fontSize * 1.4 : 20;
|
|
1485
|
+
}
|
|
1486
|
+
getParticipantDisplayName(participant) {
|
|
1487
|
+
return (participant.user?.displayName?.trim() ||
|
|
1488
|
+
participant.user?.fullName?.trim() ||
|
|
1489
|
+
participant.user?.userName?.trim() ||
|
|
1490
|
+
participant.userId);
|
|
1491
|
+
}
|
|
1492
|
+
mapParticipantToEntityUserValue(participant) {
|
|
1493
|
+
const user = participant.user ?? participant.addedByUser ?? null;
|
|
1494
|
+
return {
|
|
1495
|
+
id: user?.id ?? participant.userId,
|
|
1496
|
+
userName: user?.userName ?? undefined,
|
|
1497
|
+
displayName: this.getParticipantDisplayName(participant),
|
|
1498
|
+
photoUrl: user?.photoUrl ?? undefined,
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
mergeParticipants(current, incoming) {
|
|
1502
|
+
const byUserId = new Map();
|
|
1503
|
+
[...current, ...incoming].forEach((participant) => {
|
|
1504
|
+
const key = participant.userId.toLowerCase();
|
|
1505
|
+
if (!byUserId.has(key)) {
|
|
1506
|
+
byUserId.set(key, participant);
|
|
1507
|
+
}
|
|
1508
|
+
});
|
|
1509
|
+
return [...byUserId.values()].sort((left, right) => this.getParticipantDisplayName(left).localeCompare(this.getParticipantDisplayName(right)));
|
|
1510
|
+
}
|
|
1511
|
+
toReadStateFromPage(page) {
|
|
1512
|
+
const moduleType = this.resolvedModuleType();
|
|
1513
|
+
const recordId = this.resolvedRecordId();
|
|
1514
|
+
return {
|
|
1515
|
+
threadKey: buildDiscussionThreadKey(moduleType, recordId),
|
|
1516
|
+
moduleType,
|
|
1517
|
+
recordId,
|
|
1518
|
+
userId: this.currentUserId(),
|
|
1519
|
+
lastReadCommentId: page.lastReadCommentId,
|
|
1520
|
+
lastReadCommentCreatedAt: page.lastReadCommentCreatedAt,
|
|
1521
|
+
lastReadAt: page.lastReadAt,
|
|
1522
|
+
unreadCount: page.unreadCount,
|
|
1523
|
+
hasUnread: page.hasUnread,
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
resolveFirstUnreadCommentId(commentsAsc, readState) {
|
|
1527
|
+
if (!commentsAsc.length || !readState?.hasUnread) {
|
|
1528
|
+
return null;
|
|
1529
|
+
}
|
|
1530
|
+
const pointerCreatedAt = readState.lastReadCommentCreatedAt;
|
|
1531
|
+
const pointerId = readState.lastReadCommentId;
|
|
1532
|
+
if (!pointerCreatedAt && pointerId == null) {
|
|
1533
|
+
return commentsAsc[0]?.id ?? null;
|
|
1534
|
+
}
|
|
1535
|
+
for (const comment of commentsAsc) {
|
|
1536
|
+
if (this.isCommentAfterPointer(comment, pointerCreatedAt, pointerId)) {
|
|
1537
|
+
return comment.id;
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
return null;
|
|
1541
|
+
}
|
|
1542
|
+
isCommentAfterPointer(comment, pointerCreatedAt, pointerId) {
|
|
1543
|
+
if (!pointerCreatedAt && pointerId == null) {
|
|
1544
|
+
return true;
|
|
1545
|
+
}
|
|
1546
|
+
if (pointerCreatedAt) {
|
|
1547
|
+
if (comment.createdAt > pointerCreatedAt) {
|
|
1548
|
+
return true;
|
|
1549
|
+
}
|
|
1550
|
+
if (comment.createdAt < pointerCreatedAt) {
|
|
1551
|
+
return false;
|
|
1552
|
+
}
|
|
1553
|
+
return pointerId != null ? comment.id > pointerId : false;
|
|
1554
|
+
}
|
|
1555
|
+
return pointerId != null ? comment.id > pointerId : false;
|
|
1556
|
+
}
|
|
1557
|
+
focusComposer() {
|
|
1558
|
+
queueMicrotask(() => {
|
|
1559
|
+
this.composerInputRef()?.nativeElement.focus();
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
scheduleScrollToBottom(behavior) {
|
|
1563
|
+
setTimeout(() => {
|
|
1564
|
+
const viewport = this.viewportRef()?.nativeElement;
|
|
1565
|
+
if (!viewport) {
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
viewport.scrollTo({
|
|
1569
|
+
top: viewport.scrollHeight,
|
|
1570
|
+
behavior,
|
|
1571
|
+
});
|
|
1572
|
+
this.atBottom.set(true);
|
|
1573
|
+
}, 0);
|
|
1574
|
+
}
|
|
1575
|
+
scheduleAutoMarkRead() {
|
|
1576
|
+
if (!this.autoMarkRead() || !this.hasUnread() || !this.atBottom()) {
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
if (this.autoReadTimer) {
|
|
1580
|
+
clearTimeout(this.autoReadTimer);
|
|
1581
|
+
}
|
|
1582
|
+
this.autoReadTimer = setTimeout(() => {
|
|
1583
|
+
this.autoReadTimer = null;
|
|
1584
|
+
void this.markRead();
|
|
1585
|
+
}, 300);
|
|
1586
|
+
}
|
|
1587
|
+
patchSummaryTotal(delta, lastCommentAt) {
|
|
1588
|
+
this.threadSummary.update((summary) => {
|
|
1589
|
+
if (!summary) {
|
|
1590
|
+
return summary;
|
|
1591
|
+
}
|
|
1592
|
+
return {
|
|
1593
|
+
...summary,
|
|
1594
|
+
totalComments: Math.max(0, summary.totalComments + delta),
|
|
1595
|
+
lastCommentAt: lastCommentAt ?? summary.lastCommentAt,
|
|
1596
|
+
};
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
patchSummaryRead(readState) {
|
|
1600
|
+
this.threadSummary.update((summary) => {
|
|
1601
|
+
if (!summary) {
|
|
1602
|
+
return summary;
|
|
1603
|
+
}
|
|
1604
|
+
return {
|
|
1605
|
+
...summary,
|
|
1606
|
+
lastReadCommentId: readState.lastReadCommentId,
|
|
1607
|
+
lastReadCommentCreatedAt: readState.lastReadCommentCreatedAt,
|
|
1608
|
+
lastReadAt: readState.lastReadAt,
|
|
1609
|
+
unreadCount: readState.unreadCount,
|
|
1610
|
+
hasUnread: readState.hasUnread,
|
|
1611
|
+
};
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1614
|
+
handleError(error, fallbackMessage, setGlobalError = true) {
|
|
1615
|
+
const message = toDiscussionErrorMessage(error, fallbackMessage);
|
|
1616
|
+
if (setGlobalError) {
|
|
1617
|
+
this.errorMessage.set(message);
|
|
1618
|
+
}
|
|
1619
|
+
this.errored.emit(message);
|
|
1620
|
+
}
|
|
1621
|
+
generateDraftId(fileName) {
|
|
1622
|
+
if (typeof crypto !== 'undefined' &&
|
|
1623
|
+
typeof crypto.randomUUID === 'function') {
|
|
1624
|
+
return `${fileName}-${crypto.randomUUID()}`;
|
|
1625
|
+
}
|
|
1626
|
+
return `${fileName}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
1627
|
+
}
|
|
1628
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: DiscussionThread, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1629
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.0.3", type: DiscussionThread, isStandalone: true, selector: "mt-discussion-thread", inputs: { moduleType: { classPropertyName: "moduleType", publicName: "moduleType", isSignal: true, isRequired: true, transformFunction: null }, recordId: { classPropertyName: "recordId", publicName: "recordId", isSignal: true, isRequired: true, transformFunction: null }, title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, subtitle: { classPropertyName: "subtitle", publicName: "subtitle", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, pageSize: { classPropertyName: "pageSize", publicName: "pageSize", isSignal: true, isRequired: false, transformFunction: null }, currentUserId: { classPropertyName: "currentUserId", publicName: "currentUserId", isSignal: true, isRequired: false, transformFunction: null }, requestContext: { classPropertyName: "requestContext", publicName: "requestContext", isSignal: true, isRequired: false, transformFunction: null }, mentionableUsers: { classPropertyName: "mentionableUsers", publicName: "mentionableUsers", isSignal: true, isRequired: false, transformFunction: null }, mentionSearchEndpoint: { classPropertyName: "mentionSearchEndpoint", publicName: "mentionSearchEndpoint", isSignal: true, isRequired: false, transformFunction: null }, mentionSearchParam: { classPropertyName: "mentionSearchParam", publicName: "mentionSearchParam", isSignal: true, isRequired: false, transformFunction: null }, mentionSearchDataPath: { classPropertyName: "mentionSearchDataPath", publicName: "mentionSearchDataPath", isSignal: true, isRequired: false, transformFunction: null }, allowAttachments: { classPropertyName: "allowAttachments", publicName: "allowAttachments", isSignal: true, isRequired: false, transformFunction: null }, uploadEndpoint: { classPropertyName: "uploadEndpoint", publicName: "uploadEndpoint", isSignal: true, isRequired: false, transformFunction: null }, attachmentDownloadEndpoint: { classPropertyName: "attachmentDownloadEndpoint", publicName: "attachmentDownloadEndpoint", isSignal: true, isRequired: false, transformFunction: null }, showParticipants: { classPropertyName: "showParticipants", publicName: "showParticipants", isSignal: true, isRequired: false, transformFunction: null }, autoMarkRead: { classPropertyName: "autoMarkRead", publicName: "autoMarkRead", isSignal: true, isRequired: false, transformFunction: null }, refreshIntervalMs: { classPropertyName: "refreshIntervalMs", publicName: "refreshIntervalMs", isSignal: true, isRequired: false, transformFunction: null }, styleClass: { classPropertyName: "styleClass", publicName: "styleClass", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { loaded: "loaded", errored: "errored", commentCreated: "commentCreated", commentUpdated: "commentUpdated", commentDeleted: "commentDeleted", readStateChanged: "readStateChanged" }, host: { classAttribute: "block h-full min-h-0" }, viewQueries: [{ propertyName: "viewportRef", first: true, predicate: ["viewport"], descendants: true, isSignal: true }, { propertyName: "composerInputRef", first: true, predicate: ["composerInput"], descendants: true, isSignal: true }, { propertyName: "attachmentInputRef", first: true, predicate: ["attachmentInput"], descendants: true, isSignal: true }], ngImport: i0, template: "<mt-card\n class=\"mt-discussion-card h-full min-h-0 w-full overflow-hidden shadow-sm\"\n [paddingless]=\"true\"\n [ngClass]=\"styleClass()\"\n>\n <div class=\"flex h-full min-h-0 flex-col overflow-hidden\">\n <input\n #attachmentInput\n type=\"file\"\n class=\"hidden\"\n multiple\n (change)=\"onAttachmentSelected($event)\"\n />\n\n <header\n class=\"flex flex-wrap items-start justify-between gap-3 border-b border-surface-200 px-4 py-3\"\n >\n <div class=\"min-w-0\">\n <h3 class=\"truncate text-sm font-semibold uppercase tracking-[0.08em]\">\n {{ title() }}\n </h3>\n @if (subtitle()) {\n <p class=\"mt-1 text-xs text-surface-500\">{{ subtitle() }}</p>\n }\n <p class=\"mt-1 text-xs text-surface-500\">\n {{ threadKey() || \"Discussion\" }}\n </p>\n </div>\n\n <div class=\"flex flex-wrap items-center gap-2\">\n @if (unreadCount() > 0) {\n <span\n class=\"inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-semibold text-red-700\"\n >\n {{ unreadCount() }} unread\n </span>\n } @else {\n <span\n class=\"inline-flex items-center rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-semibold text-emerald-700\"\n >\n Up to date\n </span>\n }\n\n <mt-button\n icon=\"arrow.refresh-cw-01\"\n [label]=\"refreshing() ? 'Refreshing...' : 'Refresh'\"\n size=\"small\"\n [outlined]=\"true\"\n [loading]=\"refreshing()\"\n [disabled]=\"loadingInitial()\"\n (onClick)=\"refreshThread()\"\n />\n <mt-button\n icon=\"general.check\"\n label=\"Mark read\"\n size=\"small\"\n [outlined]=\"true\"\n [disabled]=\"markingRead() || !hasUnread()\"\n (onClick)=\"markRead()\"\n />\n <mt-button\n icon=\"communication.mail-01\"\n label=\"Mark unread\"\n size=\"small\"\n [outlined]=\"true\"\n [disabled]=\"markingRead()\"\n (onClick)=\"markUnread()\"\n />\n @if (showParticipants()) {\n <mt-button\n icon=\"user.users-01\"\n [label]=\"'Participants (' + participants().length + ')'\"\n size=\"small\"\n [outlined]=\"true\"\n (onClick)=\"toggleParticipantsPanel()\"\n />\n }\n </div>\n </header>\n\n @if (showParticipants() && participantsExpanded()) {\n <section class=\"border-b border-surface-200 px-4 py-3\">\n @if (participantsLoading()) {\n <span class=\"text-xs text-surface-500\">Loading participants...</span>\n } @else if (participantEntities().length === 0) {\n <span class=\"text-xs text-surface-500\">No participants yet.</span>\n } @else {\n <div class=\"max-h-48 overflow-y-auto pr-1\">\n <mt-entities-preview [entities]=\"participantEntities()\" />\n </div>\n }\n </section>\n }\n\n @if (errorMessage(); as error) {\n <div\n class=\"border-b border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700\"\n >\n {{ error }}\n </div>\n }\n\n <div class=\"flex min-h-0 flex-1 flex-col overflow-hidden\">\n <div\n #viewport\n class=\"min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain px-3 py-3\"\n (scroll)=\"onViewportScroll()\"\n >\n @if (hasMore()) {\n <div class=\"flex justify-center py-1\">\n <mt-button\n icon=\"arrow.chevron-up\"\n [label]=\"loadingMore() ? 'Loading...' : 'Load older messages'\"\n size=\"small\"\n [outlined]=\"true\"\n [loading]=\"loadingMore()\"\n (onClick)=\"loadOlder()\"\n />\n </div>\n }\n\n @if (loadingInitial()) {\n <div class=\"space-y-2\">\n @for (item of [1, 2, 3, 4]; track item) {\n <div class=\"h-16 animate-pulse rounded-xl bg-surface-100\"></div>\n }\n </div>\n } @else if (commentsAsc().length === 0) {\n <div\n class=\"rounded-2xl border border-dashed border-surface-300 p-8 text-center\"\n >\n <h4 class=\"text-sm font-semibold text-surface-700\">\n No comments yet\n </h4>\n <p class=\"mt-1 text-xs text-surface-500\">Start the conversation.</p>\n </div>\n } @else {\n @for (comment of commentsAsc(); track trackComment($index, comment)) {\n @if (firstUnreadCommentId() === comment.id) {\n <div class=\"my-1 flex items-center justify-center gap-2\">\n <span\n class=\"flex-1 border-t border-dashed border-red-300\"\n ></span>\n <span\n class=\"rounded-full border border-red-200 bg-red-50 px-2 py-0.5 text-[0.67rem] font-bold leading-none text-red-700\"\n >Unread messages</span\n >\n <span\n class=\"flex-1 border-t border-dashed border-red-300\"\n ></span>\n </div>\n }\n\n <article\n class=\"group flex gap-2\"\n [class.justify-end]=\"isOwnComment(comment)\"\n >\n @if (!isOwnComment(comment)) {\n <div\n class=\"mt-0.5 inline-flex h-[1.9rem] w-[1.9rem] shrink-0 items-center justify-center rounded-full border border-[color-mix(in_srgb,var(--p-primary-color)_20%,transparent)] bg-[color-mix(in_srgb,var(--p-primary-color)_16%,white)] text-[0.74rem] font-bold text-[color-mix(in_srgb,var(--p-primary-color)_75%,black)]\"\n >\n {{ getAvatarText(comment) }}\n </div>\n }\n\n <div\n class=\"max-w-[88%] min-w-[16rem] rounded-2xl border border-surface-200 px-3 py-2\"\n [class.bg-[color-mix(in_srgb,var(--p-primary-color)_7%,white)]]=\"\n isOwnComment(comment)\n \"\n >\n <div\n class=\"mb-1 flex flex-wrap items-center justify-between gap-2\"\n >\n <div class=\"text-xs text-surface-600\">\n <span class=\"font-semibold text-surface-900\">{{\n comment.createdBy\n }}</span>\n <span class=\"mx-1\">•</span>\n <span>{{\n comment.createdAt | date: \"MMM d, y h:mm a\"\n }}</span>\n @if (comment.updatedAt) {\n <span class=\"ml-1 text-[11px] text-surface-500\"\n >(edited)</span\n >\n }\n </div>\n </div>\n\n @if (getParentComment(comment); as parentComment) {\n <button\n type=\"button\"\n class=\"mb-2 block w-full rounded-xl border border-surface-200 bg-surface-50 px-2 py-1 text-left text-xs text-surface-600\"\n (click)=\"openReply(parentComment)\"\n >\n <span class=\"font-semibold\">{{\n parentComment.createdBy\n }}</span>\n <span class=\"mx-1\">:</span>\n <span class=\"line-clamp-1\">{{\n parentComment.comment\n }}</span>\n </button>\n }\n\n @if (editingCommentId() === comment.id) {\n <div class=\"relative\">\n <textarea\n rows=\"3\"\n class=\"w-full min-h-[5.25rem] resize-y rounded-2xl border border-surface-300 bg-white px-3 py-2.5 text-[0.86rem] leading-[1.45] text-surface-900 outline-none focus-visible:outline-2 focus-visible:outline-[color-mix(in_srgb,var(--p-primary-color)_32%,transparent)] focus-visible:outline-offset-1\"\n [ngModel]=\"editText()\"\n (ngModelChange)=\"editText.set($event)\"\n (input)=\"onEditInput($event)\"\n (keyup)=\"onEditCaretEvent($event)\"\n (click)=\"onEditCaretEvent($event)\"\n (scroll)=\"onEditCaretEvent($event)\"\n (keydown)=\"onEditKeydown($event)\"\n ></textarea>\n\n @if (\n mentionSession()?.mode === \"edit\" &&\n mentionSession()?.editCommentId === comment.id\n ) {\n <div\n class=\"absolute top-0 left-0 z-20 min-w-56 overflow-auto rounded-xl border border-surface-300 bg-white shadow-[0_10px_40px_color-mix(in_srgb,black_14%,transparent)]\"\n [ngStyle]=\"mentionMenuStyle()\"\n [class.origin-bottom-left]=\"\n mentionMenuPosition()?.placement === 'above'\n \"\n >\n @if (mentionLoading()) {\n <div class=\"px-3 py-2 text-xs text-surface-500\">\n Searching...\n </div>\n } @else if (mentionCandidates().length === 0) {\n <div class=\"px-3 py-2 text-xs text-surface-500\">\n No matches\n </div>\n } @else {\n @for (\n candidate of mentionCandidates();\n track candidate.userId;\n let i = $index\n ) {\n <button\n type=\"button\"\n class=\"flex w-full cursor-pointer items-start gap-2 bg-transparent px-2.5 py-2 text-left text-[0.78rem] transition-colors hover:bg-[color-mix(in_srgb,var(--p-primary-color)_11%,white)]\"\n [class.bg-[color-mix(in_srgb,var(--p-primary-color)_11%,white)]]=\"\n mentionActiveIndex() === i\n \"\n (click)=\"selectMention(candidate)\"\n >\n <span\n class=\"mt-px inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-[color-mix(in_srgb,var(--p-primary-color)_18%,transparent)] bg-[color-mix(in_srgb,var(--p-primary-color)_16%,white)] text-[0.7rem] font-bold text-[color-mix(in_srgb,var(--p-primary-color)_74%,black)]\"\n >\n {{ getMentionAvatarText(candidate) }}\n </span>\n <span\n class=\"flex min-w-0 flex-1 flex-col gap-0.5\"\n >\n <span\n class=\"text-[0.79rem] leading-[1.25] font-semibold text-surface-900\"\n >{{\n candidate.displayName ||\n candidate.userName ||\n candidate.userId\n }}</span\n >\n <span\n class=\"inline-flex items-center text-[0.73rem] leading-[1.2] text-surface-500\"\n >\n @{{ candidate.userId }}\n </span>\n </span>\n </button>\n }\n }\n </div>\n }\n </div>\n\n <div\n class=\"mt-2 flex flex-wrap items-center justify-between gap-2\"\n >\n <span class=\"text-xs text-surface-500\"\n >{{ editText().length }}/10000</span\n >\n <div class=\"flex items-center gap-2\">\n <button\n type=\"button\"\n class=\"rounded-[0.65rem] border border-surface-300 bg-transparent px-3 py-2 text-xs font-semibold leading-none text-surface-700 transition-colors hover:border-[color-mix(in_srgb,var(--p-primary-color)_24%,transparent)] hover:bg-[color-mix(in_srgb,var(--p-primary-color)_8%,white)] hover:text-[color-mix(in_srgb,var(--p-primary-color)_74%,black)] disabled:cursor-not-allowed disabled:opacity-55\"\n (click)=\"cancelEdit()\"\n >\n Cancel\n </button>\n <button\n type=\"button\"\n class=\"rounded-[0.65rem] border border-[color-mix(in_srgb,var(--p-primary-color)_26%,transparent)] bg-[color-mix(in_srgb,var(--p-primary-color)_12%,white)] px-3 py-2 text-xs font-bold leading-none text-[color-mix(in_srgb,var(--p-primary-color)_76%,black)] transition-colors hover:bg-[color-mix(in_srgb,var(--p-primary-color)_17%,white)] disabled:cursor-not-allowed disabled:opacity-55\"\n [disabled]=\"!canSaveEdit()\"\n (click)=\"saveEdit()\"\n >\n {{ savingEdit() ? \"Saving...\" : \"Save\" }}\n </button>\n </div>\n </div>\n } @else {\n <div\n class=\"whitespace-pre-wrap break-words text-sm leading-6\"\n >\n @for (\n segment of getCommentSegments(comment);\n track $index\n ) {\n <span\n [ngClass]=\"\n segment.isMention\n ? 'rounded-sm bg-primary-50 px-0.5 font-semibold text-primary-700'\n : ''\n \"\n [attr.data-user-id]=\"segment.userId || null\"\n >\n {{ segment.text }}\n </span>\n }\n </div>\n\n @if (comment.attachments.length > 0) {\n <div class=\"mt-2 flex flex-wrap gap-2\">\n @for (\n attachment of comment.attachments;\n track attachment.id\n ) {\n <button\n type=\"button\"\n class=\"inline-flex max-w-full items-center gap-1.5 rounded-lg border border-surface-300 px-2 py-1 text-[0.72rem] hover:bg-surface-50\"\n (click)=\"downloadAttachment(comment, attachment)\"\n >\n <span class=\"truncate\">{{\n attachment.fileName\n }}</span>\n <span class=\"text-[11px] text-surface-500\"\n >({{ attachment.size | number }} bytes)</span\n >\n </button>\n }\n </div>\n }\n\n <div\n class=\"mt-2 flex flex-wrap items-center gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100\"\n >\n <button\n type=\"button\"\n class=\"rounded-lg bg-transparent px-2 py-1 text-[0.72rem] font-semibold leading-none text-surface-600 transition-colors hover:bg-[color-mix(in_srgb,var(--p-primary-color)_10%,white)] hover:text-[color-mix(in_srgb,var(--p-primary-color)_74%,black)] disabled:cursor-not-allowed disabled:opacity-55\"\n (click)=\"openReply(comment)\"\n >\n Reply\n </button>\n @if (canEditComment(comment)) {\n <button\n type=\"button\"\n class=\"rounded-lg bg-transparent px-2 py-1 text-[0.72rem] font-semibold leading-none text-surface-600 transition-colors hover:bg-[color-mix(in_srgb,var(--p-primary-color)_10%,white)] hover:text-[color-mix(in_srgb,var(--p-primary-color)_74%,black)] disabled:cursor-not-allowed disabled:opacity-55\"\n (click)=\"startEdit(comment)\"\n >\n Edit\n </button>\n }\n @if (canDeleteComment(comment)) {\n <button\n type=\"button\"\n class=\"rounded-lg bg-transparent px-2 py-1 text-[0.72rem] font-semibold leading-none text-red-600 transition-colors hover:bg-[color-mix(in_srgb,var(--p-primary-color)_10%,white)] disabled:cursor-not-allowed disabled:opacity-55\"\n [disabled]=\"isDeleting(comment.id)\"\n (click)=\"deleteComment(comment)\"\n >\n {{ isDeleting(comment.id) ? \"Deleting...\" : \"Delete\" }}\n </button>\n }\n @if (comment.updatedAt) {\n <button\n type=\"button\"\n class=\"rounded-lg bg-transparent px-2 py-1 text-[0.72rem] font-semibold leading-none text-surface-600 transition-colors hover:bg-[color-mix(in_srgb,var(--p-primary-color)_10%,white)] hover:text-[color-mix(in_srgb,var(--p-primary-color)_74%,black)] disabled:cursor-not-allowed disabled:opacity-55\"\n (click)=\"openRevisions(comment)\"\n >\n History\n </button>\n }\n </div>\n }\n </div>\n\n @if (isOwnComment(comment)) {\n <div\n class=\"mt-0.5 inline-flex h-[1.9rem] w-[1.9rem] shrink-0 items-center justify-center rounded-full border border-[color-mix(in_srgb,var(--p-primary-color)_20%,transparent)] bg-[color-mix(in_srgb,var(--p-primary-color)_16%,white)] text-[0.74rem] font-bold text-[color-mix(in_srgb,var(--p-primary-color)_75%,black)]\"\n >\n {{ getAvatarText(comment) }}\n </div>\n }\n </article>\n }\n }\n </div>\n\n <footer\n class=\"z-10 shrink-0 border-t border-surface-200 bg-content px-3 py-3\"\n >\n @if (replyToComment(); as replyComment) {\n <div\n class=\"mb-2 flex items-center justify-between rounded-xl border border-surface-200 bg-surface-50 px-2 py-1\"\n >\n <div class=\"min-w-0 text-xs text-surface-600\">\n <span class=\"font-semibold\"\n >Replying to {{ replyComment.createdBy }}</span\n >\n <p class=\"truncate\">{{ replyComment.comment }}</p>\n </div>\n <button\n type=\"button\"\n class=\"rounded-lg bg-transparent px-2 py-1 text-[0.72rem] font-semibold leading-none text-surface-600 transition-colors hover:bg-[color-mix(in_srgb,var(--p-primary-color)_10%,white)] hover:text-[color-mix(in_srgb,var(--p-primary-color)_74%,black)] disabled:cursor-not-allowed disabled:opacity-55\"\n (click)=\"clearReply()\"\n >\n Cancel\n </button>\n </div>\n }\n\n <div\n class=\"rounded-2xl border border-surface-300 bg-white p-2 shadow-sm\"\n >\n <div class=\"relative\">\n <textarea\n #composerInput\n rows=\"3\"\n class=\"w-full min-h-[5.5rem] resize-y border-0 bg-transparent px-2 py-1 text-[0.9rem] leading-[1.45] text-surface-900 outline-none\"\n [disabled]=\"disabled() || posting()\"\n [ngModel]=\"composerText()\"\n (ngModelChange)=\"composerText.set($event)\"\n (input)=\"onComposerInput($event)\"\n (keyup)=\"onComposerCaretEvent($event)\"\n (click)=\"onComposerCaretEvent($event)\"\n (scroll)=\"onComposerCaretEvent($event)\"\n (keydown)=\"onComposerKeydown($event)\"\n [placeholder]=\"placeholder()\"\n ></textarea>\n\n @if (mentionSession()?.mode === \"composer\") {\n <div\n class=\"absolute top-0 left-0 z-30 min-w-56 overflow-auto rounded-xl border border-surface-300 bg-white shadow-[0_10px_40px_color-mix(in_srgb,black_14%,transparent)]\"\n [ngStyle]=\"mentionMenuStyle()\"\n [class.origin-bottom-left]=\"\n mentionMenuPosition()?.placement === 'above'\n \"\n >\n @if (mentionLoading()) {\n <div class=\"px-3 py-2 text-xs text-surface-500\">\n Searching...\n </div>\n } @else if (mentionCandidates().length === 0) {\n <div class=\"px-3 py-2 text-xs text-surface-500\">\n No matches\n </div>\n } @else {\n @for (\n candidate of mentionCandidates();\n track candidate.userId;\n let i = $index\n ) {\n <button\n type=\"button\"\n class=\"flex w-full cursor-pointer items-start gap-2 bg-transparent px-2.5 py-2 text-left text-[0.78rem] transition-colors hover:bg-[color-mix(in_srgb,var(--p-primary-color)_11%,white)]\"\n [class.bg-[color-mix(in_srgb,var(--p-primary-color)_11%,white)]]=\"\n mentionActiveIndex() === i\n \"\n (click)=\"selectMention(candidate)\"\n >\n <span\n class=\"mt-px inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-[color-mix(in_srgb,var(--p-primary-color)_18%,transparent)] bg-[color-mix(in_srgb,var(--p-primary-color)_16%,white)] text-[0.7rem] font-bold text-[color-mix(in_srgb,var(--p-primary-color)_74%,black)]\"\n >\n {{ getMentionAvatarText(candidate) }}\n </span>\n <span class=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n <span\n class=\"text-[0.79rem] leading-[1.25] font-semibold text-surface-900\"\n >{{\n candidate.displayName ||\n candidate.userName ||\n candidate.userId\n }}</span\n >\n <span\n class=\"inline-flex items-center text-[0.73rem] leading-[1.2] text-surface-500\"\n >\n @{{ candidate.userId }}\n </span>\n </span>\n </button>\n }\n }\n </div>\n }\n </div>\n\n @if (composerAttachments().length > 0) {\n <div class=\"mt-2 flex flex-wrap gap-2 px-1\">\n @for (attachment of composerAttachments(); track attachment.id) {\n <div\n class=\"rounded-xl border border-surface-200 bg-surface-50 px-2 py-1 text-xs\"\n >\n <div class=\"flex items-center gap-2\">\n <span class=\"max-w-[12rem] truncate\">{{\n attachment.fileName\n }}</span>\n @if (attachment.status === \"uploading\") {\n <span class=\"text-surface-500\"\n >{{ attachment.progress }}%</span\n >\n } @else if (attachment.status === \"failed\") {\n <span class=\"text-red-600\">Failed</span>\n } @else {\n <span class=\"text-emerald-600\">Ready</span>\n }\n <button\n type=\"button\"\n class=\"rounded-lg bg-transparent px-2 py-1 text-[0.72rem] font-semibold leading-none text-surface-600 transition-colors hover:bg-[color-mix(in_srgb,var(--p-primary-color)_10%,white)] hover:text-[color-mix(in_srgb,var(--p-primary-color)_74%,black)] disabled:cursor-not-allowed disabled:opacity-55\"\n (click)=\"removeAttachment(attachment.id)\"\n >\n Remove\n </button>\n </div>\n @if (attachment.error) {\n <p class=\"text-[11px] text-red-600\">\n {{ attachment.error }}\n </p>\n }\n </div>\n }\n </div>\n }\n\n <div\n class=\"mt-2 flex flex-wrap items-center justify-between gap-2 border-t border-surface-200 px-1 pt-2\"\n >\n <div class=\"flex items-center gap-2 text-xs text-surface-500\">\n <span>Type @ to mention people</span>\n <span>•</span>\n <span>{{ composerText().length }}/10000</span>\n @if (charactersLeft() < 0) {\n <span class=\"font-semibold text-red-600\">Too long</span>\n }\n </div>\n\n <div class=\"flex items-center gap-2\">\n @if (allowAttachments()) {\n <mt-button\n icon=\"file.paperclip\"\n label=\"Attach\"\n size=\"small\"\n [outlined]=\"true\"\n [disabled]=\"disabled() || posting()\"\n (onClick)=\"browseAttachments()\"\n />\n }\n <mt-button\n icon=\"communication.send-01\"\n [label]=\"posting() ? 'Sending...' : 'Send'\"\n size=\"small\"\n [loading]=\"posting()\"\n [disabled]=\"!canSend()\"\n (onClick)=\"sendComment()\"\n />\n </div>\n </div>\n </div>\n </footer>\n </div>\n </div>\n</mt-card>\n\n<p-dialog\n [visible]=\"revisionsDialogVisible()\"\n (visibleChange)=\"revisionsDialogVisible.set($event)\"\n [modal]=\"true\"\n [dismissableMask]=\"true\"\n [draggable]=\"false\"\n [resizable]=\"false\"\n [style]=\"{ width: 'min(42rem, 92vw)' }\"\n header=\"Comment history\"\n>\n @if (revisionLoading()) {\n <div class=\"space-y-2\">\n @for (row of [1, 2, 3]; track row) {\n <div class=\"h-12 animate-pulse rounded-xl bg-surface-100\"></div>\n }\n </div>\n } @else if (visibleRevisions().length === 0) {\n <p class=\"text-sm text-surface-500\">No revision snapshots.</p>\n } @else {\n <div class=\"max-h-[55vh] space-y-2 overflow-y-auto pr-1\">\n @for (revision of visibleRevisions(); track revision.id) {\n <article class=\"rounded-xl border border-surface-200 p-3\">\n <div class=\"mb-1 text-xs text-surface-500\">\n Revision #{{ revision.revisionNumber }} •\n {{ revision.createdAt | date: \"MMM d, y h:mm a\" }} •\n {{ revision.createdBy }}\n </div>\n <div class=\"whitespace-pre-wrap break-words text-sm leading-6\">\n @for (\n segment of getCommentSegments({\n id: revision.id,\n moduleType: selectedRevisionComment()?.moduleType || \"\",\n recordId: selectedRevisionComment()?.recordId || 0,\n parentCommentId: null,\n comment: revision.comment,\n isSystem: false,\n createdAt: revision.createdAt,\n updatedAt: null,\n createdBy: revision.createdBy,\n updatedBy: null,\n attachments: [],\n mentions: revision.mentions,\n });\n track $index\n ) {\n <span\n [ngClass]=\"\n segment.isMention\n ? 'rounded-sm bg-primary-50 px-0.5 font-semibold text-primary-700'\n : ''\n \"\n >{{ segment.text }}</span\n >\n }\n </div>\n </article>\n }\n </div>\n }\n</p-dialog>\n", styles: [":host{display:block;height:100%;min-height:0}:host ::ng-deep .mt-discussion-card>div{display:flex;flex-direction:column;height:100%;min-height:0}:host ::ng-deep .mt-discussion-card>div>div.flex-1{display:flex;flex-direction:column;min-height:0;overflow:hidden}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i1.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i2.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: DialogModule }, { kind: "component", type: i3.Dialog, selector: "p-dialog", inputs: ["hostName", "header", "draggable", "resizable", "contentStyle", "contentStyleClass", "modal", "closeOnEscape", "dismissableMask", "rtl", "closable", "breakpoints", "styleClass", "maskStyleClass", "maskStyle", "showHeader", "blockScroll", "autoZIndex", "baseZIndex", "minX", "minY", "focusOnShow", "maximizable", "keepInViewport", "focusTrap", "transitionOptions", "maskMotionOptions", "motionOptions", "closeIcon", "closeAriaLabel", "closeTabindex", "minimizeIcon", "maximizeIcon", "closeButtonProps", "maximizeButtonProps", "visible", "style", "position", "role", "appendTo", "content", "contentTemplate", "footerTemplate", "closeIconTemplate", "maximizeIconTemplate", "minimizeIconTemplate", "headlessTemplate"], outputs: ["onShow", "onHide", "visibleChange", "onResizeInit", "onResizeEnd", "onDragEnd", "onMaximize"] }, { kind: "component", type: Button, selector: "mt-button", inputs: ["icon", "label", "tooltip", "class", "type", "styleClass", "severity", "badge", "variant", "badgeSeverity", "size", "iconPos", "autofocus", "fluid", "raised", "rounded", "text", "plain", "outlined", "link", "disabled", "loading", "pInputs"], outputs: ["onClick", "onFocus", "onBlur"] }, { kind: "component", type: Card, selector: "mt-card", inputs: ["class", "title", "paddingless"] }, { kind: "component", type: EntitiesPreview, selector: "mt-entities-preview", inputs: ["entities"] }, { kind: "pipe", type: i1.DecimalPipe, name: "number" }, { kind: "pipe", type: i1.DatePipe, name: "date" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1630
|
+
}
|
|
1631
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: DiscussionThread, decorators: [{
|
|
1632
|
+
type: Component,
|
|
1633
|
+
args: [{ selector: 'mt-discussion-thread', standalone: true, imports: [
|
|
1634
|
+
CommonModule,
|
|
1635
|
+
FormsModule,
|
|
1636
|
+
DatePipe,
|
|
1637
|
+
DialogModule,
|
|
1638
|
+
Button,
|
|
1639
|
+
Card,
|
|
1640
|
+
EntitiesPreview,
|
|
1641
|
+
], changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
1642
|
+
class: 'block h-full min-h-0',
|
|
1643
|
+
}, template: "<mt-card\n class=\"mt-discussion-card h-full min-h-0 w-full overflow-hidden shadow-sm\"\n [paddingless]=\"true\"\n [ngClass]=\"styleClass()\"\n>\n <div class=\"flex h-full min-h-0 flex-col overflow-hidden\">\n <input\n #attachmentInput\n type=\"file\"\n class=\"hidden\"\n multiple\n (change)=\"onAttachmentSelected($event)\"\n />\n\n <header\n class=\"flex flex-wrap items-start justify-between gap-3 border-b border-surface-200 px-4 py-3\"\n >\n <div class=\"min-w-0\">\n <h3 class=\"truncate text-sm font-semibold uppercase tracking-[0.08em]\">\n {{ title() }}\n </h3>\n @if (subtitle()) {\n <p class=\"mt-1 text-xs text-surface-500\">{{ subtitle() }}</p>\n }\n <p class=\"mt-1 text-xs text-surface-500\">\n {{ threadKey() || \"Discussion\" }}\n </p>\n </div>\n\n <div class=\"flex flex-wrap items-center gap-2\">\n @if (unreadCount() > 0) {\n <span\n class=\"inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-semibold text-red-700\"\n >\n {{ unreadCount() }} unread\n </span>\n } @else {\n <span\n class=\"inline-flex items-center rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-semibold text-emerald-700\"\n >\n Up to date\n </span>\n }\n\n <mt-button\n icon=\"arrow.refresh-cw-01\"\n [label]=\"refreshing() ? 'Refreshing...' : 'Refresh'\"\n size=\"small\"\n [outlined]=\"true\"\n [loading]=\"refreshing()\"\n [disabled]=\"loadingInitial()\"\n (onClick)=\"refreshThread()\"\n />\n <mt-button\n icon=\"general.check\"\n label=\"Mark read\"\n size=\"small\"\n [outlined]=\"true\"\n [disabled]=\"markingRead() || !hasUnread()\"\n (onClick)=\"markRead()\"\n />\n <mt-button\n icon=\"communication.mail-01\"\n label=\"Mark unread\"\n size=\"small\"\n [outlined]=\"true\"\n [disabled]=\"markingRead()\"\n (onClick)=\"markUnread()\"\n />\n @if (showParticipants()) {\n <mt-button\n icon=\"user.users-01\"\n [label]=\"'Participants (' + participants().length + ')'\"\n size=\"small\"\n [outlined]=\"true\"\n (onClick)=\"toggleParticipantsPanel()\"\n />\n }\n </div>\n </header>\n\n @if (showParticipants() && participantsExpanded()) {\n <section class=\"border-b border-surface-200 px-4 py-3\">\n @if (participantsLoading()) {\n <span class=\"text-xs text-surface-500\">Loading participants...</span>\n } @else if (participantEntities().length === 0) {\n <span class=\"text-xs text-surface-500\">No participants yet.</span>\n } @else {\n <div class=\"max-h-48 overflow-y-auto pr-1\">\n <mt-entities-preview [entities]=\"participantEntities()\" />\n </div>\n }\n </section>\n }\n\n @if (errorMessage(); as error) {\n <div\n class=\"border-b border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700\"\n >\n {{ error }}\n </div>\n }\n\n <div class=\"flex min-h-0 flex-1 flex-col overflow-hidden\">\n <div\n #viewport\n class=\"min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain px-3 py-3\"\n (scroll)=\"onViewportScroll()\"\n >\n @if (hasMore()) {\n <div class=\"flex justify-center py-1\">\n <mt-button\n icon=\"arrow.chevron-up\"\n [label]=\"loadingMore() ? 'Loading...' : 'Load older messages'\"\n size=\"small\"\n [outlined]=\"true\"\n [loading]=\"loadingMore()\"\n (onClick)=\"loadOlder()\"\n />\n </div>\n }\n\n @if (loadingInitial()) {\n <div class=\"space-y-2\">\n @for (item of [1, 2, 3, 4]; track item) {\n <div class=\"h-16 animate-pulse rounded-xl bg-surface-100\"></div>\n }\n </div>\n } @else if (commentsAsc().length === 0) {\n <div\n class=\"rounded-2xl border border-dashed border-surface-300 p-8 text-center\"\n >\n <h4 class=\"text-sm font-semibold text-surface-700\">\n No comments yet\n </h4>\n <p class=\"mt-1 text-xs text-surface-500\">Start the conversation.</p>\n </div>\n } @else {\n @for (comment of commentsAsc(); track trackComment($index, comment)) {\n @if (firstUnreadCommentId() === comment.id) {\n <div class=\"my-1 flex items-center justify-center gap-2\">\n <span\n class=\"flex-1 border-t border-dashed border-red-300\"\n ></span>\n <span\n class=\"rounded-full border border-red-200 bg-red-50 px-2 py-0.5 text-[0.67rem] font-bold leading-none text-red-700\"\n >Unread messages</span\n >\n <span\n class=\"flex-1 border-t border-dashed border-red-300\"\n ></span>\n </div>\n }\n\n <article\n class=\"group flex gap-2\"\n [class.justify-end]=\"isOwnComment(comment)\"\n >\n @if (!isOwnComment(comment)) {\n <div\n class=\"mt-0.5 inline-flex h-[1.9rem] w-[1.9rem] shrink-0 items-center justify-center rounded-full border border-[color-mix(in_srgb,var(--p-primary-color)_20%,transparent)] bg-[color-mix(in_srgb,var(--p-primary-color)_16%,white)] text-[0.74rem] font-bold text-[color-mix(in_srgb,var(--p-primary-color)_75%,black)]\"\n >\n {{ getAvatarText(comment) }}\n </div>\n }\n\n <div\n class=\"max-w-[88%] min-w-[16rem] rounded-2xl border border-surface-200 px-3 py-2\"\n [class.bg-[color-mix(in_srgb,var(--p-primary-color)_7%,white)]]=\"\n isOwnComment(comment)\n \"\n >\n <div\n class=\"mb-1 flex flex-wrap items-center justify-between gap-2\"\n >\n <div class=\"text-xs text-surface-600\">\n <span class=\"font-semibold text-surface-900\">{{\n comment.createdBy\n }}</span>\n <span class=\"mx-1\">•</span>\n <span>{{\n comment.createdAt | date: \"MMM d, y h:mm a\"\n }}</span>\n @if (comment.updatedAt) {\n <span class=\"ml-1 text-[11px] text-surface-500\"\n >(edited)</span\n >\n }\n </div>\n </div>\n\n @if (getParentComment(comment); as parentComment) {\n <button\n type=\"button\"\n class=\"mb-2 block w-full rounded-xl border border-surface-200 bg-surface-50 px-2 py-1 text-left text-xs text-surface-600\"\n (click)=\"openReply(parentComment)\"\n >\n <span class=\"font-semibold\">{{\n parentComment.createdBy\n }}</span>\n <span class=\"mx-1\">:</span>\n <span class=\"line-clamp-1\">{{\n parentComment.comment\n }}</span>\n </button>\n }\n\n @if (editingCommentId() === comment.id) {\n <div class=\"relative\">\n <textarea\n rows=\"3\"\n class=\"w-full min-h-[5.25rem] resize-y rounded-2xl border border-surface-300 bg-white px-3 py-2.5 text-[0.86rem] leading-[1.45] text-surface-900 outline-none focus-visible:outline-2 focus-visible:outline-[color-mix(in_srgb,var(--p-primary-color)_32%,transparent)] focus-visible:outline-offset-1\"\n [ngModel]=\"editText()\"\n (ngModelChange)=\"editText.set($event)\"\n (input)=\"onEditInput($event)\"\n (keyup)=\"onEditCaretEvent($event)\"\n (click)=\"onEditCaretEvent($event)\"\n (scroll)=\"onEditCaretEvent($event)\"\n (keydown)=\"onEditKeydown($event)\"\n ></textarea>\n\n @if (\n mentionSession()?.mode === \"edit\" &&\n mentionSession()?.editCommentId === comment.id\n ) {\n <div\n class=\"absolute top-0 left-0 z-20 min-w-56 overflow-auto rounded-xl border border-surface-300 bg-white shadow-[0_10px_40px_color-mix(in_srgb,black_14%,transparent)]\"\n [ngStyle]=\"mentionMenuStyle()\"\n [class.origin-bottom-left]=\"\n mentionMenuPosition()?.placement === 'above'\n \"\n >\n @if (mentionLoading()) {\n <div class=\"px-3 py-2 text-xs text-surface-500\">\n Searching...\n </div>\n } @else if (mentionCandidates().length === 0) {\n <div class=\"px-3 py-2 text-xs text-surface-500\">\n No matches\n </div>\n } @else {\n @for (\n candidate of mentionCandidates();\n track candidate.userId;\n let i = $index\n ) {\n <button\n type=\"button\"\n class=\"flex w-full cursor-pointer items-start gap-2 bg-transparent px-2.5 py-2 text-left text-[0.78rem] transition-colors hover:bg-[color-mix(in_srgb,var(--p-primary-color)_11%,white)]\"\n [class.bg-[color-mix(in_srgb,var(--p-primary-color)_11%,white)]]=\"\n mentionActiveIndex() === i\n \"\n (click)=\"selectMention(candidate)\"\n >\n <span\n class=\"mt-px inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-[color-mix(in_srgb,var(--p-primary-color)_18%,transparent)] bg-[color-mix(in_srgb,var(--p-primary-color)_16%,white)] text-[0.7rem] font-bold text-[color-mix(in_srgb,var(--p-primary-color)_74%,black)]\"\n >\n {{ getMentionAvatarText(candidate) }}\n </span>\n <span\n class=\"flex min-w-0 flex-1 flex-col gap-0.5\"\n >\n <span\n class=\"text-[0.79rem] leading-[1.25] font-semibold text-surface-900\"\n >{{\n candidate.displayName ||\n candidate.userName ||\n candidate.userId\n }}</span\n >\n <span\n class=\"inline-flex items-center text-[0.73rem] leading-[1.2] text-surface-500\"\n >\n @{{ candidate.userId }}\n </span>\n </span>\n </button>\n }\n }\n </div>\n }\n </div>\n\n <div\n class=\"mt-2 flex flex-wrap items-center justify-between gap-2\"\n >\n <span class=\"text-xs text-surface-500\"\n >{{ editText().length }}/10000</span\n >\n <div class=\"flex items-center gap-2\">\n <button\n type=\"button\"\n class=\"rounded-[0.65rem] border border-surface-300 bg-transparent px-3 py-2 text-xs font-semibold leading-none text-surface-700 transition-colors hover:border-[color-mix(in_srgb,var(--p-primary-color)_24%,transparent)] hover:bg-[color-mix(in_srgb,var(--p-primary-color)_8%,white)] hover:text-[color-mix(in_srgb,var(--p-primary-color)_74%,black)] disabled:cursor-not-allowed disabled:opacity-55\"\n (click)=\"cancelEdit()\"\n >\n Cancel\n </button>\n <button\n type=\"button\"\n class=\"rounded-[0.65rem] border border-[color-mix(in_srgb,var(--p-primary-color)_26%,transparent)] bg-[color-mix(in_srgb,var(--p-primary-color)_12%,white)] px-3 py-2 text-xs font-bold leading-none text-[color-mix(in_srgb,var(--p-primary-color)_76%,black)] transition-colors hover:bg-[color-mix(in_srgb,var(--p-primary-color)_17%,white)] disabled:cursor-not-allowed disabled:opacity-55\"\n [disabled]=\"!canSaveEdit()\"\n (click)=\"saveEdit()\"\n >\n {{ savingEdit() ? \"Saving...\" : \"Save\" }}\n </button>\n </div>\n </div>\n } @else {\n <div\n class=\"whitespace-pre-wrap break-words text-sm leading-6\"\n >\n @for (\n segment of getCommentSegments(comment);\n track $index\n ) {\n <span\n [ngClass]=\"\n segment.isMention\n ? 'rounded-sm bg-primary-50 px-0.5 font-semibold text-primary-700'\n : ''\n \"\n [attr.data-user-id]=\"segment.userId || null\"\n >\n {{ segment.text }}\n </span>\n }\n </div>\n\n @if (comment.attachments.length > 0) {\n <div class=\"mt-2 flex flex-wrap gap-2\">\n @for (\n attachment of comment.attachments;\n track attachment.id\n ) {\n <button\n type=\"button\"\n class=\"inline-flex max-w-full items-center gap-1.5 rounded-lg border border-surface-300 px-2 py-1 text-[0.72rem] hover:bg-surface-50\"\n (click)=\"downloadAttachment(comment, attachment)\"\n >\n <span class=\"truncate\">{{\n attachment.fileName\n }}</span>\n <span class=\"text-[11px] text-surface-500\"\n >({{ attachment.size | number }} bytes)</span\n >\n </button>\n }\n </div>\n }\n\n <div\n class=\"mt-2 flex flex-wrap items-center gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100\"\n >\n <button\n type=\"button\"\n class=\"rounded-lg bg-transparent px-2 py-1 text-[0.72rem] font-semibold leading-none text-surface-600 transition-colors hover:bg-[color-mix(in_srgb,var(--p-primary-color)_10%,white)] hover:text-[color-mix(in_srgb,var(--p-primary-color)_74%,black)] disabled:cursor-not-allowed disabled:opacity-55\"\n (click)=\"openReply(comment)\"\n >\n Reply\n </button>\n @if (canEditComment(comment)) {\n <button\n type=\"button\"\n class=\"rounded-lg bg-transparent px-2 py-1 text-[0.72rem] font-semibold leading-none text-surface-600 transition-colors hover:bg-[color-mix(in_srgb,var(--p-primary-color)_10%,white)] hover:text-[color-mix(in_srgb,var(--p-primary-color)_74%,black)] disabled:cursor-not-allowed disabled:opacity-55\"\n (click)=\"startEdit(comment)\"\n >\n Edit\n </button>\n }\n @if (canDeleteComment(comment)) {\n <button\n type=\"button\"\n class=\"rounded-lg bg-transparent px-2 py-1 text-[0.72rem] font-semibold leading-none text-red-600 transition-colors hover:bg-[color-mix(in_srgb,var(--p-primary-color)_10%,white)] disabled:cursor-not-allowed disabled:opacity-55\"\n [disabled]=\"isDeleting(comment.id)\"\n (click)=\"deleteComment(comment)\"\n >\n {{ isDeleting(comment.id) ? \"Deleting...\" : \"Delete\" }}\n </button>\n }\n @if (comment.updatedAt) {\n <button\n type=\"button\"\n class=\"rounded-lg bg-transparent px-2 py-1 text-[0.72rem] font-semibold leading-none text-surface-600 transition-colors hover:bg-[color-mix(in_srgb,var(--p-primary-color)_10%,white)] hover:text-[color-mix(in_srgb,var(--p-primary-color)_74%,black)] disabled:cursor-not-allowed disabled:opacity-55\"\n (click)=\"openRevisions(comment)\"\n >\n History\n </button>\n }\n </div>\n }\n </div>\n\n @if (isOwnComment(comment)) {\n <div\n class=\"mt-0.5 inline-flex h-[1.9rem] w-[1.9rem] shrink-0 items-center justify-center rounded-full border border-[color-mix(in_srgb,var(--p-primary-color)_20%,transparent)] bg-[color-mix(in_srgb,var(--p-primary-color)_16%,white)] text-[0.74rem] font-bold text-[color-mix(in_srgb,var(--p-primary-color)_75%,black)]\"\n >\n {{ getAvatarText(comment) }}\n </div>\n }\n </article>\n }\n }\n </div>\n\n <footer\n class=\"z-10 shrink-0 border-t border-surface-200 bg-content px-3 py-3\"\n >\n @if (replyToComment(); as replyComment) {\n <div\n class=\"mb-2 flex items-center justify-between rounded-xl border border-surface-200 bg-surface-50 px-2 py-1\"\n >\n <div class=\"min-w-0 text-xs text-surface-600\">\n <span class=\"font-semibold\"\n >Replying to {{ replyComment.createdBy }}</span\n >\n <p class=\"truncate\">{{ replyComment.comment }}</p>\n </div>\n <button\n type=\"button\"\n class=\"rounded-lg bg-transparent px-2 py-1 text-[0.72rem] font-semibold leading-none text-surface-600 transition-colors hover:bg-[color-mix(in_srgb,var(--p-primary-color)_10%,white)] hover:text-[color-mix(in_srgb,var(--p-primary-color)_74%,black)] disabled:cursor-not-allowed disabled:opacity-55\"\n (click)=\"clearReply()\"\n >\n Cancel\n </button>\n </div>\n }\n\n <div\n class=\"rounded-2xl border border-surface-300 bg-white p-2 shadow-sm\"\n >\n <div class=\"relative\">\n <textarea\n #composerInput\n rows=\"3\"\n class=\"w-full min-h-[5.5rem] resize-y border-0 bg-transparent px-2 py-1 text-[0.9rem] leading-[1.45] text-surface-900 outline-none\"\n [disabled]=\"disabled() || posting()\"\n [ngModel]=\"composerText()\"\n (ngModelChange)=\"composerText.set($event)\"\n (input)=\"onComposerInput($event)\"\n (keyup)=\"onComposerCaretEvent($event)\"\n (click)=\"onComposerCaretEvent($event)\"\n (scroll)=\"onComposerCaretEvent($event)\"\n (keydown)=\"onComposerKeydown($event)\"\n [placeholder]=\"placeholder()\"\n ></textarea>\n\n @if (mentionSession()?.mode === \"composer\") {\n <div\n class=\"absolute top-0 left-0 z-30 min-w-56 overflow-auto rounded-xl border border-surface-300 bg-white shadow-[0_10px_40px_color-mix(in_srgb,black_14%,transparent)]\"\n [ngStyle]=\"mentionMenuStyle()\"\n [class.origin-bottom-left]=\"\n mentionMenuPosition()?.placement === 'above'\n \"\n >\n @if (mentionLoading()) {\n <div class=\"px-3 py-2 text-xs text-surface-500\">\n Searching...\n </div>\n } @else if (mentionCandidates().length === 0) {\n <div class=\"px-3 py-2 text-xs text-surface-500\">\n No matches\n </div>\n } @else {\n @for (\n candidate of mentionCandidates();\n track candidate.userId;\n let i = $index\n ) {\n <button\n type=\"button\"\n class=\"flex w-full cursor-pointer items-start gap-2 bg-transparent px-2.5 py-2 text-left text-[0.78rem] transition-colors hover:bg-[color-mix(in_srgb,var(--p-primary-color)_11%,white)]\"\n [class.bg-[color-mix(in_srgb,var(--p-primary-color)_11%,white)]]=\"\n mentionActiveIndex() === i\n \"\n (click)=\"selectMention(candidate)\"\n >\n <span\n class=\"mt-px inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-[color-mix(in_srgb,var(--p-primary-color)_18%,transparent)] bg-[color-mix(in_srgb,var(--p-primary-color)_16%,white)] text-[0.7rem] font-bold text-[color-mix(in_srgb,var(--p-primary-color)_74%,black)]\"\n >\n {{ getMentionAvatarText(candidate) }}\n </span>\n <span class=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n <span\n class=\"text-[0.79rem] leading-[1.25] font-semibold text-surface-900\"\n >{{\n candidate.displayName ||\n candidate.userName ||\n candidate.userId\n }}</span\n >\n <span\n class=\"inline-flex items-center text-[0.73rem] leading-[1.2] text-surface-500\"\n >\n @{{ candidate.userId }}\n </span>\n </span>\n </button>\n }\n }\n </div>\n }\n </div>\n\n @if (composerAttachments().length > 0) {\n <div class=\"mt-2 flex flex-wrap gap-2 px-1\">\n @for (attachment of composerAttachments(); track attachment.id) {\n <div\n class=\"rounded-xl border border-surface-200 bg-surface-50 px-2 py-1 text-xs\"\n >\n <div class=\"flex items-center gap-2\">\n <span class=\"max-w-[12rem] truncate\">{{\n attachment.fileName\n }}</span>\n @if (attachment.status === \"uploading\") {\n <span class=\"text-surface-500\"\n >{{ attachment.progress }}%</span\n >\n } @else if (attachment.status === \"failed\") {\n <span class=\"text-red-600\">Failed</span>\n } @else {\n <span class=\"text-emerald-600\">Ready</span>\n }\n <button\n type=\"button\"\n class=\"rounded-lg bg-transparent px-2 py-1 text-[0.72rem] font-semibold leading-none text-surface-600 transition-colors hover:bg-[color-mix(in_srgb,var(--p-primary-color)_10%,white)] hover:text-[color-mix(in_srgb,var(--p-primary-color)_74%,black)] disabled:cursor-not-allowed disabled:opacity-55\"\n (click)=\"removeAttachment(attachment.id)\"\n >\n Remove\n </button>\n </div>\n @if (attachment.error) {\n <p class=\"text-[11px] text-red-600\">\n {{ attachment.error }}\n </p>\n }\n </div>\n }\n </div>\n }\n\n <div\n class=\"mt-2 flex flex-wrap items-center justify-between gap-2 border-t border-surface-200 px-1 pt-2\"\n >\n <div class=\"flex items-center gap-2 text-xs text-surface-500\">\n <span>Type @ to mention people</span>\n <span>•</span>\n <span>{{ composerText().length }}/10000</span>\n @if (charactersLeft() < 0) {\n <span class=\"font-semibold text-red-600\">Too long</span>\n }\n </div>\n\n <div class=\"flex items-center gap-2\">\n @if (allowAttachments()) {\n <mt-button\n icon=\"file.paperclip\"\n label=\"Attach\"\n size=\"small\"\n [outlined]=\"true\"\n [disabled]=\"disabled() || posting()\"\n (onClick)=\"browseAttachments()\"\n />\n }\n <mt-button\n icon=\"communication.send-01\"\n [label]=\"posting() ? 'Sending...' : 'Send'\"\n size=\"small\"\n [loading]=\"posting()\"\n [disabled]=\"!canSend()\"\n (onClick)=\"sendComment()\"\n />\n </div>\n </div>\n </div>\n </footer>\n </div>\n </div>\n</mt-card>\n\n<p-dialog\n [visible]=\"revisionsDialogVisible()\"\n (visibleChange)=\"revisionsDialogVisible.set($event)\"\n [modal]=\"true\"\n [dismissableMask]=\"true\"\n [draggable]=\"false\"\n [resizable]=\"false\"\n [style]=\"{ width: 'min(42rem, 92vw)' }\"\n header=\"Comment history\"\n>\n @if (revisionLoading()) {\n <div class=\"space-y-2\">\n @for (row of [1, 2, 3]; track row) {\n <div class=\"h-12 animate-pulse rounded-xl bg-surface-100\"></div>\n }\n </div>\n } @else if (visibleRevisions().length === 0) {\n <p class=\"text-sm text-surface-500\">No revision snapshots.</p>\n } @else {\n <div class=\"max-h-[55vh] space-y-2 overflow-y-auto pr-1\">\n @for (revision of visibleRevisions(); track revision.id) {\n <article class=\"rounded-xl border border-surface-200 p-3\">\n <div class=\"mb-1 text-xs text-surface-500\">\n Revision #{{ revision.revisionNumber }} •\n {{ revision.createdAt | date: \"MMM d, y h:mm a\" }} •\n {{ revision.createdBy }}\n </div>\n <div class=\"whitespace-pre-wrap break-words text-sm leading-6\">\n @for (\n segment of getCommentSegments({\n id: revision.id,\n moduleType: selectedRevisionComment()?.moduleType || \"\",\n recordId: selectedRevisionComment()?.recordId || 0,\n parentCommentId: null,\n comment: revision.comment,\n isSystem: false,\n createdAt: revision.createdAt,\n updatedAt: null,\n createdBy: revision.createdBy,\n updatedBy: null,\n attachments: [],\n mentions: revision.mentions,\n });\n track $index\n ) {\n <span\n [ngClass]=\"\n segment.isMention\n ? 'rounded-sm bg-primary-50 px-0.5 font-semibold text-primary-700'\n : ''\n \"\n >{{ segment.text }}</span\n >\n }\n </div>\n </article>\n }\n </div>\n }\n</p-dialog>\n", styles: [":host{display:block;height:100%;min-height:0}:host ::ng-deep .mt-discussion-card>div{display:flex;flex-direction:column;height:100%;min-height:0}:host ::ng-deep .mt-discussion-card>div>div.flex-1{display:flex;flex-direction:column;min-height:0;overflow:hidden}\n"] }]
|
|
1644
|
+
}], ctorParameters: () => [], propDecorators: { moduleType: [{ type: i0.Input, args: [{ isSignal: true, alias: "moduleType", required: true }] }], recordId: [{ type: i0.Input, args: [{ isSignal: true, alias: "recordId", required: true }] }], title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], subtitle: [{ type: i0.Input, args: [{ isSignal: true, alias: "subtitle", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], pageSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "pageSize", required: false }] }], currentUserId: [{ type: i0.Input, args: [{ isSignal: true, alias: "currentUserId", required: false }] }], requestContext: [{ type: i0.Input, args: [{ isSignal: true, alias: "requestContext", required: false }] }], mentionableUsers: [{ type: i0.Input, args: [{ isSignal: true, alias: "mentionableUsers", required: false }] }], mentionSearchEndpoint: [{ type: i0.Input, args: [{ isSignal: true, alias: "mentionSearchEndpoint", required: false }] }], mentionSearchParam: [{ type: i0.Input, args: [{ isSignal: true, alias: "mentionSearchParam", required: false }] }], mentionSearchDataPath: [{ type: i0.Input, args: [{ isSignal: true, alias: "mentionSearchDataPath", required: false }] }], allowAttachments: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowAttachments", required: false }] }], uploadEndpoint: [{ type: i0.Input, args: [{ isSignal: true, alias: "uploadEndpoint", required: false }] }], attachmentDownloadEndpoint: [{ type: i0.Input, args: [{ isSignal: true, alias: "attachmentDownloadEndpoint", required: false }] }], showParticipants: [{ type: i0.Input, args: [{ isSignal: true, alias: "showParticipants", required: false }] }], autoMarkRead: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoMarkRead", required: false }] }], refreshIntervalMs: [{ type: i0.Input, args: [{ isSignal: true, alias: "refreshIntervalMs", required: false }] }], styleClass: [{ type: i0.Input, args: [{ isSignal: true, alias: "styleClass", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], loaded: [{ type: i0.Output, args: ["loaded"] }], errored: [{ type: i0.Output, args: ["errored"] }], commentCreated: [{ type: i0.Output, args: ["commentCreated"] }], commentUpdated: [{ type: i0.Output, args: ["commentUpdated"] }], commentDeleted: [{ type: i0.Output, args: ["commentDeleted"] }], readStateChanged: [{ type: i0.Output, args: ["readStateChanged"] }], viewportRef: [{ type: i0.ViewChild, args: ['viewport', { isSignal: true }] }], composerInputRef: [{ type: i0.ViewChild, args: ['composerInput', { isSignal: true }] }], attachmentInputRef: [{ type: i0.ViewChild, args: ['attachmentInput', { isSignal: true }] }] } });
|
|
1645
|
+
|
|
1646
|
+
/**
|
|
1647
|
+
* Generated bundle index. Do not edit.
|
|
1648
|
+
*/
|
|
1649
|
+
|
|
1650
|
+
export { DiscussionApiService, DiscussionThread };
|
|
1651
|
+
//# sourceMappingURL=masterteam-discussion.mjs.map
|