twenty-bells 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -0
- package/manifest.json +251 -0
- package/package.json +56 -0
- package/public/gallery-1-feed.png +0 -0
- package/public/gallery-2-tasks.png +0 -0
- package/public/gallery-3-buzz.png +0 -0
- package/public/logo.svg +14 -0
- package/src/front-components/activity-feed.mjs +99 -0
- package/src/front-components/activity-feed.mjs.map +7 -0
- package/src/front-components/activity-feed.tsx +3573 -0
|
@@ -0,0 +1,3573 @@
|
|
|
1
|
+
import { defineFrontComponent } from 'twenty-sdk/define';
|
|
2
|
+
import {
|
|
3
|
+
msg,
|
|
4
|
+
t,
|
|
5
|
+
openSidePanelPage,
|
|
6
|
+
SidePanelPages,
|
|
7
|
+
useColorScheme,
|
|
8
|
+
useUserId,
|
|
9
|
+
} from 'twenty-sdk/front-component';
|
|
10
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
11
|
+
import { RestApiClient } from 'twenty-client-sdk/rest';
|
|
12
|
+
// Named imports only: `useIcons` and `IconsProvider` drag in the whole Tabler
|
|
13
|
+
// set, several megabytes of it. twenty-ui re-exports a curated subset, and the
|
|
14
|
+
// per-format `IconFileTypePdf` family is not part of it — those are drawn
|
|
15
|
+
// below. These two are, and a link is exactly what they mean.
|
|
16
|
+
import { IconBrandGoogle, IconFile, IconLink } from 'twenty-ui/icon';
|
|
17
|
+
|
|
18
|
+
import { ACTIVITY_FEED_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
|
19
|
+
|
|
20
|
+
// These were application variables once, editable from the app's Settings tab.
|
|
21
|
+
// The host injects such values into a front component still encrypted —
|
|
22
|
+
// `enc:v2:<workspace>:<blob>` — and nothing in twenty-sdk or twenty-client-sdk
|
|
23
|
+
// decrypts them, so nothing an admin typed ever arrived. Treating a ciphertext
|
|
24
|
+
// as a value is what turned SHOW_ATTACHMENTS off and kept every file out of the
|
|
25
|
+
// feed. They are constants until Twenty decrypts them.
|
|
26
|
+
//
|
|
27
|
+
// Thirty seconds, not fifteen. The panel stays open all day, and each cycle
|
|
28
|
+
// parses a few hundred kilobytes of JSON into objects the sandbox then has to
|
|
29
|
+
// collect — that churn is what made Safari reclaim the tab.
|
|
30
|
+
const POLL_INTERVAL_MS = 60 * 1000;
|
|
31
|
+
// Tasks and Buzz each pull a page of records plus a page of the edit events
|
|
32
|
+
// that make up their comment threads — four requests that were the two most
|
|
33
|
+
// expensive polls in the app. Twenty-five of each is more than a side panel
|
|
34
|
+
// shows before scrolling.
|
|
35
|
+
const TAB_PAGE_SIZE = 25;
|
|
36
|
+
// A panel left open on a second screen should not poll all day. After this long
|
|
37
|
+
// without a touch the loop goes quiet, and the next click inside the panel
|
|
38
|
+
// wakes it and refreshes at once — so idle time costs a timer tick, not a
|
|
39
|
+
// request.
|
|
40
|
+
const IDLE_PAUSE_MS = 5 * 60 * 1000;
|
|
41
|
+
// Every tick asks one cheap question first — "has anything happened at all?" —
|
|
42
|
+
// for about 2 KB, and only pays for the tab's real payload when the answer is
|
|
43
|
+
// yes. A quiet workspace is the common case, and the Buzz tab alone costs
|
|
44
|
+
// 424 KB a poll without this.
|
|
45
|
+
const PROBE_LIMIT = 1;
|
|
46
|
+
// Some of what the panel shows changes with the clock rather than with the
|
|
47
|
+
// data — a task falls overdue, a timestamp turns into "2h". So the probe can
|
|
48
|
+
// suppress a fetch for this long at most, and staleness stays bounded.
|
|
49
|
+
const FULL_REFRESH_EVERY_MS = 10 * 60 * 1000;
|
|
50
|
+
// What the feed holds, deliberately fixed. Paging further back turned the
|
|
51
|
+
// panel into an archive nobody scrolled: a hundred latest events is what a
|
|
52
|
+
// person actually catches up on, and one request keeps them exact — no gaps
|
|
53
|
+
// between pages, no duplicates when a new event arrives mid-scroll.
|
|
54
|
+
const FEED_LIMIT = 100;
|
|
55
|
+
// The feed is a week of work: the denominator under it is the number of
|
|
56
|
+
// updates in that week, and it does not move as pages are loaded.
|
|
57
|
+
const FEED_WINDOW_DAYS = 7;
|
|
58
|
+
const FEED_PAGE_SIZE = 20;
|
|
59
|
+
|
|
60
|
+
// Service rows of our own object are excluded by the server, so the count it
|
|
61
|
+
// returns is the same number the panel would show — no silent discrepancy.
|
|
62
|
+
const feedFilter = () =>
|
|
63
|
+
`happensAt[gte]:${new Date(
|
|
64
|
+
Date.now() - FEED_WINDOW_DAYS * 24 * 60 * 60 * 1000,
|
|
65
|
+
).toISOString()},not(name[startsWith]:${HIDDEN_EVENT_PREFIX})`;
|
|
66
|
+
// Links are looked up for the whole page of records, and a record can carry
|
|
67
|
+
// several — so they get a wider budget than the page itself.
|
|
68
|
+
const LINK_PAGE_SIZE = Math.min(FEED_LIMIT * 2, 200);
|
|
69
|
+
|
|
70
|
+
// An import or a bulk edit produces the same event on dozens of records at
|
|
71
|
+
// once. One row per record buries everything else, so a burst of identical
|
|
72
|
+
// events by one person collapses into a single line.
|
|
73
|
+
const BULK_THRESHOLD = 5;
|
|
74
|
+
const BULK_WINDOW_MS = 5 * 60 * 1000;
|
|
75
|
+
|
|
76
|
+
// The read-state object is written by this very panel, so its own events would
|
|
77
|
+
// otherwise show up in the feed as noise about the feed.
|
|
78
|
+
const HIDDEN_EVENT_PREFIX = 'feedReadState.';
|
|
79
|
+
|
|
80
|
+
const ACTION_LABELS = {
|
|
81
|
+
created: msg('created'),
|
|
82
|
+
updated: msg('updated'),
|
|
83
|
+
deleted: msg('deleted'),
|
|
84
|
+
} as const;
|
|
85
|
+
|
|
86
|
+
// Russian overrides for the standard objects; everything else (including every
|
|
87
|
+
// custom object) takes its label from the workspace's own object metadata.
|
|
88
|
+
const OBJECT_LABELS = {
|
|
89
|
+
person: msg('Person'),
|
|
90
|
+
company: msg('Company'),
|
|
91
|
+
opportunity: msg('Opportunity'),
|
|
92
|
+
task: msg('Task'),
|
|
93
|
+
note: msg('Note'),
|
|
94
|
+
} as const;
|
|
95
|
+
|
|
96
|
+
// Attaching a note or a task to a record emits `linked-<kind>.<action>`, whose
|
|
97
|
+
// target is the record and whose `linkedRecordCachedName` is the note title.
|
|
98
|
+
// This is how comments on a record surface in the timeline.
|
|
99
|
+
const LINKED_KIND_LABELS = {
|
|
100
|
+
note: msg('comment'),
|
|
101
|
+
task: msg('task'),
|
|
102
|
+
attachment: msg('document'),
|
|
103
|
+
} as const;
|
|
104
|
+
|
|
105
|
+
const LINKED_ACTION_LABELS = {
|
|
106
|
+
created: msg('added'),
|
|
107
|
+
updated: msg('edited'),
|
|
108
|
+
deleted: msg('removed'),
|
|
109
|
+
} as const;
|
|
110
|
+
|
|
111
|
+
// Twenty's own palette (twenty-ui/theme MAIN_COLORS_LIGHT, converted from
|
|
112
|
+
// display-p3), matched to the colours the sidebar already gives each object.
|
|
113
|
+
// Object metadata carries no colour field, so this mapping is the only way to
|
|
114
|
+
// agree with what the app renders.
|
|
115
|
+
const OBJECT_COLORS: Record<string, string> = {
|
|
116
|
+
company: '#4662D5', // blue
|
|
117
|
+
person: '#5B5BCF', // iris
|
|
118
|
+
opportunity: '#D45453', // red
|
|
119
|
+
task: '#55A271', // green
|
|
120
|
+
note: '#51A185', // jade
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// twenty-ui MAIN_COLORS_LIGHT, converted from display-p3. SELECT options in
|
|
124
|
+
// field metadata reference these by name.
|
|
125
|
+
const ACCENT_BLUE = '#4662D5';
|
|
126
|
+
|
|
127
|
+
const THEME_COLORS: Record<string, string> = {
|
|
128
|
+
red: '#D45453',
|
|
129
|
+
ruby: '#D45268',
|
|
130
|
+
crimson: '#D74C81',
|
|
131
|
+
tomato: '#D4583B',
|
|
132
|
+
orange: '#E67333',
|
|
133
|
+
amber: '#FFC442',
|
|
134
|
+
yellow: '#FFEB38',
|
|
135
|
+
lime: '#C7ED77',
|
|
136
|
+
grass: '#61A560',
|
|
137
|
+
green: '#55A271',
|
|
138
|
+
jade: '#51A185',
|
|
139
|
+
mint: '#9EE8D5',
|
|
140
|
+
turquoise: '#4CA294',
|
|
141
|
+
cyan: '#48A0C3',
|
|
142
|
+
sky: '#95E0FB',
|
|
143
|
+
blue: '#4662D5',
|
|
144
|
+
iris: '#5B5BCF',
|
|
145
|
+
violet: '#6A57C8',
|
|
146
|
+
purple: '#8551C0',
|
|
147
|
+
plum: '#9F50B5',
|
|
148
|
+
pink: '#C64C9C',
|
|
149
|
+
bronze: '#9C8174',
|
|
150
|
+
gold: '#948469',
|
|
151
|
+
brown: '#A6815E',
|
|
152
|
+
gray: '#999999',
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// Custom objects (dostavki, reklamacii, …) get a stable colour from this
|
|
156
|
+
// fallback ring, keyed by the object name so it never shifts between rows.
|
|
157
|
+
// Deliberately none of the colours used by the standard objects above.
|
|
158
|
+
const FALLBACK_OBJECT_COLORS = [
|
|
159
|
+
'#E67333', // orange
|
|
160
|
+
'#8551C0', // purple
|
|
161
|
+
'#C64C9C', // pink
|
|
162
|
+
'#4CA294', // turquoise
|
|
163
|
+
'#FFEB38', // yellow
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
type TimelineRecord = Record<string, unknown>;
|
|
167
|
+
|
|
168
|
+
type FeedGroup = { key: string; items: TimelineRecord[] };
|
|
169
|
+
|
|
170
|
+
// REST answers with the rows plus how many there are in total and where the
|
|
171
|
+
// page ended — that is what makes "loaded 96 of 199" and paging possible.
|
|
172
|
+
type Page<K extends string> = {
|
|
173
|
+
data?: Partial<Record<K, TimelineRecord[]>>;
|
|
174
|
+
totalCount?: number;
|
|
175
|
+
pageInfo?: { endCursor?: string; hasNextPage?: boolean };
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// The server's cursor is nothing but the sort key of the last row, base64'd —
|
|
179
|
+
// verified byte-for-byte against `pageInfo.endCursor`. Building it from the
|
|
180
|
+
// oldest row we already hold means paging never depends on a `pageInfo` that
|
|
181
|
+
// may not survive a poll, and it repairs itself after any refresh.
|
|
182
|
+
const cursorFor = (item: TimelineRecord) =>
|
|
183
|
+
btoa(JSON.stringify({ happensAt: item.happensAt, id: item.id }));
|
|
184
|
+
|
|
185
|
+
const isVisibleEvent = (item: TimelineRecord) => {
|
|
186
|
+
if (typeof item.name !== 'string') {
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (item.name.startsWith(HIDDEN_EVENT_PREFIX)) {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// An update whose every changed field is bookkeeping — or reports the same
|
|
195
|
+
// value on both sides — leaves a card with a heading, a chip and nothing
|
|
196
|
+
// underneath. Something happened to the record, but there is nothing in it
|
|
197
|
+
// for a reader. Creations carry no diff at all by design and stay.
|
|
198
|
+
if (item.name.endsWith('.updated')) {
|
|
199
|
+
return extractDiff(item.properties).length > 0;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return true;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
type FieldMeta = {
|
|
206
|
+
label: string;
|
|
207
|
+
options?: Record<string, { label: string; color: string }>;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
type ResolvedTarget = {
|
|
211
|
+
objectNameSingular: string;
|
|
212
|
+
recordId: string;
|
|
213
|
+
label: string;
|
|
214
|
+
avatarUrl?: string;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const readDisplayName = (record: unknown): string => {
|
|
218
|
+
if (record === null || typeof record !== 'object') {
|
|
219
|
+
return '';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const { name, title } = record as { name?: unknown; title?: unknown };
|
|
223
|
+
|
|
224
|
+
if (typeof name === 'string') {
|
|
225
|
+
return name;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (name !== null && typeof name === 'object') {
|
|
229
|
+
const { firstName, lastName } = name as {
|
|
230
|
+
firstName?: string;
|
|
231
|
+
lastName?: string;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
return [firstName, lastName].filter(Boolean).join(' ');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return typeof title === 'string' ? title : '';
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const readMemberId = (value: unknown): string | undefined => {
|
|
241
|
+
if (value === null || typeof value !== 'object') {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const { workspaceMemberId, id } = value as {
|
|
246
|
+
workspaceMemberId?: unknown;
|
|
247
|
+
id?: unknown;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
if (typeof workspaceMemberId === 'string' && workspaceMemberId !== '') {
|
|
251
|
+
return workspaceMemberId;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return typeof id === 'string' && id !== '' ? id : undefined;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const readAvatarUrl = (record: unknown): string | undefined => {
|
|
258
|
+
if (record === null || typeof record !== 'object') {
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const { avatarUrl } = record as { avatarUrl?: unknown };
|
|
263
|
+
|
|
264
|
+
return typeof avatarUrl === 'string' && avatarUrl !== '' ? avatarUrl : undefined;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// Both the object type and the record id come from whichever `target<Object>Id`
|
|
268
|
+
// is filled in — deliberately not from the event name. `linked-note.created`
|
|
269
|
+
// carries `targetCompanyId`, so trusting the event name would build the route
|
|
270
|
+
// /object/linked-note/<company id>. Reading the raw REST payload also makes
|
|
271
|
+
// custom objects — dostavki, reklamacii — resolvable without naming them.
|
|
272
|
+
const resolveTarget = (item: TimelineRecord): ResolvedTarget | null => {
|
|
273
|
+
const idEntry = Object.entries(item).find(
|
|
274
|
+
([key, value]) =>
|
|
275
|
+
key.startsWith('target') &&
|
|
276
|
+
key.endsWith('Id') &&
|
|
277
|
+
typeof value === 'string' &&
|
|
278
|
+
value !== '',
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
if (idEntry === undefined) {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const relationKey = idEntry[0].slice(0, -2);
|
|
286
|
+
const bare = relationKey.slice('target'.length);
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
objectNameSingular: bare.charAt(0).toLowerCase() + bare.slice(1),
|
|
290
|
+
recordId: idEntry[1] as string,
|
|
291
|
+
label: readDisplayName(item[relationKey]),
|
|
292
|
+
avatarUrl: readAvatarUrl(item[relationKey]),
|
|
293
|
+
};
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const ATTACHMENT_EVENT = 'linked-attachment.created';
|
|
297
|
+
|
|
298
|
+
// Attaching a file emits no timeline event at all — verified against a live
|
|
299
|
+
// instance. Attachments carry their own `target<Object>Id` and `createdAt`,
|
|
300
|
+
// so they are read separately and folded into the feed as synthetic events.
|
|
301
|
+
const toAttachmentEvent = (attachment: TimelineRecord): TimelineRecord => ({
|
|
302
|
+
...attachment,
|
|
303
|
+
id: `attachment-${String(attachment.id)}`,
|
|
304
|
+
name: ATTACHMENT_EVENT,
|
|
305
|
+
happensAt: attachment.createdAt,
|
|
306
|
+
linkedRecordCachedName: attachment.name,
|
|
307
|
+
properties: null,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const GOOGLE_DRIVE_HOSTS = ['drive.google.com', 'docs.google.com'];
|
|
311
|
+
|
|
312
|
+
// One glyph for every format. Per-extension icons turned the strip into a
|
|
313
|
+
// sticker sheet, and Twenty's own pack has no per-format family anyway — a
|
|
314
|
+
// link is the only thing worth telling apart, because it is not a file.
|
|
315
|
+
const getFileIcon = (fileName: string) => {
|
|
316
|
+
const name = fileName.toLowerCase();
|
|
317
|
+
|
|
318
|
+
if (!name.startsWith('http')) {
|
|
319
|
+
return IconFile;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return GOOGLE_DRIVE_HOSTS.some((host) => name.includes(host))
|
|
323
|
+
? IconBrandGoogle
|
|
324
|
+
: IconLink;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const readMarkdown = (body: unknown) => {
|
|
328
|
+
if (body === null || typeof body !== 'object') {
|
|
329
|
+
return '';
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const { markdown } = body as { markdown?: unknown };
|
|
333
|
+
|
|
334
|
+
return typeof markdown === 'string' ? markdown : '';
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
// An edit either appends to the note (a comment) or rewrites it. Only the
|
|
338
|
+
// appended part is worth showing — that is the thing that was actually said.
|
|
339
|
+
const readNoteEdit = (event: TimelineRecord) => {
|
|
340
|
+
const diff = (
|
|
341
|
+
event.properties as { diff?: { bodyV2?: { before?: unknown; after?: unknown } } } | null
|
|
342
|
+
)?.diff?.bodyV2;
|
|
343
|
+
|
|
344
|
+
const before = readMarkdown(diff?.before);
|
|
345
|
+
const after = readMarkdown(diff?.after);
|
|
346
|
+
|
|
347
|
+
if (after !== '' && after.startsWith(before)) {
|
|
348
|
+
return { text: after.slice(before.length).trim(), isRewrite: false, before };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return { text: after.trim(), isRewrite: true, before };
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
// The note as it stood when the comment was written: its last paragraphs.
|
|
355
|
+
// `before` is the exact body at that moment, so the context is reconstructed
|
|
356
|
+
// precisely rather than guessed from the current text.
|
|
357
|
+
const readContextBefore = (before: string, paragraphs = 2) => {
|
|
358
|
+
const blocks = before
|
|
359
|
+
.split(/\n{2,}/)
|
|
360
|
+
.map((block) => block.trim())
|
|
361
|
+
.filter((block) => block !== '');
|
|
362
|
+
|
|
363
|
+
return blocks.slice(-paragraphs);
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const isRichTextValue = (value: unknown) =>
|
|
367
|
+
value !== null && typeof value === 'object' && 'markdown' in value;
|
|
368
|
+
|
|
369
|
+
const truncate = (text: string, limit = 90) =>
|
|
370
|
+
text.length > limit ? `${text.slice(0, limit).trimEnd()}…` : text;
|
|
371
|
+
|
|
372
|
+
// A rich-text diff is two whole documents. Neither is worth printing in a feed
|
|
373
|
+
// row — what changed is.
|
|
374
|
+
const describeRichTextChange = (beforeRaw: unknown, afterRaw: unknown) => {
|
|
375
|
+
const before = readMarkdown(beforeRaw);
|
|
376
|
+
const after = readMarkdown(afterRaw);
|
|
377
|
+
|
|
378
|
+
if (before === after) {
|
|
379
|
+
return t('no change');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (before === '') {
|
|
383
|
+
return t('text added: {text}', { text: truncate(after) });
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (after === '') {
|
|
387
|
+
return t('text removed');
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (after.startsWith(before)) {
|
|
391
|
+
return t('appended: {text}', { text: truncate(after.slice(before.length).trim()) });
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (before.startsWith(after)) {
|
|
395
|
+
return t('text shortened');
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return t('rewritten: {text}', { text: truncate(after) });
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const byHappensAtDesc = (a: TimelineRecord, b: TimelineRecord) =>
|
|
402
|
+
new Date(String(b.happensAt)).getTime() -
|
|
403
|
+
new Date(String(a.happensAt)).getTime();
|
|
404
|
+
|
|
405
|
+
// Diff values arrive as raw column payloads: plain scalars, composites
|
|
406
|
+
// (FULL_NAME, CURRENCY, ACTOR) and relations reduced to { id }. Dumping JSON
|
|
407
|
+
// at the user is not an option.
|
|
408
|
+
// Raw ISO timestamps are unreadable in a diff line. When the value carries no
|
|
409
|
+
// meaningful time of day, the date alone is enough.
|
|
410
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
|
|
411
|
+
|
|
412
|
+
const formatDateValue = (value: string) => {
|
|
413
|
+
const date = new Date(value);
|
|
414
|
+
|
|
415
|
+
if (Number.isNaN(date.getTime())) {
|
|
416
|
+
return value;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
420
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
421
|
+
const stamp = `${day}.${month}.${date.getFullYear()}`;
|
|
422
|
+
|
|
423
|
+
if (date.getHours() === 0 && date.getMinutes() === 0) {
|
|
424
|
+
return stamp;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const hours = String(date.getHours()).padStart(2, '0');
|
|
428
|
+
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
429
|
+
|
|
430
|
+
return `${stamp}, ${hours}:${minutes}`;
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const formatValue = (
|
|
434
|
+
value: unknown,
|
|
435
|
+
memberNames: Record<string, string>,
|
|
436
|
+
): string => {
|
|
437
|
+
if (value === null || value === undefined || value === '') {
|
|
438
|
+
return '—';
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (typeof value === 'string' && ISO_DATE.test(value)) {
|
|
442
|
+
return formatDateValue(value);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (typeof value !== 'object') {
|
|
446
|
+
return String(value);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const record = value as Record<string, unknown>;
|
|
450
|
+
|
|
451
|
+
// ACTOR — createdBy / updatedBy and friends.
|
|
452
|
+
if (typeof record.name === 'string' && record.name !== '') {
|
|
453
|
+
return record.name;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// FULL_NAME
|
|
457
|
+
const composedName = [record.firstName, record.lastName]
|
|
458
|
+
.filter((part) => typeof part === 'string' && part !== '')
|
|
459
|
+
.join(' ');
|
|
460
|
+
|
|
461
|
+
if (composedName !== '') {
|
|
462
|
+
return composedName;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// CURRENCY
|
|
466
|
+
if (typeof record.amountMicros === 'number') {
|
|
467
|
+
const amount = record.amountMicros / 1_000_000;
|
|
468
|
+
|
|
469
|
+
return `${amount} ${String(record.currencyCode ?? '')}`.trim();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// LINKS / EMAILS / PHONES keep the primary value up front.
|
|
473
|
+
const primary =
|
|
474
|
+
record.primaryLinkUrl ?? record.primaryEmail ?? record.primaryPhoneNumber;
|
|
475
|
+
|
|
476
|
+
if (typeof primary === 'string' && primary !== '') {
|
|
477
|
+
return primary;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// A relation, reduced to its id — resolvable when it points at a teammate.
|
|
481
|
+
if (typeof record.id === 'string') {
|
|
482
|
+
return memberNames[record.id] ?? '—';
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return '—';
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
// Bookkeeping fields change on every single write, so their diffs are noise.
|
|
489
|
+
const HIDDEN_DIFF_FIELDS = [
|
|
490
|
+
'updatedBy',
|
|
491
|
+
'createdBy',
|
|
492
|
+
'updatedAt',
|
|
493
|
+
'createdAt',
|
|
494
|
+
'deletedAt',
|
|
495
|
+
'position',
|
|
496
|
+
'searchVector',
|
|
497
|
+
];
|
|
498
|
+
|
|
499
|
+
// properties is RAW_JSON — the server puts a { diff: { field: { before, after } } }
|
|
500
|
+
// payload there on `updated` events, and an empty object on `created`.
|
|
501
|
+
const extractDiff = (properties: unknown) => {
|
|
502
|
+
const diff = (properties as { diff?: Record<string, unknown> } | null)?.diff;
|
|
503
|
+
|
|
504
|
+
if (!diff || typeof diff !== 'object') {
|
|
505
|
+
return [];
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return Object.entries(diff)
|
|
509
|
+
.filter(([field]) => !HIDDEN_DIFF_FIELDS.includes(field))
|
|
510
|
+
.map(([field, change]) => {
|
|
511
|
+
const { before, after } = (change ?? {}) as {
|
|
512
|
+
before?: unknown;
|
|
513
|
+
after?: unknown;
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
return { field, beforeRaw: before, afterRaw: after };
|
|
517
|
+
})
|
|
518
|
+
// A field the server reports as changed while both sides are equal has
|
|
519
|
+
// nothing to show, and "title: Report → Report" reads like a bug.
|
|
520
|
+
//
|
|
521
|
+
// Rich text needs comparing by its text: a body re-saved unchanged still
|
|
522
|
+
// differs as JSON, because every block is reissued with a fresh id, and
|
|
523
|
+
// that produced rows reading "Body: no change".
|
|
524
|
+
.filter(({ beforeRaw, afterRaw }) => {
|
|
525
|
+
if (isRichTextValue(beforeRaw) && isRichTextValue(afterRaw)) {
|
|
526
|
+
return readMarkdown(beforeRaw) !== readMarkdown(afterRaw);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
try {
|
|
530
|
+
return JSON.stringify(beforeRaw) !== JSON.stringify(afterRaw);
|
|
531
|
+
} catch {
|
|
532
|
+
return true;
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
const formatAgo = (isoDate: string) => {
|
|
538
|
+
const minutes = Math.floor((Date.now() - new Date(isoDate).getTime()) / 60_000);
|
|
539
|
+
|
|
540
|
+
if (minutes < 1) {
|
|
541
|
+
return t('just now');
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (minutes < 60) {
|
|
545
|
+
return t('{minutes}m', { minutes });
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const hours = Math.floor(minutes / 60);
|
|
549
|
+
|
|
550
|
+
if (hours < 24) {
|
|
551
|
+
return t('{hours}h', { hours });
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return t('{days}d', { days: Math.floor(hours / 24) });
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
558
|
+
|
|
559
|
+
const describeDue = (dueAt: unknown) => {
|
|
560
|
+
if (typeof dueAt !== 'string' || dueAt === '') {
|
|
561
|
+
return { label: t('no due date'), overdueDays: 0, sortKey: Number.MAX_SAFE_INTEGER };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const due = new Date(dueAt).getTime();
|
|
565
|
+
const days = Math.floor((Date.now() - due) / DAY_MS);
|
|
566
|
+
|
|
567
|
+
if (days > 0) {
|
|
568
|
+
return {
|
|
569
|
+
label: t('{days}d overdue', { days }),
|
|
570
|
+
overdueDays: days,
|
|
571
|
+
sortKey: due,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (days === 0) {
|
|
576
|
+
return { label: t('today'), overdueDays: 0, sortKey: due };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return { label: t('in {days}d', { days: -days }), overdueDays: 0, sortKey: due };
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
const getObjectColor = (objectNameSingular: string) => {
|
|
583
|
+
const known = OBJECT_COLORS[objectNameSingular];
|
|
584
|
+
|
|
585
|
+
if (known !== undefined) {
|
|
586
|
+
return known;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
let hash = 0;
|
|
590
|
+
|
|
591
|
+
for (let index = 0; index < objectNameSingular.length; index++) {
|
|
592
|
+
hash = (hash * 31 + objectNameSingular.charCodeAt(index)) % 100_000;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
return FALLBACK_OBJECT_COLORS[hash % FALLBACK_OBJECT_COLORS.length];
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
const getInitials = (value: string) =>
|
|
599
|
+
value
|
|
600
|
+
.split(' ')
|
|
601
|
+
.filter(Boolean)
|
|
602
|
+
.slice(0, 2)
|
|
603
|
+
.map((part) => part[0]?.toUpperCase() ?? '')
|
|
604
|
+
.join('');
|
|
605
|
+
|
|
606
|
+
const getPalette = (colorScheme: 'light' | 'dark') =>
|
|
607
|
+
colorScheme === 'dark'
|
|
608
|
+
? {
|
|
609
|
+
text: '#E8E8E8',
|
|
610
|
+
textMid: '#A8A8A8',
|
|
611
|
+
textLight: '#7E7E7E',
|
|
612
|
+
border: '#282828',
|
|
613
|
+
rail: '#333333',
|
|
614
|
+
hover: '#1F1F1F',
|
|
615
|
+
buttonBackground: 'rgba(255, 255, 255, 0.06)',
|
|
616
|
+
// A card under the cursor is marked by its outline alone — the fill
|
|
617
|
+
// stays flat so the feed does not flicker as the pointer crosses it.
|
|
618
|
+
cardHoverBorder: 'rgba(149, 224, 251, 0.24)',
|
|
619
|
+
// twenty-ui `sky`, the softest blue in the palette: enough to mark a
|
|
620
|
+
// row as unread, not enough to shout over the text.
|
|
621
|
+
unread: 'rgba(149, 224, 251, 0.09)',
|
|
622
|
+
unreadHover: 'rgba(149, 224, 251, 0.16)',
|
|
623
|
+
mutedFill: '#3A3A3A',
|
|
624
|
+
mutedGlyph: '#8F8F8F',
|
|
625
|
+
}
|
|
626
|
+
: {
|
|
627
|
+
// twenty-ui FONT_LIGHT tokens: primary / secondary / tertiary.
|
|
628
|
+
text: '#333333',
|
|
629
|
+
textMid: '#666666',
|
|
630
|
+
textLight: '#999999',
|
|
631
|
+
border: '#EDEDED',
|
|
632
|
+
rail: '#E3E3E3',
|
|
633
|
+
hover: '#0000000A',
|
|
634
|
+
buttonBackground: 'rgba(0, 0, 0, 0.035)',
|
|
635
|
+
cardHoverBorder: 'rgba(70, 98, 213, 0.26)',
|
|
636
|
+
unread: 'rgba(149, 224, 251, 0.16)',
|
|
637
|
+
unreadHover: 'rgba(149, 224, 251, 0.28)',
|
|
638
|
+
mutedFill: '#F0F0F0',
|
|
639
|
+
mutedGlyph: '#999999',
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
const InlineAvatar = ({
|
|
643
|
+
label,
|
|
644
|
+
color,
|
|
645
|
+
textColor,
|
|
646
|
+
avatarUrl,
|
|
647
|
+
size,
|
|
648
|
+
}: {
|
|
649
|
+
label: string;
|
|
650
|
+
color: string;
|
|
651
|
+
textColor: string;
|
|
652
|
+
avatarUrl?: string;
|
|
653
|
+
size: number;
|
|
654
|
+
}) => {
|
|
655
|
+
if (avatarUrl !== undefined) {
|
|
656
|
+
return (
|
|
657
|
+
<img
|
|
658
|
+
src={avatarUrl}
|
|
659
|
+
alt=""
|
|
660
|
+
style={{
|
|
661
|
+
width: `${size}px`,
|
|
662
|
+
height: `${size}px`,
|
|
663
|
+
borderRadius: '3px',
|
|
664
|
+
objectFit: 'cover',
|
|
665
|
+
flexShrink: 0,
|
|
666
|
+
}}
|
|
667
|
+
/>
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return (
|
|
672
|
+
<span
|
|
673
|
+
style={{
|
|
674
|
+
display: 'inline-flex',
|
|
675
|
+
alignItems: 'center',
|
|
676
|
+
justifyContent: 'center',
|
|
677
|
+
width: `${size}px`,
|
|
678
|
+
height: `${size}px`,
|
|
679
|
+
borderRadius: '3px',
|
|
680
|
+
background: color,
|
|
681
|
+
color: textColor,
|
|
682
|
+
fontSize: `${Math.round(size * 0.5)}px`,
|
|
683
|
+
fontWeight: 500,
|
|
684
|
+
flexShrink: 0,
|
|
685
|
+
}}
|
|
686
|
+
>
|
|
687
|
+
{getInitials(label) || '?'}
|
|
688
|
+
</span>
|
|
689
|
+
);
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
const ToolbarButton = ({
|
|
693
|
+
label,
|
|
694
|
+
title,
|
|
695
|
+
onClick,
|
|
696
|
+
background,
|
|
697
|
+
color,
|
|
698
|
+
isActive,
|
|
699
|
+
activeColor,
|
|
700
|
+
}: {
|
|
701
|
+
label: string;
|
|
702
|
+
title?: string;
|
|
703
|
+
onClick: () => void;
|
|
704
|
+
background: string;
|
|
705
|
+
color: string;
|
|
706
|
+
isActive?: boolean;
|
|
707
|
+
activeColor?: string;
|
|
708
|
+
}) => (
|
|
709
|
+
<button
|
|
710
|
+
type="button"
|
|
711
|
+
onClick={onClick}
|
|
712
|
+
title={title}
|
|
713
|
+
aria-pressed={isActive}
|
|
714
|
+
style={{
|
|
715
|
+
padding: '4px 9px',
|
|
716
|
+
borderRadius: '4px',
|
|
717
|
+
whiteSpace: 'nowrap',
|
|
718
|
+
flexShrink: 0,
|
|
719
|
+
border:
|
|
720
|
+
isActive === true
|
|
721
|
+
? `1px solid ${activeColor ?? color}`
|
|
722
|
+
: '1px solid transparent',
|
|
723
|
+
background: isActive === true ? 'transparent' : background,
|
|
724
|
+
color: isActive === true ? (activeColor ?? color) : color,
|
|
725
|
+
fontSize: '0.85rem',
|
|
726
|
+
fontWeight: isActive === true ? 500 : 400,
|
|
727
|
+
cursor: 'pointer',
|
|
728
|
+
fontFamily: 'inherit',
|
|
729
|
+
}}
|
|
730
|
+
>
|
|
731
|
+
{label}
|
|
732
|
+
</button>
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
const ActivityFeed = () => {
|
|
736
|
+
const colorScheme = useColorScheme();
|
|
737
|
+
const palette = getPalette(colorScheme);
|
|
738
|
+
const userId = useUserId();
|
|
739
|
+
|
|
740
|
+
const [items, setItems] = useState<TimelineRecord[]>([]);
|
|
741
|
+
const [objectLabels, setObjectLabels] = useState<Record<string, string>>({});
|
|
742
|
+
const [fieldMeta, setFieldMeta] = useState<Record<string, FieldMeta>>({});
|
|
743
|
+
const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
|
|
744
|
+
const [expandedComments, setExpandedComments] = useState<string[]>([]);
|
|
745
|
+
const [expandedThreads, setExpandedThreads] = useState<string[]>([]);
|
|
746
|
+
// Cards keep their own hover state: the inner rows and comments share
|
|
747
|
+
// `hoveredId`, and their onMouseLeave would otherwise clear the card's
|
|
748
|
+
// highlight while the cursor is still inside it.
|
|
749
|
+
const [hoveredCard, setHoveredCard] = useState<string | null>(null);
|
|
750
|
+
const [memberId, setMemberId] = useState<string | null>(null);
|
|
751
|
+
const [myCompanyIds, setMyCompanyIds] = useState<string[]>([]);
|
|
752
|
+
const [memberNames, setMemberNames] = useState<Record<string, string>>({});
|
|
753
|
+
const [memberAvatars, setMemberAvatars] = useState<Record<string, string>>({});
|
|
754
|
+
const [enforcement, setEnforcement] = useState<
|
|
755
|
+
'server' | 'client' | 'unknown'
|
|
756
|
+
>('unknown');
|
|
757
|
+
const [view, setView] = useState<'feed' | 'tasks' | 'buzz'>('feed');
|
|
758
|
+
const [tasks, setTasks] = useState<TimelineRecord[]>([]);
|
|
759
|
+
// Every task assigned to the reader that is not done — the whole workspace,
|
|
760
|
+
// not the loaded page. Null until the viewer is known.
|
|
761
|
+
const [assignedOpenCount, setAssignedOpenCount] = useState<number | null>(null);
|
|
762
|
+
const [notes, setNotes] = useState<TimelineRecord[]>([]);
|
|
763
|
+
const [noteEdits, setNoteEdits] = useState<TimelineRecord[]>([]);
|
|
764
|
+
const [noteTargets, setNoteTargets] = useState<TimelineRecord[]>([]);
|
|
765
|
+
const [taskEdits, setTaskEdits] = useState<TimelineRecord[]>([]);
|
|
766
|
+
const [taskTargets, setTaskTargets] = useState<TimelineRecord[]>([]);
|
|
767
|
+
const [lastSeenAt, setLastSeenAt] = useState<string | null>(null);
|
|
768
|
+
const [readStateId, setReadStateId] = useState<string | null>(null);
|
|
769
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
770
|
+
const [error, setError] = useState<string | null>(null);
|
|
771
|
+
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
|
772
|
+
const [feedTotal, setFeedTotal] = useState(0);
|
|
773
|
+
// Pages fetched behind the first one. Kept apart from `items` so the poll can
|
|
774
|
+
// refresh the newest page without throwing away what the reader paged in.
|
|
775
|
+
const [olderItems, setOlderItems] = useState<TimelineRecord[]>([]);
|
|
776
|
+
const [isFeedExhausted, setIsFeedExhausted] = useState(false);
|
|
777
|
+
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
778
|
+
// Files are refreshed with the links rather than on every poll: a hundred
|
|
779
|
+
// attachments at depth 1 is a quarter-megabyte of JSON, and a document that
|
|
780
|
+
// appears half a minute later is nobody's emergency.
|
|
781
|
+
const [documentItems, setDocumentItems] = useState<TimelineRecord[]>([]);
|
|
782
|
+
// A file filed under a contact says nothing about the business behind it, so
|
|
783
|
+
// the card borrows context: the contact's company, and that company's live
|
|
784
|
+
// deal. Kept as flat maps because that is all the chips need.
|
|
785
|
+
const [companyNames, setCompanyNames] = useState<Record<string, string>>({});
|
|
786
|
+
const [companyDeals, setCompanyDeals] = useState<
|
|
787
|
+
Record<string, { id: string; name: string }>
|
|
788
|
+
>({});
|
|
789
|
+
// Refs, not state: a click must not re-create the polling interval, and the
|
|
790
|
+
// interval must read the latest values without being rebuilt around them.
|
|
791
|
+
const activeAtRef = useRef(Date.now());
|
|
792
|
+
const pausedRef = useRef(false);
|
|
793
|
+
const refreshRef = useRef<() => void>(() => {});
|
|
794
|
+
const probeRef = useRef<{ topId: string; total: number } | null>(null);
|
|
795
|
+
const lastFullLoadAtRef = useRef(0);
|
|
796
|
+
const [isPaused, setIsPaused] = useState(false);
|
|
797
|
+
|
|
798
|
+
const markActive = useCallback(() => {
|
|
799
|
+
activeAtRef.current = Date.now();
|
|
800
|
+
|
|
801
|
+
if (pausedRef.current) {
|
|
802
|
+
pausedRef.current = false;
|
|
803
|
+
setIsPaused(false);
|
|
804
|
+
refreshRef.current();
|
|
805
|
+
}
|
|
806
|
+
}, []);
|
|
807
|
+
|
|
808
|
+
// One request, one row, no relations: the id of the newest event plus the
|
|
809
|
+
// count of the window. Both unchanged means nothing has happened since the
|
|
810
|
+
// last look — the timeline is append-only, so an edit anywhere in the
|
|
811
|
+
// workspace shows up here as a new top row.
|
|
812
|
+
const probeForNews = useCallback(async () => {
|
|
813
|
+
try {
|
|
814
|
+
const probe = await new RestApiClient().get<Page<'timelineActivities'>>(
|
|
815
|
+
'/rest/timelineActivities',
|
|
816
|
+
{
|
|
817
|
+
query: {
|
|
818
|
+
filter: feedFilter(),
|
|
819
|
+
limit: PROBE_LIMIT,
|
|
820
|
+
depth: 0,
|
|
821
|
+
order_by: 'happensAt[DescNullsLast]',
|
|
822
|
+
},
|
|
823
|
+
},
|
|
824
|
+
);
|
|
825
|
+
|
|
826
|
+
const total = probe.totalCount ?? 0;
|
|
827
|
+
const topId = String(probe.data?.timelineActivities?.[0]?.id ?? '');
|
|
828
|
+
|
|
829
|
+
// The denominator under the feed rides along for free.
|
|
830
|
+
setFeedTotal(total);
|
|
831
|
+
|
|
832
|
+
const seen = probeRef.current;
|
|
833
|
+
|
|
834
|
+
probeRef.current = { topId, total };
|
|
835
|
+
|
|
836
|
+
return seen === null || seen.topId !== topId || seen.total !== total;
|
|
837
|
+
} catch {
|
|
838
|
+
// A failed probe must not swallow the poll: fall through and fetch.
|
|
839
|
+
return true;
|
|
840
|
+
}
|
|
841
|
+
}, []);
|
|
842
|
+
|
|
843
|
+
const loadFeed = useCallback(async () => {
|
|
844
|
+
try {
|
|
845
|
+
const client = new RestApiClient();
|
|
846
|
+
|
|
847
|
+
const timeline = await client.get<Page<'timelineActivities'>>(
|
|
848
|
+
'/rest/timelineActivities',
|
|
849
|
+
{
|
|
850
|
+
query: {
|
|
851
|
+
filter: feedFilter(),
|
|
852
|
+
limit: FEED_PAGE_SIZE,
|
|
853
|
+
depth: 1,
|
|
854
|
+
order_by: 'happensAt[DescNullsLast]',
|
|
855
|
+
},
|
|
856
|
+
},
|
|
857
|
+
);
|
|
858
|
+
|
|
859
|
+
setItems(
|
|
860
|
+
(timeline.data?.timelineActivities ?? []).filter(isVisibleEvent),
|
|
861
|
+
);
|
|
862
|
+
setFeedTotal(timeline.totalCount ?? 0);
|
|
863
|
+
setError(null);
|
|
864
|
+
} catch (caught) {
|
|
865
|
+
setError(caught instanceof Error ? caught.message : String(caught));
|
|
866
|
+
} finally {
|
|
867
|
+
setIsLoading(false);
|
|
868
|
+
}
|
|
869
|
+
}, []);
|
|
870
|
+
|
|
871
|
+
// One page further back, never past the cap. Files do not page: they live in
|
|
872
|
+
// their own strip with its own expander.
|
|
873
|
+
const loadMore = async () => {
|
|
874
|
+
const oldest = loadedEvents[loadedEvents.length - 1];
|
|
875
|
+
|
|
876
|
+
if (oldest === undefined || loadedEvents.length >= FEED_LIMIT) {
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
setIsLoadingMore(true);
|
|
881
|
+
|
|
882
|
+
try {
|
|
883
|
+
const page = await new RestApiClient().get<Page<'timelineActivities'>>(
|
|
884
|
+
'/rest/timelineActivities',
|
|
885
|
+
{
|
|
886
|
+
query: {
|
|
887
|
+
filter: feedFilter(),
|
|
888
|
+
// A whole page every time. Trimming the request to the remaining
|
|
889
|
+
// room made the last clicks add one or two rows each; the hard cap
|
|
890
|
+
// on `changes` does the trimming instead.
|
|
891
|
+
limit: FEED_PAGE_SIZE,
|
|
892
|
+
depth: 1,
|
|
893
|
+
order_by: 'happensAt[DescNullsLast]',
|
|
894
|
+
starting_after: cursorFor(oldest),
|
|
895
|
+
},
|
|
896
|
+
},
|
|
897
|
+
);
|
|
898
|
+
|
|
899
|
+
const fetched = (page.data?.timelineActivities ?? []).filter(isVisibleEvent);
|
|
900
|
+
|
|
901
|
+
setIsFeedExhausted(fetched.length === 0);
|
|
902
|
+
setOlderItems((prev) => [...prev, ...fetched]);
|
|
903
|
+
} catch (caught) {
|
|
904
|
+
setError(caught instanceof Error ? caught.message : String(caught));
|
|
905
|
+
} finally {
|
|
906
|
+
setIsLoadingMore(false);
|
|
907
|
+
}
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
// Custom objects should read by their own label, not their API name —
|
|
911
|
+
// own metadata is the only place those labels exist.
|
|
912
|
+
const loadObjectLabels = useCallback(async () => {
|
|
913
|
+
try {
|
|
914
|
+
const client = new RestApiClient();
|
|
915
|
+
|
|
916
|
+
const response = await client.get<{
|
|
917
|
+
data?: {
|
|
918
|
+
objects?: {
|
|
919
|
+
nameSingular: string;
|
|
920
|
+
labelSingular: string;
|
|
921
|
+
fields?: {
|
|
922
|
+
name: string;
|
|
923
|
+
label: string;
|
|
924
|
+
options?: { value: string; label: string; color: string }[];
|
|
925
|
+
}[];
|
|
926
|
+
}[];
|
|
927
|
+
};
|
|
928
|
+
}>('/rest/metadata/objects', { query: { limit: 200 } });
|
|
929
|
+
|
|
930
|
+
const objects = response.data?.objects ?? [];
|
|
931
|
+
|
|
932
|
+
setObjectLabels(
|
|
933
|
+
Object.fromEntries(
|
|
934
|
+
objects.map((object) => [object.nameSingular, object.labelSingular]),
|
|
935
|
+
),
|
|
936
|
+
);
|
|
937
|
+
|
|
938
|
+
// SELECT options come with their own label and theme colour, which is
|
|
939
|
+
// what turns `stage: NEW → MEETING` into the labels the record shows.
|
|
940
|
+
setFieldMeta(
|
|
941
|
+
Object.fromEntries(
|
|
942
|
+
objects.flatMap((object) =>
|
|
943
|
+
(object.fields ?? []).map((field) => [
|
|
944
|
+
`${object.nameSingular}.${field.name}`,
|
|
945
|
+
{
|
|
946
|
+
label: field.label,
|
|
947
|
+
options:
|
|
948
|
+
field.options === undefined
|
|
949
|
+
? undefined
|
|
950
|
+
: Object.fromEntries(
|
|
951
|
+
field.options.map((option) => [
|
|
952
|
+
option.value,
|
|
953
|
+
{ label: option.label, color: option.color },
|
|
954
|
+
]),
|
|
955
|
+
),
|
|
956
|
+
},
|
|
957
|
+
]),
|
|
958
|
+
),
|
|
959
|
+
),
|
|
960
|
+
);
|
|
961
|
+
} catch {
|
|
962
|
+
// Without labels the feed falls back to raw object names.
|
|
963
|
+
}
|
|
964
|
+
}, []);
|
|
965
|
+
|
|
966
|
+
// Who is looking. The app token carries the application's role, not the
|
|
967
|
+
// viewer's, so the panel has to work out relevance itself.
|
|
968
|
+
const loadViewer = useCallback(async () => {
|
|
969
|
+
if (userId === null) {
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
try {
|
|
974
|
+
const client = new RestApiClient();
|
|
975
|
+
|
|
976
|
+
const members = await client.get<{
|
|
977
|
+
data?: {
|
|
978
|
+
workspaceMembers?: {
|
|
979
|
+
id: string;
|
|
980
|
+
userId?: string;
|
|
981
|
+
name?: { firstName?: string; lastName?: string };
|
|
982
|
+
avatarUrl?: string;
|
|
983
|
+
}[];
|
|
984
|
+
};
|
|
985
|
+
}>('/rest/workspaceMembers', { query: { limit: 200 } });
|
|
986
|
+
|
|
987
|
+
const allMembers = members.data?.workspaceMembers ?? [];
|
|
988
|
+
|
|
989
|
+
setMemberNames(
|
|
990
|
+
Object.fromEntries(
|
|
991
|
+
allMembers.map((member) => [
|
|
992
|
+
member.id,
|
|
993
|
+
[member.name?.firstName, member.name?.lastName]
|
|
994
|
+
.filter(Boolean)
|
|
995
|
+
.join(' '),
|
|
996
|
+
]),
|
|
997
|
+
),
|
|
998
|
+
);
|
|
999
|
+
|
|
1000
|
+
setMemberAvatars(
|
|
1001
|
+
Object.fromEntries(
|
|
1002
|
+
allMembers
|
|
1003
|
+
.filter((member) => (member.avatarUrl ?? '') !== '')
|
|
1004
|
+
.map((member) => [member.id, member.avatarUrl as string]),
|
|
1005
|
+
),
|
|
1006
|
+
);
|
|
1007
|
+
|
|
1008
|
+
const id =
|
|
1009
|
+
allMembers.find((member) => member.userId === userId)?.id ?? null;
|
|
1010
|
+
|
|
1011
|
+
setMemberId(id);
|
|
1012
|
+
|
|
1013
|
+
if (id === null) {
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const companies = await client.get<{
|
|
1018
|
+
data?: { companies?: { id: string }[] };
|
|
1019
|
+
}>('/rest/companies', {
|
|
1020
|
+
query: { filter: `accountOwnerId[eq]:${id}`, limit: 200 },
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
setMyCompanyIds((companies.data?.companies ?? []).map((c) => c.id));
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
// Is row-level security actually enforced? Row-level permissions are an
|
|
1027
|
+
// Organization-plan feature, so the same build lands on instances where
|
|
1028
|
+
// the predicates in our role are live and on ones where they are inert.
|
|
1029
|
+
// Rather than guess the plan, ask the server for records it should have
|
|
1030
|
+
// withheld: if a deal owned by somebody else comes back, enforcement is
|
|
1031
|
+
// off and the panel has to filter by itself.
|
|
1032
|
+
const probe = await client.get<{
|
|
1033
|
+
data?: { opportunities?: { ownerId?: string | null }[] };
|
|
1034
|
+
}>('/rest/opportunities', { query: { limit: 100 } });
|
|
1035
|
+
|
|
1036
|
+
const owned = (probe.data?.opportunities ?? []).filter(
|
|
1037
|
+
(row) => typeof row.ownerId === 'string' && row.ownerId !== '',
|
|
1038
|
+
);
|
|
1039
|
+
const foreign = owned.filter((row) => row.ownerId !== id);
|
|
1040
|
+
|
|
1041
|
+
setEnforcement(
|
|
1042
|
+
foreign.length > 0 ? 'client' : owned.length > 0 ? 'server' : 'unknown',
|
|
1043
|
+
);
|
|
1044
|
+
} catch {
|
|
1045
|
+
// Without a resolved member the panel falls back to showing everything.
|
|
1046
|
+
}
|
|
1047
|
+
}, [userId]);
|
|
1048
|
+
|
|
1049
|
+
// What a task or a note hangs on — a deal, a company, a contact. The to-many
|
|
1050
|
+
// link is not expanded on the record itself, so it is read separately.
|
|
1051
|
+
//
|
|
1052
|
+
// Only for the records actually on screen. Reading the two hundred newest
|
|
1053
|
+
// links blind cost 700 KB per call and kept every one of them alive in
|
|
1054
|
+
// memory; asking by id costs a few kilobytes and returns exactly what the
|
|
1055
|
+
// chips need.
|
|
1056
|
+
const loadLinks = useCallback(async (taskIds: string[], noteIds: string[]) => {
|
|
1057
|
+
try {
|
|
1058
|
+
const client = new RestApiClient();
|
|
1059
|
+
|
|
1060
|
+
const [taskResponse, noteResponse] = await Promise.all([
|
|
1061
|
+
taskIds.length === 0
|
|
1062
|
+
? Promise.resolve({ data: { taskTargets: [] } })
|
|
1063
|
+
: client.get<{ data?: { taskTargets?: TimelineRecord[] } }>(
|
|
1064
|
+
'/rest/taskTargets',
|
|
1065
|
+
{
|
|
1066
|
+
query: {
|
|
1067
|
+
filter: `taskId[in]:[${taskIds.join(',')}]`,
|
|
1068
|
+
limit: LINK_PAGE_SIZE,
|
|
1069
|
+
depth: 1,
|
|
1070
|
+
},
|
|
1071
|
+
},
|
|
1072
|
+
),
|
|
1073
|
+
noteIds.length === 0
|
|
1074
|
+
? Promise.resolve({ data: { noteTargets: [] } })
|
|
1075
|
+
: client.get<{ data?: { noteTargets?: TimelineRecord[] } }>(
|
|
1076
|
+
'/rest/noteTargets',
|
|
1077
|
+
{
|
|
1078
|
+
query: {
|
|
1079
|
+
filter: `noteId[in]:[${noteIds.join(',')}]`,
|
|
1080
|
+
limit: LINK_PAGE_SIZE,
|
|
1081
|
+
// The chip is named from the linked record, which only comes
|
|
1082
|
+
// with the relation expanded — same as taskTargets above.
|
|
1083
|
+
// Without it every note chip read "Opportunity", "Company".
|
|
1084
|
+
depth: 1,
|
|
1085
|
+
},
|
|
1086
|
+
},
|
|
1087
|
+
),
|
|
1088
|
+
]);
|
|
1089
|
+
|
|
1090
|
+
setTaskTargets(taskResponse.data?.taskTargets ?? []);
|
|
1091
|
+
setNoteTargets(noteResponse.data?.noteTargets ?? []);
|
|
1092
|
+
} catch {
|
|
1093
|
+
// Missing links only cost the chips, so a failure here stays silent.
|
|
1094
|
+
}
|
|
1095
|
+
}, []);
|
|
1096
|
+
|
|
1097
|
+
// Two lookups by id, the same shape as the links: whatever is on screen, and
|
|
1098
|
+
// nothing more. A closed deal is skipped — the point is what is live now.
|
|
1099
|
+
const loadContext = useCallback(async (companyIds: string[]) => {
|
|
1100
|
+
if (companyIds.length === 0) {
|
|
1101
|
+
setCompanyNames({});
|
|
1102
|
+
setCompanyDeals({});
|
|
1103
|
+
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
try {
|
|
1108
|
+
const client = new RestApiClient();
|
|
1109
|
+
const filter = `id[in]:[${companyIds.join(',')}]`;
|
|
1110
|
+
|
|
1111
|
+
const [companies, deals] = await Promise.all([
|
|
1112
|
+
client.get<{ data?: { companies?: TimelineRecord[] } }>(
|
|
1113
|
+
'/rest/companies',
|
|
1114
|
+
{ query: { filter, limit: LINK_PAGE_SIZE, depth: 0 } },
|
|
1115
|
+
),
|
|
1116
|
+
client.get<{ data?: { opportunities?: TimelineRecord[] } }>(
|
|
1117
|
+
'/rest/opportunities',
|
|
1118
|
+
{
|
|
1119
|
+
query: {
|
|
1120
|
+
filter: `companyId[in]:[${companyIds.join(',')}]`,
|
|
1121
|
+
limit: LINK_PAGE_SIZE,
|
|
1122
|
+
depth: 0,
|
|
1123
|
+
order_by: 'createdAt[DescNullsLast]',
|
|
1124
|
+
},
|
|
1125
|
+
},
|
|
1126
|
+
),
|
|
1127
|
+
]);
|
|
1128
|
+
|
|
1129
|
+
setCompanyNames(
|
|
1130
|
+
Object.fromEntries(
|
|
1131
|
+
(companies.data?.companies ?? []).map((company) => [
|
|
1132
|
+
String(company.id),
|
|
1133
|
+
typeof company.name === 'string' ? company.name : '',
|
|
1134
|
+
]),
|
|
1135
|
+
),
|
|
1136
|
+
);
|
|
1137
|
+
|
|
1138
|
+
const live: Record<string, { id: string; name: string }> = {};
|
|
1139
|
+
|
|
1140
|
+
for (const deal of deals.data?.opportunities ?? []) {
|
|
1141
|
+
const companyId = String(deal.companyId ?? '');
|
|
1142
|
+
const stage = String(deal.stage ?? '');
|
|
1143
|
+
|
|
1144
|
+
if (
|
|
1145
|
+
companyId === '' ||
|
|
1146
|
+
live[companyId] !== undefined ||
|
|
1147
|
+
stage === 'WON' ||
|
|
1148
|
+
stage === 'LOST'
|
|
1149
|
+
) {
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
live[companyId] = {
|
|
1154
|
+
id: String(deal.id),
|
|
1155
|
+
name: typeof deal.name === 'string' ? deal.name : '',
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
setCompanyDeals(live);
|
|
1160
|
+
} catch {
|
|
1161
|
+
// No context is a missing chip, never a broken card.
|
|
1162
|
+
}
|
|
1163
|
+
}, []);
|
|
1164
|
+
|
|
1165
|
+
const loadDocuments = useCallback(async () => {
|
|
1166
|
+
try {
|
|
1167
|
+
const response = await new RestApiClient().get<{
|
|
1168
|
+
data?: { attachments?: TimelineRecord[] };
|
|
1169
|
+
}>('/rest/attachments', {
|
|
1170
|
+
// At fifty the newest files were all on contacts and companies, and
|
|
1171
|
+
// documents filed under tasks or notes never showed up.
|
|
1172
|
+
query: {
|
|
1173
|
+
limit: FEED_LIMIT,
|
|
1174
|
+
depth: 1,
|
|
1175
|
+
order_by: 'createdAt[DescNullsLast]',
|
|
1176
|
+
},
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
setDocumentItems(
|
|
1180
|
+
(response.data?.attachments ?? []).map(toAttachmentEvent),
|
|
1181
|
+
);
|
|
1182
|
+
} catch {
|
|
1183
|
+
// The strip stays empty; the change feed is unaffected.
|
|
1184
|
+
}
|
|
1185
|
+
}, []);
|
|
1186
|
+
|
|
1187
|
+
const loadTasks = useCallback(async () => {
|
|
1188
|
+
try {
|
|
1189
|
+
const client = new RestApiClient();
|
|
1190
|
+
|
|
1191
|
+
const [response, editsResponse, assignedResponse] = await Promise.all([
|
|
1192
|
+
// depth 0: the assignee is needed as a name, and `assigneeId` plus the
|
|
1193
|
+
// member map already give that — the nested record was four fifths of
|
|
1194
|
+
// this response.
|
|
1195
|
+
client.get<{ data?: { tasks?: TimelineRecord[] } }>('/rest/tasks', {
|
|
1196
|
+
query: {
|
|
1197
|
+
limit: TAB_PAGE_SIZE,
|
|
1198
|
+
depth: 0,
|
|
1199
|
+
order_by: 'dueAt[AscNullsLast]',
|
|
1200
|
+
},
|
|
1201
|
+
}),
|
|
1202
|
+
// A task body can be amended exactly like a note body, which is the
|
|
1203
|
+
// only comment mechanism a task has — notes cannot be linked to tasks.
|
|
1204
|
+
client.get<{ data?: { timelineActivities?: TimelineRecord[] } }>(
|
|
1205
|
+
'/rest/timelineActivities',
|
|
1206
|
+
{
|
|
1207
|
+
query: {
|
|
1208
|
+
filter: 'name[eq]:task.updated',
|
|
1209
|
+
limit: TAB_PAGE_SIZE,
|
|
1210
|
+
depth: 0,
|
|
1211
|
+
order_by: 'happensAt[DescNullsLast]',
|
|
1212
|
+
},
|
|
1213
|
+
},
|
|
1214
|
+
),
|
|
1215
|
+
// The badge is a count, not a list: asking the server for one row and
|
|
1216
|
+
// reading `totalCount` costs under two kilobytes and is exact, where
|
|
1217
|
+
// counting the loaded page would top out at TAB_PAGE_SIZE.
|
|
1218
|
+
memberId === null
|
|
1219
|
+
? Promise.resolve(null)
|
|
1220
|
+
: client.get<Page<'tasks'>>('/rest/tasks', {
|
|
1221
|
+
query: {
|
|
1222
|
+
filter: `assigneeId[eq]:${memberId},status[neq]:DONE`,
|
|
1223
|
+
limit: 1,
|
|
1224
|
+
depth: 0,
|
|
1225
|
+
},
|
|
1226
|
+
}),
|
|
1227
|
+
]);
|
|
1228
|
+
|
|
1229
|
+
setTasks(response.data?.tasks ?? []);
|
|
1230
|
+
setTaskEdits((editsResponse.data?.timelineActivities ?? []).reverse());
|
|
1231
|
+
setAssignedOpenCount(assignedResponse?.totalCount ?? null);
|
|
1232
|
+
} catch (caught) {
|
|
1233
|
+
setError(caught instanceof Error ? caught.message : String(caught));
|
|
1234
|
+
}
|
|
1235
|
+
}, [memberId]);
|
|
1236
|
+
|
|
1237
|
+
// Buzz: notes as posts, and every later edit of a note body as a comment.
|
|
1238
|
+
// Twenty has no comment object — amending the note text is the mechanism —
|
|
1239
|
+
// so the thread is reconstructed from `note.updated` diffs.
|
|
1240
|
+
const loadBuzz = useCallback(async () => {
|
|
1241
|
+
try {
|
|
1242
|
+
const client = new RestApiClient();
|
|
1243
|
+
|
|
1244
|
+
const [notesResponse, editsResponse] = await Promise.all([
|
|
1245
|
+
// depth 0: a post needs its own columns — title, body, createdBy,
|
|
1246
|
+
// timestamps — and its links arrive separately as noteTargets.
|
|
1247
|
+
client.get<{ data?: { notes?: TimelineRecord[] } }>('/rest/notes', {
|
|
1248
|
+
query: {
|
|
1249
|
+
limit: TAB_PAGE_SIZE,
|
|
1250
|
+
depth: 0,
|
|
1251
|
+
order_by: 'createdAt[DescNullsLast]',
|
|
1252
|
+
},
|
|
1253
|
+
}),
|
|
1254
|
+
client.get<{ data?: { timelineActivities?: TimelineRecord[] } }>(
|
|
1255
|
+
'/rest/timelineActivities',
|
|
1256
|
+
{
|
|
1257
|
+
query: {
|
|
1258
|
+
filter: 'name[eq]:note.updated',
|
|
1259
|
+
limit: TAB_PAGE_SIZE,
|
|
1260
|
+
depth: 0,
|
|
1261
|
+
// Newest first, because that is the end the limit cuts. Asking
|
|
1262
|
+
// ascending meant the page held the oldest edits in the
|
|
1263
|
+
// workspace and the recent comments fell off it.
|
|
1264
|
+
order_by: 'happensAt[DescNullsLast]',
|
|
1265
|
+
},
|
|
1266
|
+
},
|
|
1267
|
+
),
|
|
1268
|
+
]);
|
|
1269
|
+
|
|
1270
|
+
setNotes(notesResponse.data?.notes ?? []);
|
|
1271
|
+
// Threads are read oldest-first downstream, so the page goes back the
|
|
1272
|
+
// way it came.
|
|
1273
|
+
setNoteEdits((editsResponse.data?.timelineActivities ?? []).reverse());
|
|
1274
|
+
} catch (caught) {
|
|
1275
|
+
setError(caught instanceof Error ? caught.message : String(caught));
|
|
1276
|
+
}
|
|
1277
|
+
}, []);
|
|
1278
|
+
|
|
1279
|
+
const loadReadState = useCallback(async () => {
|
|
1280
|
+
if (userId === null) {
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
try {
|
|
1285
|
+
const client = new RestApiClient();
|
|
1286
|
+
|
|
1287
|
+
const response = await client.get<{
|
|
1288
|
+
data?: { feedReadStates?: { id: string; lastSeenAt?: string }[] };
|
|
1289
|
+
}>('/rest/feedReadStates', {
|
|
1290
|
+
query: { filter: `userId[eq]:${userId}`, limit: 1 },
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
const state = response.data?.feedReadStates?.[0];
|
|
1294
|
+
|
|
1295
|
+
setReadStateId(state?.id ?? null);
|
|
1296
|
+
setLastSeenAt(state?.lastSeenAt ?? null);
|
|
1297
|
+
} catch {
|
|
1298
|
+
// A missing read state just means everything reads as unread.
|
|
1299
|
+
}
|
|
1300
|
+
}, [userId]);
|
|
1301
|
+
|
|
1302
|
+
useEffect(() => {
|
|
1303
|
+
void loadReadState();
|
|
1304
|
+
void loadObjectLabels();
|
|
1305
|
+
void loadViewer();
|
|
1306
|
+
}, [loadReadState, loadObjectLabels, loadViewer]);
|
|
1307
|
+
|
|
1308
|
+
// Files change rarely, so they ride the tab switch rather than the poll.
|
|
1309
|
+
useEffect(() => {
|
|
1310
|
+
void loadDocuments();
|
|
1311
|
+
}, [loadDocuments, view]);
|
|
1312
|
+
|
|
1313
|
+
// Only the tab in front of the user is polled. Refreshing all three every
|
|
1314
|
+
// 15 seconds meant 1.7 MB of JSON per cycle — most of it for screens nobody
|
|
1315
|
+
// was looking at, all of it parsed into objects the sandbox then collected.
|
|
1316
|
+
useEffect(() => {
|
|
1317
|
+
const fetchTab = () => {
|
|
1318
|
+
lastFullLoadAtRef.current = Date.now();
|
|
1319
|
+
|
|
1320
|
+
if (view === 'tasks') {
|
|
1321
|
+
void loadTasks();
|
|
1322
|
+
} else if (view === 'buzz') {
|
|
1323
|
+
void loadBuzz();
|
|
1324
|
+
} else {
|
|
1325
|
+
void loadFeed();
|
|
1326
|
+
}
|
|
1327
|
+
};
|
|
1328
|
+
|
|
1329
|
+
// Opening the panel or switching tabs is the user asking for the data —
|
|
1330
|
+
// that always pays in full. Only the timer haggles.
|
|
1331
|
+
const refresh = () => {
|
|
1332
|
+
void (async () => {
|
|
1333
|
+
const isStale =
|
|
1334
|
+
Date.now() - lastFullLoadAtRef.current >= FULL_REFRESH_EVERY_MS;
|
|
1335
|
+
|
|
1336
|
+
if ((await probeForNews()) || isStale) {
|
|
1337
|
+
fetchTab();
|
|
1338
|
+
}
|
|
1339
|
+
})();
|
|
1340
|
+
};
|
|
1341
|
+
|
|
1342
|
+
refreshRef.current = refresh;
|
|
1343
|
+
activeAtRef.current = Date.now();
|
|
1344
|
+
pausedRef.current = false;
|
|
1345
|
+
setIsPaused(false);
|
|
1346
|
+
fetchTab();
|
|
1347
|
+
// Seed the watermark, or the very first tick would see "no previous probe"
|
|
1348
|
+
// and pay for a page it already has.
|
|
1349
|
+
void probeForNews();
|
|
1350
|
+
|
|
1351
|
+
// The tick itself is kept, only the work is skipped: resuming then costs
|
|
1352
|
+
// nothing to set up, and the timer is free next to a request.
|
|
1353
|
+
const intervalId = setInterval(() => {
|
|
1354
|
+
if (Date.now() - activeAtRef.current >= IDLE_PAUSE_MS) {
|
|
1355
|
+
pausedRef.current = true;
|
|
1356
|
+
setIsPaused(true);
|
|
1357
|
+
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
refresh();
|
|
1362
|
+
}, POLL_INTERVAL_MS);
|
|
1363
|
+
|
|
1364
|
+
return () => clearInterval(intervalId);
|
|
1365
|
+
}, [view, loadFeed, loadTasks, loadBuzz, probeForNews]);
|
|
1366
|
+
|
|
1367
|
+
const markAllAsRead = async () => {
|
|
1368
|
+
if (userId === null) {
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const now = new Date().toISOString();
|
|
1373
|
+
const client = new RestApiClient();
|
|
1374
|
+
|
|
1375
|
+
setLastSeenAt(now);
|
|
1376
|
+
|
|
1377
|
+
if (readStateId !== null) {
|
|
1378
|
+
await client.patch(`/rest/feedReadStates/${readStateId}`, {
|
|
1379
|
+
lastSeenAt: now,
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
const created = await client.post<{
|
|
1386
|
+
data?: { createFeedReadState?: { id: string } };
|
|
1387
|
+
}>('/rest/feedReadStates', { userId, lastSeenAt: now });
|
|
1388
|
+
|
|
1389
|
+
setReadStateId(created.data?.createFeedReadState?.id ?? null);
|
|
1390
|
+
};
|
|
1391
|
+
|
|
1392
|
+
// The SDK cannot scroll to a position inside a record — no anchor, offset or
|
|
1393
|
+
// block id anywhere in the front-component API. EditRichText is the closest
|
|
1394
|
+
// thing: it opens the note's body instead of the record overview, and since
|
|
1395
|
+
// comments are appended the newest text sits at the end of it.
|
|
1396
|
+
const openRecord = (target: ResolvedTarget) => {
|
|
1397
|
+
void openSidePanelPage({
|
|
1398
|
+
page: SidePanelPages.ViewRecord,
|
|
1399
|
+
recordId: target.recordId,
|
|
1400
|
+
objectNameSingular: target.objectNameSingular,
|
|
1401
|
+
});
|
|
1402
|
+
};
|
|
1403
|
+
|
|
1404
|
+
const isUnread = (item: TimelineRecord) =>
|
|
1405
|
+
lastSeenAt === null ||
|
|
1406
|
+
new Date(String(item.happensAt)).getTime() > new Date(lastSeenAt).getTime();
|
|
1407
|
+
|
|
1408
|
+
// Relevance, not security: this decides what is worth showing, it does not
|
|
1409
|
+
// stop the data reaching the browser. The app role is what actually governs
|
|
1410
|
+
// access, and it is workspace-wide.
|
|
1411
|
+
const isMine = (item: TimelineRecord) => {
|
|
1412
|
+
if (memberId === null) {
|
|
1413
|
+
return true;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
// Anything you did is yours. Events made through the API carry no
|
|
1417
|
+
// workspaceMember, only the ACTOR in `createdBy` — reading just the first
|
|
1418
|
+
// of the two dropped every note and comment written over the API.
|
|
1419
|
+
if (
|
|
1420
|
+
item.workspaceMemberId === memberId ||
|
|
1421
|
+
readMemberId(item.createdBy) === memberId
|
|
1422
|
+
) {
|
|
1423
|
+
return true;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// A note is addressed to the team by definition: it has no owner, and the
|
|
1427
|
+
// ones that matter most — the standalone ones — are filed under nothing at
|
|
1428
|
+
// all. Judging them by ownership kept every comment out of the feed, so
|
|
1429
|
+
// they are relevant to everyone, like a message on a board.
|
|
1430
|
+
const eventSubject = String(item.name ?? '').split('.')[0];
|
|
1431
|
+
|
|
1432
|
+
if (eventSubject === 'note' || eventSubject === 'linked-note') {
|
|
1433
|
+
return true;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// A task belongs to the two people it names: whoever set it and whoever has
|
|
1437
|
+
// to do it. Being filed under your deal does not make somebody else's task
|
|
1438
|
+
// yours — that is a question of what the deal shows, not of what your bell
|
|
1439
|
+
// should ring for.
|
|
1440
|
+
const task = item.targetTask as
|
|
1441
|
+
| { assigneeId?: unknown; createdBy?: unknown }
|
|
1442
|
+
| null
|
|
1443
|
+
| undefined;
|
|
1444
|
+
|
|
1445
|
+
if (task !== null && task !== undefined) {
|
|
1446
|
+
return (
|
|
1447
|
+
task.assigneeId === memberId ||
|
|
1448
|
+
readMemberId(task.createdBy) === memberId
|
|
1449
|
+
);
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
const relationKey = Object.keys(item).find(
|
|
1453
|
+
(key) =>
|
|
1454
|
+
key.startsWith('target') && !key.endsWith('Id') && item[key] !== null,
|
|
1455
|
+
);
|
|
1456
|
+
const record = relationKey === undefined ? null : item[relationKey];
|
|
1457
|
+
|
|
1458
|
+
if (record === null || typeof record !== 'object') {
|
|
1459
|
+
return false;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
const { ownerId, accountOwnerId, assigneeId, companyId, id } =
|
|
1463
|
+
record as Record<string, unknown>;
|
|
1464
|
+
|
|
1465
|
+
if (
|
|
1466
|
+
ownerId === memberId ||
|
|
1467
|
+
accountOwnerId === memberId ||
|
|
1468
|
+
assigneeId === memberId
|
|
1469
|
+
) {
|
|
1470
|
+
return true;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
if (typeof companyId === 'string' && myCompanyIds.includes(companyId)) {
|
|
1474
|
+
return true;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
return typeof id === 'string' && myCompanyIds.includes(id);
|
|
1478
|
+
};
|
|
1479
|
+
|
|
1480
|
+
// The newest page plus everything paged in behind it. Keyed by id: a poll can
|
|
1481
|
+
// land mid-scroll and serve the same event twice. Memoised because hovering a
|
|
1482
|
+
// card is a state change, and re-sorting on every mouse move is work nobody
|
|
1483
|
+
// asked for.
|
|
1484
|
+
const allItems = useMemo(
|
|
1485
|
+
() =>
|
|
1486
|
+
[
|
|
1487
|
+
...new Map(
|
|
1488
|
+
[...items, ...olderItems, ...documentItems].map((item) => [
|
|
1489
|
+
String(item.id),
|
|
1490
|
+
item,
|
|
1491
|
+
]),
|
|
1492
|
+
).values(),
|
|
1493
|
+
].sort(byHappensAtDesc),
|
|
1494
|
+
[items, olderItems, documentItems],
|
|
1495
|
+
);
|
|
1496
|
+
|
|
1497
|
+
// On an enforcing instance the server already scoped the response, so
|
|
1498
|
+
// filtering again would only hide records the viewer is entitled to.
|
|
1499
|
+
const visibleItems = useMemo(
|
|
1500
|
+
() =>
|
|
1501
|
+
// The panel is personal, full stop. A toggle to "everything" made the
|
|
1502
|
+
// counter meaningless — a number under a feed that showed a fraction of
|
|
1503
|
+
// it — and nobody reads a workspace-wide firehose anyway. The variant
|
|
1504
|
+
// with the switch is kept on the backup/scope-toggle branch.
|
|
1505
|
+
enforcement === 'server' ? allItems : allItems.filter(isMine),
|
|
1506
|
+
// `isMine` is rebuilt every render; what it actually reads is listed here.
|
|
1507
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1508
|
+
[
|
|
1509
|
+
allItems,
|
|
1510
|
+
enforcement,
|
|
1511
|
+
memberId,
|
|
1512
|
+
myCompanyIds,
|
|
1513
|
+
],
|
|
1514
|
+
);
|
|
1515
|
+
|
|
1516
|
+
// Tasks view: what is hanging, not what changed. Done tasks are dropped —
|
|
1517
|
+
// a finished task is never something the user still owes.
|
|
1518
|
+
const openTasks = tasks.filter((task) => {
|
|
1519
|
+
if (task.status === 'DONE') {
|
|
1520
|
+
return false;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
if (enforcement === 'server' || memberId === null) {
|
|
1524
|
+
return true;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
return (
|
|
1528
|
+
task.assigneeId === memberId ||
|
|
1529
|
+
readMemberId(task.createdBy) === memberId
|
|
1530
|
+
);
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1533
|
+
const overdueTasks = openTasks
|
|
1534
|
+
.filter((task) => describeDue(task.dueAt).overdueDays > 0)
|
|
1535
|
+
.sort((a, b) => describeDue(a.dueAt).sortKey - describeDue(b.dueAt).sortKey);
|
|
1536
|
+
|
|
1537
|
+
const upcomingTasks = openTasks
|
|
1538
|
+
.filter((task) => describeDue(task.dueAt).overdueDays === 0)
|
|
1539
|
+
.sort((a, b) => describeDue(a.dueAt).sortKey - describeDue(b.dueAt).sortKey);
|
|
1540
|
+
|
|
1541
|
+
// A post keeps the text the note started with; everything appended later
|
|
1542
|
+
// becomes a comment under it.
|
|
1543
|
+
// A note that hangs on a deal belongs to that deal's story, not to the
|
|
1544
|
+
// team wall — Buzz keeps everything else.
|
|
1545
|
+
const noteIdsOnDeals = new Set(
|
|
1546
|
+
noteTargets
|
|
1547
|
+
.filter((link) => typeof link.targetOpportunityId === 'string')
|
|
1548
|
+
.map((link) => String(link.noteId)),
|
|
1549
|
+
);
|
|
1550
|
+
|
|
1551
|
+
const buzzPosts = notes
|
|
1552
|
+
.filter((note) => !noteIdsOnDeals.has(String(note.id)))
|
|
1553
|
+
.map((note) => {
|
|
1554
|
+
const edits = noteEdits.filter(
|
|
1555
|
+
(edit) => edit.targetNoteId === note.id,
|
|
1556
|
+
);
|
|
1557
|
+
const comments = edits
|
|
1558
|
+
.map((edit) => ({ edit, parsed: readNoteEdit(edit) }))
|
|
1559
|
+
.filter(({ parsed }) => parsed.text !== '');
|
|
1560
|
+
|
|
1561
|
+
const originalBody =
|
|
1562
|
+
comments.length > 0
|
|
1563
|
+
? comments[0].parsed.before
|
|
1564
|
+
: readMarkdown(note.bodyV2);
|
|
1565
|
+
|
|
1566
|
+
// A post is dated by the last thing that happened to it, not by the day
|
|
1567
|
+
// it was written: a note from Monday with an answer an hour ago belongs
|
|
1568
|
+
// at the top, showing "1h". Comments arrive oldest-first, so the last
|
|
1569
|
+
// one is the newest.
|
|
1570
|
+
const lastComment = comments[comments.length - 1]?.edit.happensAt;
|
|
1571
|
+
const touchedAt = [note.createdAt, note.updatedAt, lastComment]
|
|
1572
|
+
.filter((value): value is string => typeof value === 'string')
|
|
1573
|
+
.reduce((latest, value) =>
|
|
1574
|
+
new Date(value).getTime() > new Date(latest).getTime() ? value : latest,
|
|
1575
|
+
);
|
|
1576
|
+
|
|
1577
|
+
return { note, comments, originalBody, touchedAt };
|
|
1578
|
+
})
|
|
1579
|
+
.filter(
|
|
1580
|
+
(post) =>
|
|
1581
|
+
post.comments.length > 0 ||
|
|
1582
|
+
post.originalBody !== '' ||
|
|
1583
|
+
typeof post.note.title === 'string',
|
|
1584
|
+
)
|
|
1585
|
+
.sort(
|
|
1586
|
+
(a, b) =>
|
|
1587
|
+
new Date(b.touchedAt).getTime() - new Date(a.touchedAt).getTime(),
|
|
1588
|
+
);
|
|
1589
|
+
|
|
1590
|
+
// Several events on the same record collapse into one row: the newest is
|
|
1591
|
+
// shown, the rest hide behind a counter.
|
|
1592
|
+
const describeBulk = (item: TimelineRecord) => {
|
|
1593
|
+
const [subject = '', action = ''] = String(item.name ?? '').split('.');
|
|
1594
|
+
const target = resolveTarget(item);
|
|
1595
|
+
|
|
1596
|
+
return {
|
|
1597
|
+
// The event subject has to stay in the key. A file attached to a deal and
|
|
1598
|
+
// the deal itself both resolve to `opportunity`, so without it a batch of
|
|
1599
|
+
// uploads merged into the deals row and was counted as deals.
|
|
1600
|
+
subject,
|
|
1601
|
+
objectNameSingular: target?.objectNameSingular ?? subject,
|
|
1602
|
+
action,
|
|
1603
|
+
author: String(
|
|
1604
|
+
item.workspaceMemberId ?? readMemberId(item.createdBy) ?? '',
|
|
1605
|
+
),
|
|
1606
|
+
bucket: Math.floor(
|
|
1607
|
+
new Date(String(item.happensAt)).getTime() / BULK_WINDOW_MS,
|
|
1608
|
+
),
|
|
1609
|
+
};
|
|
1610
|
+
};
|
|
1611
|
+
|
|
1612
|
+
type FeedEntry =
|
|
1613
|
+
| { kind: 'group'; key: string; items: TimelineRecord[] }
|
|
1614
|
+
| { kind: 'bulk'; key: string; items: TimelineRecord[] };
|
|
1615
|
+
|
|
1616
|
+
// Grouping walks the whole feed twice and sorts the result. It depends on
|
|
1617
|
+
// the events alone, so it is rebuilt when they change — not when a card
|
|
1618
|
+
// lights up under the cursor.
|
|
1619
|
+
// Every change event fetched, before the personal filter. This is what the
|
|
1620
|
+
// counter compares against the server's total and what paging walks back
|
|
1621
|
+
// from — the filtered list would page into a different place.
|
|
1622
|
+
const loadedEvents = useMemo(
|
|
1623
|
+
() => allItems.filter((item) => item.name !== ATTACHMENT_EVENT),
|
|
1624
|
+
[allItems],
|
|
1625
|
+
);
|
|
1626
|
+
|
|
1627
|
+
// Which records the chips will actually be asked about. The signature keeps
|
|
1628
|
+
// the effect from refiring when the same set comes back in another order.
|
|
1629
|
+
const linkedIds = useMemo(() => {
|
|
1630
|
+
const taskIds = new Set<string>();
|
|
1631
|
+
const noteIds = new Set<string>();
|
|
1632
|
+
|
|
1633
|
+
for (const item of allItems) {
|
|
1634
|
+
if (typeof item.targetTaskId === 'string' && item.targetTaskId !== '') {
|
|
1635
|
+
taskIds.add(item.targetTaskId);
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
if (typeof item.targetNoteId === 'string' && item.targetNoteId !== '') {
|
|
1639
|
+
noteIds.add(item.targetNoteId);
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
for (const task of tasks) {
|
|
1644
|
+
taskIds.add(String(task.id));
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
for (const note of notes) {
|
|
1648
|
+
noteIds.add(String(note.id));
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
return { taskIds: [...taskIds].sort(), noteIds: [...noteIds].sort() };
|
|
1652
|
+
}, [allItems, tasks, notes]);
|
|
1653
|
+
|
|
1654
|
+
const linkSignature = `${linkedIds.taskIds.join(',')}|${linkedIds.noteIds.join(',')}`;
|
|
1655
|
+
|
|
1656
|
+
useEffect(() => {
|
|
1657
|
+
void loadLinks(linkedIds.taskIds, linkedIds.noteIds);
|
|
1658
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1659
|
+
}, [linkSignature]);
|
|
1660
|
+
|
|
1661
|
+
// Companies worth resolving: the ones a card points at directly, and the ones
|
|
1662
|
+
// behind the contacts it points at.
|
|
1663
|
+
const contextCompanyIds = useMemo(() => {
|
|
1664
|
+
const ids = new Set<string>();
|
|
1665
|
+
|
|
1666
|
+
for (const item of allItems) {
|
|
1667
|
+
const direct = item.targetCompanyId;
|
|
1668
|
+
const viaPerson = (item.targetPerson as { companyId?: unknown } | null)
|
|
1669
|
+
?.companyId;
|
|
1670
|
+
|
|
1671
|
+
if (typeof direct === 'string' && direct !== '') {
|
|
1672
|
+
ids.add(direct);
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
if (typeof viaPerson === 'string' && viaPerson !== '') {
|
|
1676
|
+
ids.add(viaPerson);
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
return [...ids].sort();
|
|
1681
|
+
}, [allItems]);
|
|
1682
|
+
|
|
1683
|
+
const contextSignature = contextCompanyIds.join(',');
|
|
1684
|
+
|
|
1685
|
+
useEffect(() => {
|
|
1686
|
+
void loadContext(contextCompanyIds);
|
|
1687
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1688
|
+
}, [contextSignature]);
|
|
1689
|
+
|
|
1690
|
+
// Files are grouped with everything else, so a record's card carries both
|
|
1691
|
+
// what changed on it and what was filed under it. A record with only files
|
|
1692
|
+
// still forms a card of its own.
|
|
1693
|
+
// The cap is enforced here as well as at fetch time: whatever arrives, the
|
|
1694
|
+
// feed never grows past FEED_LIMIT rows of change history.
|
|
1695
|
+
const changes = useMemo(() => {
|
|
1696
|
+
// The cap counts change events only; files ride along with the record they
|
|
1697
|
+
// belong to. Cutting them by the event window was tried and thrown away —
|
|
1698
|
+
// files are older than the latest edits, so it left one document out of a
|
|
1699
|
+
// hundred on screen.
|
|
1700
|
+
const events = visibleItems
|
|
1701
|
+
.filter((item) => item.name !== ATTACHMENT_EVENT)
|
|
1702
|
+
.slice(0, FEED_LIMIT);
|
|
1703
|
+
const files = visibleItems.filter((item) => item.name === ATTACHMENT_EVENT);
|
|
1704
|
+
|
|
1705
|
+
return [...events, ...files].sort(byHappensAtDesc);
|
|
1706
|
+
}, [visibleItems]);
|
|
1707
|
+
|
|
1708
|
+
// What the feed actually shows: after the personal filter and after the cap.
|
|
1709
|
+
const visibleEventCount = useMemo(
|
|
1710
|
+
() => changes.filter((item) => item.name !== ATTACHMENT_EVENT).length,
|
|
1711
|
+
[changes],
|
|
1712
|
+
);
|
|
1713
|
+
|
|
1714
|
+
const entries = useMemo<FeedEntry[]>(() => {
|
|
1715
|
+
const bulkCounts = new Map<string, number>();
|
|
1716
|
+
|
|
1717
|
+
for (const item of changes) {
|
|
1718
|
+
const { subject, objectNameSingular, action, author, bucket } =
|
|
1719
|
+
describeBulk(item);
|
|
1720
|
+
const key = `${subject}|${objectNameSingular}|${action}|${author}|${bucket}`;
|
|
1721
|
+
|
|
1722
|
+
bulkCounts.set(key, (bulkCounts.get(key) ?? 0) + 1);
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
const bulkEntries: { key: string; items: TimelineRecord[] }[] = [];
|
|
1726
|
+
const bulkIndex = new Map<string, { key: string; items: TimelineRecord[] }>();
|
|
1727
|
+
const groups: FeedGroup[] = [];
|
|
1728
|
+
const groupIndex = new Map<string, FeedGroup>();
|
|
1729
|
+
|
|
1730
|
+
for (const item of changes) {
|
|
1731
|
+
const { subject, objectNameSingular, action, author, bucket } =
|
|
1732
|
+
describeBulk(item);
|
|
1733
|
+
const bulkKey = `${subject}|${objectNameSingular}|${action}|${author}|${bucket}`;
|
|
1734
|
+
|
|
1735
|
+
if ((bulkCounts.get(bulkKey) ?? 0) >= BULK_THRESHOLD) {
|
|
1736
|
+
const existingBulk = bulkIndex.get(bulkKey);
|
|
1737
|
+
|
|
1738
|
+
if (existingBulk === undefined) {
|
|
1739
|
+
const entry = { key: bulkKey, items: [item] };
|
|
1740
|
+
|
|
1741
|
+
bulkIndex.set(bulkKey, entry);
|
|
1742
|
+
bulkEntries.push(entry);
|
|
1743
|
+
} else {
|
|
1744
|
+
existingBulk.items.push(item);
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
continue;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
const target = resolveTarget(item);
|
|
1751
|
+
const key =
|
|
1752
|
+
target !== null
|
|
1753
|
+
? `${target.objectNameSingular}:${target.recordId}`
|
|
1754
|
+
: `event:${String(item.id)}`;
|
|
1755
|
+
const existing = groupIndex.get(key);
|
|
1756
|
+
|
|
1757
|
+
if (existing === undefined) {
|
|
1758
|
+
const group = { key, items: [item] };
|
|
1759
|
+
|
|
1760
|
+
groupIndex.set(key, group);
|
|
1761
|
+
groups.push(group);
|
|
1762
|
+
} else {
|
|
1763
|
+
existing.items.push(item);
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
const newestOf = (items: TimelineRecord[]) =>
|
|
1768
|
+
Math.max(...items.map((i) => new Date(String(i.happensAt)).getTime()));
|
|
1769
|
+
|
|
1770
|
+
return [
|
|
1771
|
+
...groups.map((g) => ({ kind: 'group' as const, ...g })),
|
|
1772
|
+
...bulkEntries.map((b) => ({ kind: 'bulk' as const, ...b })),
|
|
1773
|
+
].sort((a, b) => newestOf(b.items) - newestOf(a.items));
|
|
1774
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1775
|
+
}, [changes]);
|
|
1776
|
+
|
|
1777
|
+
const unreadCount = visibleItems.filter(isUnread).length;
|
|
1778
|
+
|
|
1779
|
+
const unreadEntries = entries.filter((e) => e.items.some(isUnread));
|
|
1780
|
+
const readEntries = entries.filter((e) => !e.items.some(isUnread));
|
|
1781
|
+
|
|
1782
|
+
// Standard objects read from our own catalogue so the wording matches the
|
|
1783
|
+
// rest of the panel; everything else — custom objects included — takes the
|
|
1784
|
+
// label the workspace gave it.
|
|
1785
|
+
const labelForObject = (objectNameSingular: string) => {
|
|
1786
|
+
const known = OBJECT_LABELS[objectNameSingular as keyof typeof OBJECT_LABELS];
|
|
1787
|
+
|
|
1788
|
+
return known !== undefined
|
|
1789
|
+
? t(known)
|
|
1790
|
+
: (objectLabels[objectNameSingular] ?? objectNameSingular ?? t('Record'));
|
|
1791
|
+
};
|
|
1792
|
+
|
|
1793
|
+
const describe = (item: TimelineRecord) => {
|
|
1794
|
+
const eventName = typeof item.name === 'string' ? item.name : '';
|
|
1795
|
+
const [eventSubject = '', action = ''] = eventName.split('.');
|
|
1796
|
+
const target = resolveTarget(item);
|
|
1797
|
+
// Events made through the API carry no workspaceMember — the ACTOR in
|
|
1798
|
+
// `createdBy` is the only attribution there is.
|
|
1799
|
+
const author =
|
|
1800
|
+
readDisplayName(item.workspaceMember) || readDisplayName(item.createdBy);
|
|
1801
|
+
|
|
1802
|
+
// `linked-note.created` and friends describe something attached to the
|
|
1803
|
+
// target record rather than a change of the record itself.
|
|
1804
|
+
const linkedKind = eventSubject.startsWith('linked-')
|
|
1805
|
+
? eventSubject.slice('linked-'.length)
|
|
1806
|
+
: null;
|
|
1807
|
+
const linkedName =
|
|
1808
|
+
typeof item.linkedRecordCachedName === 'string'
|
|
1809
|
+
? item.linkedRecordCachedName
|
|
1810
|
+
: '';
|
|
1811
|
+
|
|
1812
|
+
const objectNameSingular = target?.objectNameSingular ?? eventSubject;
|
|
1813
|
+
|
|
1814
|
+
return {
|
|
1815
|
+
target,
|
|
1816
|
+
author,
|
|
1817
|
+
linkedKind,
|
|
1818
|
+
linkedName,
|
|
1819
|
+
objectNameSingular,
|
|
1820
|
+
changes: extractDiff(item.properties),
|
|
1821
|
+
objectLabel: labelForObject(objectNameSingular),
|
|
1822
|
+
actionLabel: (() => {
|
|
1823
|
+
const verb = ACTION_LABELS[action as keyof typeof ACTION_LABELS];
|
|
1824
|
+
|
|
1825
|
+
if (linkedKind === null) {
|
|
1826
|
+
return verb !== undefined ? t(verb) : action;
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
const kind = LINKED_KIND_LABELS[linkedKind as keyof typeof LINKED_KIND_LABELS];
|
|
1830
|
+
const linkVerb =
|
|
1831
|
+
LINKED_ACTION_LABELS[action as keyof typeof LINKED_ACTION_LABELS];
|
|
1832
|
+
|
|
1833
|
+
return `${kind !== undefined ? t(kind) : linkedKind} ${
|
|
1834
|
+
linkVerb !== undefined ? t(linkVerb) : action
|
|
1835
|
+
}`;
|
|
1836
|
+
})(),
|
|
1837
|
+
};
|
|
1838
|
+
};
|
|
1839
|
+
|
|
1840
|
+
// SELECT fields carry their own labels and theme colours in metadata, so a
|
|
1841
|
+
// stage change renders as the same coloured labels the record page shows.
|
|
1842
|
+
const renderFieldValue = (
|
|
1843
|
+
objectNameSingular: string,
|
|
1844
|
+
field: string,
|
|
1845
|
+
raw: unknown,
|
|
1846
|
+
fallback: string,
|
|
1847
|
+
) => {
|
|
1848
|
+
const option = fieldMeta[`${objectNameSingular}.${field}`]?.options?.[
|
|
1849
|
+
String(raw)
|
|
1850
|
+
];
|
|
1851
|
+
|
|
1852
|
+
if (option === undefined) {
|
|
1853
|
+
return fallback;
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
const color = THEME_COLORS[option.color] ?? palette.textMid;
|
|
1857
|
+
|
|
1858
|
+
return (
|
|
1859
|
+
<span
|
|
1860
|
+
style={{
|
|
1861
|
+
padding: '1px 7px',
|
|
1862
|
+
borderRadius: '4px',
|
|
1863
|
+
background: `${color}26`,
|
|
1864
|
+
color,
|
|
1865
|
+
fontWeight: 500,
|
|
1866
|
+
whiteSpace: 'nowrap',
|
|
1867
|
+
}}
|
|
1868
|
+
>
|
|
1869
|
+
{option.label}
|
|
1870
|
+
</span>
|
|
1871
|
+
);
|
|
1872
|
+
};
|
|
1873
|
+
|
|
1874
|
+
const renderFileMark = (fileName: string) => {
|
|
1875
|
+
const Icon = getFileIcon(fileName);
|
|
1876
|
+
|
|
1877
|
+
return <Icon size={16} stroke={1.7} color={palette.textLight} />;
|
|
1878
|
+
};
|
|
1879
|
+
|
|
1880
|
+
const renderPayload = (
|
|
1881
|
+
item: TimelineRecord,
|
|
1882
|
+
described: ReturnType<typeof describe>,
|
|
1883
|
+
accentColor: string,
|
|
1884
|
+
) => (
|
|
1885
|
+
<>
|
|
1886
|
+
{described.linkedKind !== null && described.linkedName !== '' && (
|
|
1887
|
+
<div
|
|
1888
|
+
style={{
|
|
1889
|
+
marginTop: '5px',
|
|
1890
|
+
paddingLeft: '8px',
|
|
1891
|
+
borderLeft: `2px solid ${accentColor}`,
|
|
1892
|
+
fontSize: '0.92rem',
|
|
1893
|
+
fontWeight: 400,
|
|
1894
|
+
color: palette.text,
|
|
1895
|
+
lineHeight: '1.5',
|
|
1896
|
+
overflowWrap: 'anywhere',
|
|
1897
|
+
}}
|
|
1898
|
+
>
|
|
1899
|
+
{described.linkedName}
|
|
1900
|
+
</div>
|
|
1901
|
+
)}
|
|
1902
|
+
|
|
1903
|
+
{described.changes.map((change) => {
|
|
1904
|
+
// What somebody wrote is speech, not a field value. Buzz and Tasks
|
|
1905
|
+
// render it as a comment; the feed used to print the same text as
|
|
1906
|
+
// `Body: appended: …`, which read like a database column. Same content,
|
|
1907
|
+
// same quoted shape — the author is already on the line above.
|
|
1908
|
+
const written = isRichTextValue(change.beforeRaw) || isRichTextValue(change.afterRaw)
|
|
1909
|
+
? readNoteEdit({
|
|
1910
|
+
properties: { diff: { bodyV2: { before: change.beforeRaw, after: change.afterRaw } } },
|
|
1911
|
+
} as TimelineRecord)
|
|
1912
|
+
: null;
|
|
1913
|
+
|
|
1914
|
+
if (written !== null && written.text !== '') {
|
|
1915
|
+
return (
|
|
1916
|
+
<div
|
|
1917
|
+
key={change.field}
|
|
1918
|
+
style={{
|
|
1919
|
+
marginTop: '5px',
|
|
1920
|
+
paddingLeft: '8px',
|
|
1921
|
+
borderLeft: `2px solid ${palette.border}`,
|
|
1922
|
+
fontSize: '0.92rem',
|
|
1923
|
+
fontWeight: 400,
|
|
1924
|
+
color: palette.text,
|
|
1925
|
+
lineHeight: '1.55',
|
|
1926
|
+
whiteSpace: 'pre-wrap',
|
|
1927
|
+
overflowWrap: 'anywhere',
|
|
1928
|
+
}}
|
|
1929
|
+
>
|
|
1930
|
+
{written.isRewrite && (
|
|
1931
|
+
<span style={{ color: palette.textLight }}>
|
|
1932
|
+
{t('rewrote the text')}{' '}
|
|
1933
|
+
</span>
|
|
1934
|
+
)}
|
|
1935
|
+
{written.text}
|
|
1936
|
+
</div>
|
|
1937
|
+
);
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
return (
|
|
1941
|
+
<div
|
|
1942
|
+
key={change.field}
|
|
1943
|
+
style={{
|
|
1944
|
+
marginTop: '4px',
|
|
1945
|
+
fontSize: '0.92rem',
|
|
1946
|
+
fontWeight: 400,
|
|
1947
|
+
color: palette.text,
|
|
1948
|
+
lineHeight: '1.6',
|
|
1949
|
+
overflowWrap: 'anywhere',
|
|
1950
|
+
}}
|
|
1951
|
+
>
|
|
1952
|
+
<span style={{ color: palette.textMid }}>
|
|
1953
|
+
{fieldMeta[`${described.objectNameSingular}.${change.field}`]
|
|
1954
|
+
?.label ?? change.field}
|
|
1955
|
+
:{' '}
|
|
1956
|
+
</span>
|
|
1957
|
+
{isRichTextValue(change.beforeRaw) || isRichTextValue(change.afterRaw) ? (
|
|
1958
|
+
describeRichTextChange(change.beforeRaw, change.afterRaw)
|
|
1959
|
+
) : (
|
|
1960
|
+
<>
|
|
1961
|
+
{renderFieldValue(
|
|
1962
|
+
described.objectNameSingular,
|
|
1963
|
+
change.field,
|
|
1964
|
+
change.beforeRaw,
|
|
1965
|
+
formatValue(change.beforeRaw, memberNames),
|
|
1966
|
+
)}
|
|
1967
|
+
<span style={{ color: palette.textLight }}> → </span>
|
|
1968
|
+
{renderFieldValue(
|
|
1969
|
+
described.objectNameSingular,
|
|
1970
|
+
change.field,
|
|
1971
|
+
change.afterRaw,
|
|
1972
|
+
formatValue(change.afterRaw, memberNames),
|
|
1973
|
+
)}
|
|
1974
|
+
</>
|
|
1975
|
+
)}
|
|
1976
|
+
</div>
|
|
1977
|
+
);
|
|
1978
|
+
})}
|
|
1979
|
+
</>
|
|
1980
|
+
);
|
|
1981
|
+
|
|
1982
|
+
// Tasks and notes hang off the record they were filed under, and that
|
|
1983
|
+
// record — usually a deal — is what the reader is actually following.
|
|
1984
|
+
// A deal matters more than the contact it goes through, so it leads.
|
|
1985
|
+
const LINK_RANK: Record<string, number> = { opportunity: 0, company: 1 };
|
|
1986
|
+
|
|
1987
|
+
// Context for a record that carries no link rows of its own: a contact lends
|
|
1988
|
+
// its company, a company lends the deal that is still open on it.
|
|
1989
|
+
const contextFor = (item: TimelineRecord): ResolvedTarget[] => {
|
|
1990
|
+
const viaPerson = (item.targetPerson as { companyId?: unknown } | null)
|
|
1991
|
+
?.companyId;
|
|
1992
|
+
const companyId =
|
|
1993
|
+
typeof item.targetCompanyId === 'string' && item.targetCompanyId !== ''
|
|
1994
|
+
? item.targetCompanyId
|
|
1995
|
+
: typeof viaPerson === 'string'
|
|
1996
|
+
? viaPerson
|
|
1997
|
+
: '';
|
|
1998
|
+
|
|
1999
|
+
if (companyId === '') {
|
|
2000
|
+
return [];
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
const chips: ResolvedTarget[] = [];
|
|
2004
|
+
const deal = companyDeals[companyId];
|
|
2005
|
+
|
|
2006
|
+
if (deal !== undefined) {
|
|
2007
|
+
chips.push({
|
|
2008
|
+
objectNameSingular: 'opportunity',
|
|
2009
|
+
recordId: deal.id,
|
|
2010
|
+
label: deal.name,
|
|
2011
|
+
});
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
// The company itself is redundant on a card that already leads with it.
|
|
2015
|
+
if (item.targetCompanyId !== companyId && companyNames[companyId] !== undefined) {
|
|
2016
|
+
chips.push({
|
|
2017
|
+
objectNameSingular: 'company',
|
|
2018
|
+
recordId: companyId,
|
|
2019
|
+
label: companyNames[companyId],
|
|
2020
|
+
});
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
return chips;
|
|
2024
|
+
};
|
|
2025
|
+
|
|
2026
|
+
const relatedRecords = (objectNameSingular: string, recordId: string) => {
|
|
2027
|
+
const rows =
|
|
2028
|
+
objectNameSingular === 'task'
|
|
2029
|
+
? taskTargets.filter((link) => String(link.taskId) === recordId)
|
|
2030
|
+
: objectNameSingular === 'note'
|
|
2031
|
+
? noteTargets.filter((link) => String(link.noteId) === recordId)
|
|
2032
|
+
: [];
|
|
2033
|
+
|
|
2034
|
+
return rows
|
|
2035
|
+
.map((link) => resolveTarget(link))
|
|
2036
|
+
.filter((link): link is ResolvedTarget => link !== null)
|
|
2037
|
+
.sort(
|
|
2038
|
+
(a, b) =>
|
|
2039
|
+
(LINK_RANK[a.objectNameSingular] ?? 2) -
|
|
2040
|
+
(LINK_RANK[b.objectNameSingular] ?? 2),
|
|
2041
|
+
)
|
|
2042
|
+
.slice(0, 2);
|
|
2043
|
+
};
|
|
2044
|
+
|
|
2045
|
+
// The newest thing that happened to a record — what dates its card and what
|
|
2046
|
+
// orders the feed. `newestOf` returns a number for sorting; this one returns
|
|
2047
|
+
// the timestamp itself, for display.
|
|
2048
|
+
const newestHappensAt = (items: TimelineRecord[]) =>
|
|
2049
|
+
items.reduce(
|
|
2050
|
+
(latest, item) =>
|
|
2051
|
+
new Date(String(item.happensAt)).getTime() >
|
|
2052
|
+
new Date(String(latest)).getTime()
|
|
2053
|
+
? String(item.happensAt)
|
|
2054
|
+
: latest,
|
|
2055
|
+
String(items[0]?.happensAt ?? ''),
|
|
2056
|
+
);
|
|
2057
|
+
|
|
2058
|
+
const renderHead = (
|
|
2059
|
+
item: TimelineRecord,
|
|
2060
|
+
unread: boolean,
|
|
2061
|
+
touchedAt?: string,
|
|
2062
|
+
) => {
|
|
2063
|
+
const described = describe(item);
|
|
2064
|
+
const { target, author, objectLabel, actionLabel, objectNameSingular } =
|
|
2065
|
+
described;
|
|
2066
|
+
const isBroken = target === null;
|
|
2067
|
+
// Own links first — a task really is filed under that deal. Borrowed
|
|
2068
|
+
// context fills the gap for records that have none: a contact's company,
|
|
2069
|
+
// that company's open deal.
|
|
2070
|
+
const ownLinks = isBroken
|
|
2071
|
+
? []
|
|
2072
|
+
: relatedRecords(objectNameSingular, String(target.recordId));
|
|
2073
|
+
const recordLinks =
|
|
2074
|
+
ownLinks.length > 0 ? ownLinks : contextFor(item).slice(0, 2);
|
|
2075
|
+
const isActive = unread && !isBroken;
|
|
2076
|
+
const classColor = getObjectColor(objectNameSingular);
|
|
2077
|
+
const chipLabel = !isBroken && target.label !== '' ? target.label : objectLabel;
|
|
2078
|
+
|
|
2079
|
+
return (
|
|
2080
|
+
<div
|
|
2081
|
+
onClick={isBroken ? undefined : () => openRecord(target)}
|
|
2082
|
+
style={{
|
|
2083
|
+
display: 'flex',
|
|
2084
|
+
gap: '11px',
|
|
2085
|
+
padding: '10px 16px 0',
|
|
2086
|
+
cursor: isBroken ? 'default' : 'pointer',
|
|
2087
|
+
}}
|
|
2088
|
+
>
|
|
2089
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
2090
|
+
<div
|
|
2091
|
+
style={{
|
|
2092
|
+
display: 'flex',
|
|
2093
|
+
alignItems: 'baseline',
|
|
2094
|
+
justifyContent: 'space-between',
|
|
2095
|
+
gap: '10px',
|
|
2096
|
+
}}
|
|
2097
|
+
>
|
|
2098
|
+
<div
|
|
2099
|
+
style={{
|
|
2100
|
+
display: 'flex',
|
|
2101
|
+
alignItems: 'center',
|
|
2102
|
+
gap: '6px',
|
|
2103
|
+
minWidth: 0,
|
|
2104
|
+
flexWrap: 'wrap',
|
|
2105
|
+
}}
|
|
2106
|
+
>
|
|
2107
|
+
{!isBroken && (
|
|
2108
|
+
<InlineAvatar
|
|
2109
|
+
size={15}
|
|
2110
|
+
label={chipLabel}
|
|
2111
|
+
color={isActive ? `${classColor}22` : palette.mutedFill}
|
|
2112
|
+
textColor={isActive ? classColor : palette.mutedGlyph}
|
|
2113
|
+
avatarUrl={target.avatarUrl}
|
|
2114
|
+
/>
|
|
2115
|
+
)}
|
|
2116
|
+
<span
|
|
2117
|
+
style={{
|
|
2118
|
+
fontSize: '0.92rem',
|
|
2119
|
+
// A card's heading is a heading whether or not it has been
|
|
2120
|
+
// read; weight was carrying the unread state, which the tint
|
|
2121
|
+
// now says on its own — and it left read cards looking faded.
|
|
2122
|
+
fontWeight: 500,
|
|
2123
|
+
color: isBroken ? palette.textLight : palette.text,
|
|
2124
|
+
textDecoration: isBroken ? 'line-through' : 'none',
|
|
2125
|
+
overflow: 'hidden',
|
|
2126
|
+
textOverflow: 'ellipsis',
|
|
2127
|
+
whiteSpace: 'nowrap',
|
|
2128
|
+
maxWidth: '190px',
|
|
2129
|
+
}}
|
|
2130
|
+
>
|
|
2131
|
+
{chipLabel}
|
|
2132
|
+
</span>
|
|
2133
|
+
</div>
|
|
2134
|
+
|
|
2135
|
+
<span
|
|
2136
|
+
style={{
|
|
2137
|
+
fontSize: '0.92rem',
|
|
2138
|
+
fontWeight: 400,
|
|
2139
|
+
color: palette.textLight,
|
|
2140
|
+
whiteSpace: 'nowrap',
|
|
2141
|
+
}}
|
|
2142
|
+
>
|
|
2143
|
+
{formatAgo(touchedAt ?? String(item.happensAt))}
|
|
2144
|
+
</span>
|
|
2145
|
+
</div>
|
|
2146
|
+
|
|
2147
|
+
<div
|
|
2148
|
+
style={{
|
|
2149
|
+
marginTop: '2px',
|
|
2150
|
+
fontSize: '0.92rem',
|
|
2151
|
+
fontWeight: 400,
|
|
2152
|
+
// Secondary, not tertiary: what happened and who did it is the
|
|
2153
|
+
// sentence of the card. Tertiary grey is for the timestamp.
|
|
2154
|
+
color: palette.textMid,
|
|
2155
|
+
lineHeight: '1.5',
|
|
2156
|
+
}}
|
|
2157
|
+
>
|
|
2158
|
+
{objectLabel} · {actionLabel}
|
|
2159
|
+
{author !== '' && (
|
|
2160
|
+
<>
|
|
2161
|
+
{' · '}
|
|
2162
|
+
<span
|
|
2163
|
+
style={{
|
|
2164
|
+
display: 'inline-flex',
|
|
2165
|
+
alignItems: 'center',
|
|
2166
|
+
gap: '4px',
|
|
2167
|
+
verticalAlign: 'middle',
|
|
2168
|
+
}}
|
|
2169
|
+
>
|
|
2170
|
+
<InlineAvatar
|
|
2171
|
+
size={14}
|
|
2172
|
+
label={author}
|
|
2173
|
+
color={palette.mutedFill}
|
|
2174
|
+
textColor={palette.textMid}
|
|
2175
|
+
avatarUrl={
|
|
2176
|
+
memberAvatars[
|
|
2177
|
+
readMemberId(item.workspaceMember) ??
|
|
2178
|
+
readMemberId(item.createdBy) ??
|
|
2179
|
+
''
|
|
2180
|
+
]
|
|
2181
|
+
}
|
|
2182
|
+
/>
|
|
2183
|
+
{author}
|
|
2184
|
+
</span>
|
|
2185
|
+
</>
|
|
2186
|
+
)}
|
|
2187
|
+
{isBroken && ` · ${t('record deleted, cannot open')}`}
|
|
2188
|
+
</div>
|
|
2189
|
+
|
|
2190
|
+
{recordLinks.length > 0 && (
|
|
2191
|
+
<div
|
|
2192
|
+
style={{
|
|
2193
|
+
marginTop: '5px',
|
|
2194
|
+
display: 'flex',
|
|
2195
|
+
alignItems: 'center',
|
|
2196
|
+
gap: '6px',
|
|
2197
|
+
flexWrap: 'wrap',
|
|
2198
|
+
rowGap: '5px',
|
|
2199
|
+
}}
|
|
2200
|
+
>
|
|
2201
|
+
{recordLinks.map((link) => {
|
|
2202
|
+
const linkColor = getObjectColor(link.objectNameSingular);
|
|
2203
|
+
// A record can have no name — an untitled note, a company saved
|
|
2204
|
+
// blank. The chip still opens it, so it keeps its place and
|
|
2205
|
+
// borrows the object's own name instead of rendering as an
|
|
2206
|
+
// empty box with a question mark in it.
|
|
2207
|
+
const linkLabel =
|
|
2208
|
+
link.label !== ''
|
|
2209
|
+
? link.label
|
|
2210
|
+
: labelForObject(link.objectNameSingular);
|
|
2211
|
+
|
|
2212
|
+
return (
|
|
2213
|
+
<span
|
|
2214
|
+
key={link.recordId}
|
|
2215
|
+
onClick={(event) => {
|
|
2216
|
+
event.stopPropagation();
|
|
2217
|
+
openRecord(link);
|
|
2218
|
+
}}
|
|
2219
|
+
title={t('Open: {label}', { label: linkLabel })}
|
|
2220
|
+
style={{
|
|
2221
|
+
display: 'inline-flex',
|
|
2222
|
+
alignItems: 'center',
|
|
2223
|
+
gap: '5px',
|
|
2224
|
+
maxWidth: '180px',
|
|
2225
|
+
padding: '1px 7px 1px 3px',
|
|
2226
|
+
borderRadius: '4px',
|
|
2227
|
+
background: `${linkColor}14`,
|
|
2228
|
+
cursor: 'pointer',
|
|
2229
|
+
whiteSpace: 'nowrap',
|
|
2230
|
+
}}
|
|
2231
|
+
>
|
|
2232
|
+
<InlineAvatar
|
|
2233
|
+
size={14}
|
|
2234
|
+
label={linkLabel}
|
|
2235
|
+
color={`${linkColor}2E`}
|
|
2236
|
+
textColor={linkColor}
|
|
2237
|
+
avatarUrl={link.avatarUrl}
|
|
2238
|
+
/>
|
|
2239
|
+
<span
|
|
2240
|
+
style={{
|
|
2241
|
+
fontSize: '0.92rem',
|
|
2242
|
+
color: palette.text,
|
|
2243
|
+
overflow: 'hidden',
|
|
2244
|
+
textOverflow: 'ellipsis',
|
|
2245
|
+
}}
|
|
2246
|
+
>
|
|
2247
|
+
{linkLabel}
|
|
2248
|
+
</span>
|
|
2249
|
+
</span>
|
|
2250
|
+
);
|
|
2251
|
+
})}
|
|
2252
|
+
</div>
|
|
2253
|
+
)}
|
|
2254
|
+
|
|
2255
|
+
{renderPayload(item, described, isActive ? classColor : palette.rail)}
|
|
2256
|
+
</div>
|
|
2257
|
+
</div>
|
|
2258
|
+
);
|
|
2259
|
+
};
|
|
2260
|
+
|
|
2261
|
+
const renderFollowUp = (item: TimelineRecord) => {
|
|
2262
|
+
const described = describe(item);
|
|
2263
|
+
|
|
2264
|
+
return (
|
|
2265
|
+
<div
|
|
2266
|
+
key={String(item.id)}
|
|
2267
|
+
style={{
|
|
2268
|
+
padding: '6px 16px 0',
|
|
2269
|
+
}}
|
|
2270
|
+
>
|
|
2271
|
+
<div
|
|
2272
|
+
style={{
|
|
2273
|
+
display: 'flex',
|
|
2274
|
+
alignItems: 'baseline',
|
|
2275
|
+
justifyContent: 'space-between',
|
|
2276
|
+
gap: '10px',
|
|
2277
|
+
}}
|
|
2278
|
+
>
|
|
2279
|
+
<span
|
|
2280
|
+
style={{
|
|
2281
|
+
fontSize: '0.92rem',
|
|
2282
|
+
fontWeight: 400,
|
|
2283
|
+
color: palette.textLight,
|
|
2284
|
+
}}
|
|
2285
|
+
>
|
|
2286
|
+
{described.actionLabel}
|
|
2287
|
+
{described.author !== '' ? ` · ${described.author}` : ''}
|
|
2288
|
+
</span>
|
|
2289
|
+
<span
|
|
2290
|
+
style={{
|
|
2291
|
+
fontSize: '0.92rem',
|
|
2292
|
+
fontWeight: 400,
|
|
2293
|
+
color: palette.textLight,
|
|
2294
|
+
whiteSpace: 'nowrap',
|
|
2295
|
+
}}
|
|
2296
|
+
>
|
|
2297
|
+
{formatAgo(String(item.happensAt))}
|
|
2298
|
+
</span>
|
|
2299
|
+
</div>
|
|
2300
|
+
{renderPayload(item, described, palette.rail)}
|
|
2301
|
+
</div>
|
|
2302
|
+
);
|
|
2303
|
+
};
|
|
2304
|
+
|
|
2305
|
+
const renderGroup = (group: FeedGroup, unread: boolean) => {
|
|
2306
|
+
// Files are not "one more event on this record" — they are things you come
|
|
2307
|
+
// back to. They stay visible under the head instead of hiding behind the
|
|
2308
|
+
// counter, and a card made only of files leads with the newest one.
|
|
2309
|
+
const files = group.items.filter((item) => item.name === ATTACHMENT_EVENT);
|
|
2310
|
+
const events = group.items.filter((item) => item.name !== ATTACHMENT_EVENT);
|
|
2311
|
+
const [head, ...rest] = events.length > 0 ? events : files;
|
|
2312
|
+
const attachments = events.length > 0 ? files : files.slice(1);
|
|
2313
|
+
const isExpanded = expandedKeys.includes(group.key);
|
|
2314
|
+
|
|
2315
|
+
// A feed row opens the record exactly like a card does, so it answers the
|
|
2316
|
+
// cursor the same way. A row has no frame to outline, so the reaction is a
|
|
2317
|
+
// fill — the standard list affordance — rather than the card's border.
|
|
2318
|
+
const isRowHovered = hoveredCard === group.key;
|
|
2319
|
+
const touchedAt = newestHappensAt(group.items);
|
|
2320
|
+
|
|
2321
|
+
return (
|
|
2322
|
+
<div
|
|
2323
|
+
key={group.key}
|
|
2324
|
+
onMouseEnter={() => setHoveredCard(group.key)}
|
|
2325
|
+
onMouseLeave={() => setHoveredCard(null)}
|
|
2326
|
+
style={{
|
|
2327
|
+
margin: '16px 12px',
|
|
2328
|
+
border: `1px solid ${
|
|
2329
|
+
isRowHovered ? palette.cardHoverBorder : palette.border
|
|
2330
|
+
}`,
|
|
2331
|
+
borderRadius: '10px',
|
|
2332
|
+
// Unread keeps the tint; a read card sits on the panel's own surface.
|
|
2333
|
+
background: unread
|
|
2334
|
+
? isRowHovered
|
|
2335
|
+
? palette.unreadHover
|
|
2336
|
+
: palette.unread
|
|
2337
|
+
: colorScheme === 'dark'
|
|
2338
|
+
? '#1B1B1B'
|
|
2339
|
+
: '#FFFFFF',
|
|
2340
|
+
overflow: 'hidden',
|
|
2341
|
+
transition: 'background 140ms ease, border-color 140ms ease',
|
|
2342
|
+
}}
|
|
2343
|
+
>
|
|
2344
|
+
<div style={{ paddingBottom: '12px' }}>
|
|
2345
|
+
{renderHead(head, unread, touchedAt)}
|
|
2346
|
+
</div>
|
|
2347
|
+
|
|
2348
|
+
{/* Everything filed under the record sits on the same tinted strip a
|
|
2349
|
+
task card gives its comments: what happened above, what is attached
|
|
2350
|
+
to it below. Two surfaces of one anatomy, so the feed and the other
|
|
2351
|
+
tabs read as the same object. */}
|
|
2352
|
+
{(attachments.length > 0 || rest.length > 0) && (
|
|
2353
|
+
<div
|
|
2354
|
+
style={{
|
|
2355
|
+
borderTop: `1px solid ${palette.border}`,
|
|
2356
|
+
background: unread
|
|
2357
|
+
? 'transparent'
|
|
2358
|
+
: colorScheme === 'dark'
|
|
2359
|
+
? '#191919'
|
|
2360
|
+
: '#FAFAFA',
|
|
2361
|
+
padding: '8px 0 10px',
|
|
2362
|
+
}}
|
|
2363
|
+
>
|
|
2364
|
+
{attachments.length > 0 && (
|
|
2365
|
+
<div
|
|
2366
|
+
style={{
|
|
2367
|
+
padding: '0 16px',
|
|
2368
|
+
}}
|
|
2369
|
+
>
|
|
2370
|
+
{attachments.map((file) => {
|
|
2371
|
+
const fileName =
|
|
2372
|
+
typeof file.linkedRecordCachedName === 'string'
|
|
2373
|
+
? file.linkedRecordCachedName
|
|
2374
|
+
: t('Document');
|
|
2375
|
+
|
|
2376
|
+
// The SDK has no file viewer — its only modal is a text
|
|
2377
|
+
// confirmation, and no side-panel page renders a document. The
|
|
2378
|
+
// attachment is a record of its own, though, so the name opens
|
|
2379
|
+
// that record, where Twenty offers the file itself.
|
|
2380
|
+
const attachmentId = String(file.id).replace(/^attachment-/, '');
|
|
2381
|
+
|
|
2382
|
+
return (
|
|
2383
|
+
<div
|
|
2384
|
+
key={String(file.id)}
|
|
2385
|
+
style={{
|
|
2386
|
+
marginTop: '4px',
|
|
2387
|
+
display: 'flex',
|
|
2388
|
+
alignItems: 'center',
|
|
2389
|
+
gap: '6px',
|
|
2390
|
+
minWidth: 0,
|
|
2391
|
+
}}
|
|
2392
|
+
>
|
|
2393
|
+
{renderFileMark(fileName)}
|
|
2394
|
+
<span
|
|
2395
|
+
onClick={(event) => {
|
|
2396
|
+
event.stopPropagation();
|
|
2397
|
+
openRecord({
|
|
2398
|
+
objectNameSingular: 'attachment',
|
|
2399
|
+
recordId: attachmentId,
|
|
2400
|
+
label: fileName,
|
|
2401
|
+
});
|
|
2402
|
+
}}
|
|
2403
|
+
onMouseEnter={() => setHoveredId(`file-${attachmentId}`)}
|
|
2404
|
+
onMouseLeave={() => setHoveredId(null)}
|
|
2405
|
+
title={t('Open: {label}', { label: fileName })}
|
|
2406
|
+
style={{
|
|
2407
|
+
fontSize: '0.92rem',
|
|
2408
|
+
fontWeight: 400,
|
|
2409
|
+
color: palette.text,
|
|
2410
|
+
overflow: 'hidden',
|
|
2411
|
+
textOverflow: 'ellipsis',
|
|
2412
|
+
whiteSpace: 'nowrap',
|
|
2413
|
+
cursor: 'pointer',
|
|
2414
|
+
textDecoration:
|
|
2415
|
+
hoveredId === `file-${attachmentId}`
|
|
2416
|
+
? 'underline'
|
|
2417
|
+
: 'none',
|
|
2418
|
+
}}
|
|
2419
|
+
>
|
|
2420
|
+
{fileName}
|
|
2421
|
+
</span>
|
|
2422
|
+
</div>
|
|
2423
|
+
);
|
|
2424
|
+
})}
|
|
2425
|
+
</div>
|
|
2426
|
+
)}
|
|
2427
|
+
|
|
2428
|
+
{rest.length > 0 && (
|
|
2429
|
+
<div
|
|
2430
|
+
style={{
|
|
2431
|
+
padding: attachments.length > 0 ? '8px 16px 0' : '0 16px',
|
|
2432
|
+
}}
|
|
2433
|
+
>
|
|
2434
|
+
<button
|
|
2435
|
+
type="button"
|
|
2436
|
+
onClick={() =>
|
|
2437
|
+
setExpandedKeys((keys) =>
|
|
2438
|
+
keys.includes(group.key)
|
|
2439
|
+
? keys.filter((key) => key !== group.key)
|
|
2440
|
+
: [...keys, group.key],
|
|
2441
|
+
)
|
|
2442
|
+
}
|
|
2443
|
+
style={{
|
|
2444
|
+
border: 'none',
|
|
2445
|
+
background: 'transparent',
|
|
2446
|
+
padding: 0,
|
|
2447
|
+
fontFamily: 'inherit',
|
|
2448
|
+
fontSize: '0.92rem',
|
|
2449
|
+
fontWeight: 400,
|
|
2450
|
+
color: palette.textMid,
|
|
2451
|
+
cursor: 'pointer',
|
|
2452
|
+
textDecoration: 'underline',
|
|
2453
|
+
}}
|
|
2454
|
+
>
|
|
2455
|
+
{isExpanded
|
|
2456
|
+
? t('Collapse')
|
|
2457
|
+
: rest.length === 1
|
|
2458
|
+
? t('1 more event on this record')
|
|
2459
|
+
: t('{count} more events on this record', {
|
|
2460
|
+
count: rest.length,
|
|
2461
|
+
})}
|
|
2462
|
+
</button>
|
|
2463
|
+
</div>
|
|
2464
|
+
|
|
2465
|
+
)}
|
|
2466
|
+
|
|
2467
|
+
{isExpanded && rest.map(renderFollowUp)}
|
|
2468
|
+
</div>
|
|
2469
|
+
)}
|
|
2470
|
+
</div>
|
|
2471
|
+
);
|
|
2472
|
+
};
|
|
2473
|
+
|
|
2474
|
+
|
|
2475
|
+
const renderTask = (task: TimelineRecord) => {
|
|
2476
|
+
const due = describeDue(task.dueAt);
|
|
2477
|
+
const isOverdue = due.overdueDays > 0;
|
|
2478
|
+
const title = typeof task.title === 'string' ? task.title : t('Untitled');
|
|
2479
|
+
// The task arrives without its relations, so the assignee is resolved
|
|
2480
|
+
// through the member map the panel already holds.
|
|
2481
|
+
const assignee =
|
|
2482
|
+
memberNames[String(task.assigneeId ?? '')] ?? readDisplayName(task.assignee);
|
|
2483
|
+
const classColor = getObjectColor('task');
|
|
2484
|
+
// A deal matters more than the contact it goes through, so it leads.
|
|
2485
|
+
const RANK: Record<string, number> = { opportunity: 0, company: 1 };
|
|
2486
|
+
const links = taskTargets
|
|
2487
|
+
.filter((link) => link.taskId === task.id)
|
|
2488
|
+
.map((link) => resolveTarget(link))
|
|
2489
|
+
.filter((link): link is ResolvedTarget => link !== null)
|
|
2490
|
+
.sort((a, b) => (RANK[a.objectNameSingular] ?? 2) - (RANK[b.objectNameSingular] ?? 2));
|
|
2491
|
+
|
|
2492
|
+
const comments = taskEdits
|
|
2493
|
+
.filter((edit) => edit.targetTaskId === task.id)
|
|
2494
|
+
.map((edit) => ({ edit, parsed: readNoteEdit(edit) }))
|
|
2495
|
+
// An empty `before` means the body was written for the first time —
|
|
2496
|
+
// that is the task's own description, not a comment on it.
|
|
2497
|
+
.filter(({ parsed }) => parsed.text !== '' && parsed.before !== '');
|
|
2498
|
+
|
|
2499
|
+
const cardKey = `task-card-${String(task.id)}`;
|
|
2500
|
+
const isCardHovered = hoveredCard === cardKey;
|
|
2501
|
+
|
|
2502
|
+
return (
|
|
2503
|
+
<div
|
|
2504
|
+
key={String(task.id)}
|
|
2505
|
+
onMouseEnter={() => setHoveredCard(cardKey)}
|
|
2506
|
+
onMouseLeave={() => setHoveredCard(null)}
|
|
2507
|
+
style={{
|
|
2508
|
+
margin: '16px 12px',
|
|
2509
|
+
border: `1px solid ${
|
|
2510
|
+
isCardHovered ? palette.cardHoverBorder : palette.border
|
|
2511
|
+
}`,
|
|
2512
|
+
borderRadius: '10px',
|
|
2513
|
+
background: colorScheme === 'dark' ? '#1B1B1B' : '#FFFFFF',
|
|
2514
|
+
overflow: 'hidden',
|
|
2515
|
+
transition: 'border-color 140ms ease',
|
|
2516
|
+
}}
|
|
2517
|
+
>
|
|
2518
|
+
<div
|
|
2519
|
+
onClick={() =>
|
|
2520
|
+
openRecord({
|
|
2521
|
+
objectNameSingular: 'task',
|
|
2522
|
+
recordId: String(task.id),
|
|
2523
|
+
label: title,
|
|
2524
|
+
})
|
|
2525
|
+
}
|
|
2526
|
+
style={{
|
|
2527
|
+
display: 'flex',
|
|
2528
|
+
gap: '11px',
|
|
2529
|
+
padding: '12px 14px',
|
|
2530
|
+
cursor: 'pointer',
|
|
2531
|
+
}}
|
|
2532
|
+
>
|
|
2533
|
+
<div style={{ paddingTop: '1px' }}>
|
|
2534
|
+
<InlineAvatar
|
|
2535
|
+
size={15}
|
|
2536
|
+
label={title}
|
|
2537
|
+
color={`${classColor}22`}
|
|
2538
|
+
textColor={classColor}
|
|
2539
|
+
/>
|
|
2540
|
+
</div>
|
|
2541
|
+
|
|
2542
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
2543
|
+
<div
|
|
2544
|
+
style={{
|
|
2545
|
+
display: 'flex',
|
|
2546
|
+
alignItems: 'baseline',
|
|
2547
|
+
justifyContent: 'space-between',
|
|
2548
|
+
gap: '10px',
|
|
2549
|
+
}}
|
|
2550
|
+
>
|
|
2551
|
+
<span
|
|
2552
|
+
style={{
|
|
2553
|
+
fontSize: '0.92rem',
|
|
2554
|
+
fontWeight: isOverdue ? 500 : 400,
|
|
2555
|
+
color: palette.text,
|
|
2556
|
+
overflow: 'hidden',
|
|
2557
|
+
textOverflow: 'ellipsis',
|
|
2558
|
+
whiteSpace: 'nowrap',
|
|
2559
|
+
}}
|
|
2560
|
+
>
|
|
2561
|
+
{title}
|
|
2562
|
+
</span>
|
|
2563
|
+
<span
|
|
2564
|
+
style={{
|
|
2565
|
+
fontSize: '0.92rem',
|
|
2566
|
+
fontWeight: 400,
|
|
2567
|
+
color: isOverdue ? '#D45453' : palette.textLight,
|
|
2568
|
+
whiteSpace: 'nowrap',
|
|
2569
|
+
}}
|
|
2570
|
+
>
|
|
2571
|
+
{due.label}
|
|
2572
|
+
</span>
|
|
2573
|
+
</div>
|
|
2574
|
+
|
|
2575
|
+
<div
|
|
2576
|
+
style={{
|
|
2577
|
+
marginTop: '4px',
|
|
2578
|
+
display: 'flex',
|
|
2579
|
+
alignItems: 'center',
|
|
2580
|
+
gap: '8px',
|
|
2581
|
+
fontSize: '0.92rem',
|
|
2582
|
+
fontWeight: 400,
|
|
2583
|
+
color: palette.textLight,
|
|
2584
|
+
flexWrap: 'nowrap',
|
|
2585
|
+
whiteSpace: 'nowrap',
|
|
2586
|
+
}}
|
|
2587
|
+
>
|
|
2588
|
+
{renderFieldValue('task', 'status', task.status, String(task.status))}
|
|
2589
|
+
{assignee !== '' && (
|
|
2590
|
+
<span
|
|
2591
|
+
style={{
|
|
2592
|
+
overflow: 'hidden',
|
|
2593
|
+
textOverflow: 'ellipsis',
|
|
2594
|
+
whiteSpace: 'nowrap',
|
|
2595
|
+
}}
|
|
2596
|
+
>
|
|
2597
|
+
{assignee}
|
|
2598
|
+
</span>
|
|
2599
|
+
)}
|
|
2600
|
+
</div>
|
|
2601
|
+
|
|
2602
|
+
{/* Links get a line of their own: cramming them next to the status
|
|
2603
|
+
broke the assignee name across two lines. Overflow scrolls. */}
|
|
2604
|
+
{links.length > 0 && (
|
|
2605
|
+
<div
|
|
2606
|
+
style={{
|
|
2607
|
+
marginTop: '5px',
|
|
2608
|
+
display: 'flex',
|
|
2609
|
+
alignItems: 'center',
|
|
2610
|
+
gap: '6px',
|
|
2611
|
+
// Wrap rather than scroll: a scrollbar inside a card reads as
|
|
2612
|
+
// a defect, and links are few enough to fold onto a new line.
|
|
2613
|
+
flexWrap: 'wrap',
|
|
2614
|
+
rowGap: '5px',
|
|
2615
|
+
}}
|
|
2616
|
+
>
|
|
2617
|
+
{links.map((link) => {
|
|
2618
|
+
const linkColor = getObjectColor(link.objectNameSingular);
|
|
2619
|
+
|
|
2620
|
+
return (
|
|
2621
|
+
<span
|
|
2622
|
+
key={link.recordId}
|
|
2623
|
+
onClick={(event) => {
|
|
2624
|
+
event.stopPropagation();
|
|
2625
|
+
openRecord(link);
|
|
2626
|
+
}}
|
|
2627
|
+
title={t("Open: {label}", { label: link.label })}
|
|
2628
|
+
style={{
|
|
2629
|
+
display: 'inline-flex',
|
|
2630
|
+
alignItems: 'center',
|
|
2631
|
+
gap: '5px',
|
|
2632
|
+
maxWidth: '180px',
|
|
2633
|
+
padding: '1px 7px 1px 3px',
|
|
2634
|
+
borderRadius: '4px',
|
|
2635
|
+
background: `${linkColor}14`,
|
|
2636
|
+
cursor: 'pointer',
|
|
2637
|
+
flexShrink: 0,
|
|
2638
|
+
whiteSpace: 'nowrap',
|
|
2639
|
+
}}
|
|
2640
|
+
>
|
|
2641
|
+
<InlineAvatar
|
|
2642
|
+
size={14}
|
|
2643
|
+
label={link.label}
|
|
2644
|
+
color={`${linkColor}2E`}
|
|
2645
|
+
textColor={linkColor}
|
|
2646
|
+
avatarUrl={link.avatarUrl}
|
|
2647
|
+
/>
|
|
2648
|
+
<span
|
|
2649
|
+
style={{
|
|
2650
|
+
color: palette.textMid,
|
|
2651
|
+
overflow: 'hidden',
|
|
2652
|
+
textOverflow: 'ellipsis',
|
|
2653
|
+
whiteSpace: 'nowrap',
|
|
2654
|
+
}}
|
|
2655
|
+
>
|
|
2656
|
+
{link.label}
|
|
2657
|
+
</span>
|
|
2658
|
+
</span>
|
|
2659
|
+
);
|
|
2660
|
+
})}
|
|
2661
|
+
</div>
|
|
2662
|
+
)}
|
|
2663
|
+
|
|
2664
|
+
</div>
|
|
2665
|
+
</div>
|
|
2666
|
+
|
|
2667
|
+
{comments.length > 0 && (
|
|
2668
|
+
<div
|
|
2669
|
+
style={{
|
|
2670
|
+
borderTop: `1px solid ${palette.border}`,
|
|
2671
|
+
background: colorScheme === 'dark' ? '#191919' : '#FAFAFA',
|
|
2672
|
+
padding: '4px 0',
|
|
2673
|
+
}}
|
|
2674
|
+
>
|
|
2675
|
+
{(() => {
|
|
2676
|
+
const key = `task-${String(task.id)}`;
|
|
2677
|
+
const thread = splitThread(comments, key);
|
|
2678
|
+
|
|
2679
|
+
return thread.hidden > 0 ? (
|
|
2680
|
+
<div style={{ padding: '4px 14px 0' }}>
|
|
2681
|
+
{renderThreadToggle(thread.hidden, key)}
|
|
2682
|
+
</div>
|
|
2683
|
+
) : null;
|
|
2684
|
+
})()}
|
|
2685
|
+
|
|
2686
|
+
{splitThread(comments, `task-${String(task.id)}`).visible.map(
|
|
2687
|
+
({ edit, parsed }) =>
|
|
2688
|
+
renderComment(
|
|
2689
|
+
edit,
|
|
2690
|
+
parsed,
|
|
2691
|
+
() =>
|
|
2692
|
+
openRecord({
|
|
2693
|
+
objectNameSingular: 'task',
|
|
2694
|
+
recordId: String(task.id),
|
|
2695
|
+
label: title,
|
|
2696
|
+
}),
|
|
2697
|
+
t('Open task'),
|
|
2698
|
+
),
|
|
2699
|
+
)}
|
|
2700
|
+
</div>
|
|
2701
|
+
)}
|
|
2702
|
+
</div>
|
|
2703
|
+
);
|
|
2704
|
+
};
|
|
2705
|
+
|
|
2706
|
+
// A comment looks the same wherever it appears — Buzz and tasks were drifting
|
|
2707
|
+
// apart because they were written at different times.
|
|
2708
|
+
const renderComment = (
|
|
2709
|
+
edit: TimelineRecord,
|
|
2710
|
+
parsed: ReturnType<typeof readNoteEdit>,
|
|
2711
|
+
onOpen: () => void,
|
|
2712
|
+
openLabel: string,
|
|
2713
|
+
) => {
|
|
2714
|
+
// The event's own column first: it survives a depth-0 fetch, where the
|
|
2715
|
+
// `workspaceMember` relation does not. `updatedBy` is the last resort — it
|
|
2716
|
+
// names whatever actor last wrote the row, which for anything touched by a
|
|
2717
|
+
// script or an integration is the token, not a person.
|
|
2718
|
+
const commenterId =
|
|
2719
|
+
String(edit.workspaceMemberId ?? '') ||
|
|
2720
|
+
readMemberId(edit.workspaceMember) ||
|
|
2721
|
+
readMemberId(edit.updatedBy) ||
|
|
2722
|
+
'';
|
|
2723
|
+
const commenter =
|
|
2724
|
+
memberNames[commenterId] ||
|
|
2725
|
+
readDisplayName(edit.workspaceMember) ||
|
|
2726
|
+
readDisplayName(edit.updatedBy);
|
|
2727
|
+
const editId = String(edit.id);
|
|
2728
|
+
const avatarUrl = memberAvatars[commenterId];
|
|
2729
|
+
|
|
2730
|
+
return (
|
|
2731
|
+
<div
|
|
2732
|
+
key={editId}
|
|
2733
|
+
onClick={() =>
|
|
2734
|
+
setExpandedComments((keys) =>
|
|
2735
|
+
keys.includes(editId)
|
|
2736
|
+
? keys.filter((key) => key !== editId)
|
|
2737
|
+
: [...keys, editId],
|
|
2738
|
+
)
|
|
2739
|
+
}
|
|
2740
|
+
onMouseEnter={() => setHoveredId(editId)}
|
|
2741
|
+
onMouseLeave={() => setHoveredId(null)}
|
|
2742
|
+
title={t("Show what this was written against")}
|
|
2743
|
+
style={{
|
|
2744
|
+
display: 'flex',
|
|
2745
|
+
gap: '8px',
|
|
2746
|
+
padding: '8px 14px',
|
|
2747
|
+
alignItems: 'flex-start',
|
|
2748
|
+
cursor: 'pointer',
|
|
2749
|
+
background: hoveredId === editId ? palette.hover : 'transparent',
|
|
2750
|
+
}}
|
|
2751
|
+
>
|
|
2752
|
+
<InlineAvatar
|
|
2753
|
+
size={22}
|
|
2754
|
+
// An unresolved author is a person we cannot name, not a puzzle: a
|
|
2755
|
+
// bare "?" in a chip reads as a rendering fault.
|
|
2756
|
+
label={commenter !== '' ? commenter : t('Someone')}
|
|
2757
|
+
color={palette.mutedFill}
|
|
2758
|
+
textColor={palette.textMid}
|
|
2759
|
+
avatarUrl={avatarUrl}
|
|
2760
|
+
/>
|
|
2761
|
+
<div style={{ minWidth: 0, flex: 1 }}>
|
|
2762
|
+
<div style={{ display: 'flex', alignItems: 'baseline', gap: '8px' }}>
|
|
2763
|
+
<span
|
|
2764
|
+
style={{
|
|
2765
|
+
fontSize: '0.92rem',
|
|
2766
|
+
fontWeight: 500,
|
|
2767
|
+
color: palette.text,
|
|
2768
|
+
}}
|
|
2769
|
+
>
|
|
2770
|
+
{commenter !== '' ? commenter : t('Someone')}
|
|
2771
|
+
</span>
|
|
2772
|
+
<span style={{ fontSize: '0.85rem', color: palette.textLight }}>
|
|
2773
|
+
{formatAgo(String(edit.happensAt))}
|
|
2774
|
+
</span>
|
|
2775
|
+
{parsed.isRewrite && (
|
|
2776
|
+
<span style={{ fontSize: '0.85rem', color: palette.textLight }}>
|
|
2777
|
+
{t('rewrote the text')}
|
|
2778
|
+
</span>
|
|
2779
|
+
)}
|
|
2780
|
+
</div>
|
|
2781
|
+
|
|
2782
|
+
<div
|
|
2783
|
+
style={{
|
|
2784
|
+
marginTop: '2px',
|
|
2785
|
+
fontSize: '0.92rem',
|
|
2786
|
+
fontWeight: 400,
|
|
2787
|
+
color: palette.textMid,
|
|
2788
|
+
lineHeight: '1.55',
|
|
2789
|
+
whiteSpace: 'pre-wrap',
|
|
2790
|
+
overflowWrap: 'anywhere',
|
|
2791
|
+
}}
|
|
2792
|
+
>
|
|
2793
|
+
{parsed.text}
|
|
2794
|
+
</div>
|
|
2795
|
+
|
|
2796
|
+
{expandedComments.includes(editId) && (
|
|
2797
|
+
<div
|
|
2798
|
+
style={{
|
|
2799
|
+
marginTop: '8px',
|
|
2800
|
+
paddingLeft: '10px',
|
|
2801
|
+
borderLeft: `2px solid ${palette.border}`,
|
|
2802
|
+
}}
|
|
2803
|
+
>
|
|
2804
|
+
{readContextBefore(parsed.before).map((block, index) => (
|
|
2805
|
+
<div
|
|
2806
|
+
key={index}
|
|
2807
|
+
style={{
|
|
2808
|
+
fontSize: '0.92rem',
|
|
2809
|
+
fontWeight: 400,
|
|
2810
|
+
color: palette.textLight,
|
|
2811
|
+
lineHeight: '1.55',
|
|
2812
|
+
whiteSpace: 'pre-wrap',
|
|
2813
|
+
overflowWrap: 'anywhere',
|
|
2814
|
+
marginBottom: '4px',
|
|
2815
|
+
}}
|
|
2816
|
+
>
|
|
2817
|
+
{block}
|
|
2818
|
+
</div>
|
|
2819
|
+
))}
|
|
2820
|
+
|
|
2821
|
+
<div
|
|
2822
|
+
style={{
|
|
2823
|
+
marginTop: '2px',
|
|
2824
|
+
padding: '4px 7px',
|
|
2825
|
+
borderRadius: '4px',
|
|
2826
|
+
background: `${getObjectColor('note')}1F`,
|
|
2827
|
+
fontSize: '0.92rem',
|
|
2828
|
+
fontWeight: 400,
|
|
2829
|
+
color: palette.text,
|
|
2830
|
+
lineHeight: '1.55',
|
|
2831
|
+
whiteSpace: 'pre-wrap',
|
|
2832
|
+
overflowWrap: 'anywhere',
|
|
2833
|
+
}}
|
|
2834
|
+
>
|
|
2835
|
+
{parsed.text}
|
|
2836
|
+
</div>
|
|
2837
|
+
|
|
2838
|
+
<button
|
|
2839
|
+
type="button"
|
|
2840
|
+
onClick={(event) => {
|
|
2841
|
+
event.stopPropagation();
|
|
2842
|
+
onOpen();
|
|
2843
|
+
}}
|
|
2844
|
+
style={{
|
|
2845
|
+
marginTop: '6px',
|
|
2846
|
+
border: 'none',
|
|
2847
|
+
background: 'transparent',
|
|
2848
|
+
padding: 0,
|
|
2849
|
+
fontFamily: 'inherit',
|
|
2850
|
+
fontSize: '0.85rem',
|
|
2851
|
+
color: palette.textMid,
|
|
2852
|
+
cursor: 'pointer',
|
|
2853
|
+
textDecoration: 'underline',
|
|
2854
|
+
}}
|
|
2855
|
+
>
|
|
2856
|
+
{openLabel}
|
|
2857
|
+
</button>
|
|
2858
|
+
</div>
|
|
2859
|
+
)}
|
|
2860
|
+
</div>
|
|
2861
|
+
</div>
|
|
2862
|
+
);
|
|
2863
|
+
};
|
|
2864
|
+
|
|
2865
|
+
const renderBuzzPost = (post: (typeof buzzPosts)[number]) => {
|
|
2866
|
+
const { note, comments, originalBody, touchedAt } = post;
|
|
2867
|
+
const title = typeof note.title === 'string' ? note.title : t('Untitled');
|
|
2868
|
+
const author = readDisplayName(note.createdBy);
|
|
2869
|
+
const noteColor = getObjectColor('note');
|
|
2870
|
+
|
|
2871
|
+
const cardKey = `post-${String(note.id)}`;
|
|
2872
|
+
const isCardHovered = hoveredCard === cardKey;
|
|
2873
|
+
|
|
2874
|
+
return (
|
|
2875
|
+
<div
|
|
2876
|
+
key={String(note.id)}
|
|
2877
|
+
onMouseEnter={() => setHoveredCard(cardKey)}
|
|
2878
|
+
onMouseLeave={() => setHoveredCard(null)}
|
|
2879
|
+
style={{
|
|
2880
|
+
margin: '16px 12px',
|
|
2881
|
+
border: `1px solid ${
|
|
2882
|
+
isCardHovered ? palette.cardHoverBorder : palette.border
|
|
2883
|
+
}`,
|
|
2884
|
+
borderRadius: '10px',
|
|
2885
|
+
background: colorScheme === 'dark' ? '#1B1B1B' : '#FFFFFF',
|
|
2886
|
+
overflow: 'hidden',
|
|
2887
|
+
transition: 'border-color 140ms ease',
|
|
2888
|
+
}}
|
|
2889
|
+
>
|
|
2890
|
+
<div
|
|
2891
|
+
onClick={() =>
|
|
2892
|
+
openRecord({
|
|
2893
|
+
objectNameSingular: 'note',
|
|
2894
|
+
recordId: String(note.id),
|
|
2895
|
+
label: title,
|
|
2896
|
+
})
|
|
2897
|
+
}
|
|
2898
|
+
style={{ padding: '12px 14px', cursor: 'pointer' }}
|
|
2899
|
+
>
|
|
2900
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
2901
|
+
<InlineAvatar
|
|
2902
|
+
size={28}
|
|
2903
|
+
label={author !== '' ? author : title}
|
|
2904
|
+
color={`${noteColor}22`}
|
|
2905
|
+
textColor={noteColor}
|
|
2906
|
+
avatarUrl={memberAvatars[readMemberId(note.createdBy) ?? '']}
|
|
2907
|
+
/>
|
|
2908
|
+
<div style={{ minWidth: 0 }}>
|
|
2909
|
+
<div
|
|
2910
|
+
style={{
|
|
2911
|
+
fontSize: '0.92rem',
|
|
2912
|
+
fontWeight: 500,
|
|
2913
|
+
color: palette.text,
|
|
2914
|
+
}}
|
|
2915
|
+
>
|
|
2916
|
+
{author !== '' ? author : t('Unknown author')}
|
|
2917
|
+
</div>
|
|
2918
|
+
<div style={{ fontSize: '0.85rem', color: palette.textLight }}>
|
|
2919
|
+
{formatAgo(touchedAt)}
|
|
2920
|
+
</div>
|
|
2921
|
+
</div>
|
|
2922
|
+
</div>
|
|
2923
|
+
|
|
2924
|
+
<div
|
|
2925
|
+
style={{
|
|
2926
|
+
marginTop: '10px',
|
|
2927
|
+
fontSize: '0.92rem',
|
|
2928
|
+
fontWeight: 500,
|
|
2929
|
+
color: palette.text,
|
|
2930
|
+
}}
|
|
2931
|
+
>
|
|
2932
|
+
{title}
|
|
2933
|
+
</div>
|
|
2934
|
+
|
|
2935
|
+
{originalBody !== '' && (
|
|
2936
|
+
<div
|
|
2937
|
+
style={{
|
|
2938
|
+
marginTop: '4px',
|
|
2939
|
+
fontSize: '0.92rem',
|
|
2940
|
+
fontWeight: 400,
|
|
2941
|
+
color: palette.textMid,
|
|
2942
|
+
lineHeight: '1.55',
|
|
2943
|
+
whiteSpace: 'pre-wrap',
|
|
2944
|
+
overflowWrap: 'anywhere',
|
|
2945
|
+
}}
|
|
2946
|
+
>
|
|
2947
|
+
{originalBody}
|
|
2948
|
+
</div>
|
|
2949
|
+
)}
|
|
2950
|
+
</div>
|
|
2951
|
+
|
|
2952
|
+
{comments.length > 0 && (
|
|
2953
|
+
<div
|
|
2954
|
+
style={{
|
|
2955
|
+
borderTop: `1px solid ${palette.border}`,
|
|
2956
|
+
background: colorScheme === 'dark' ? '#191919' : '#FAFAFA',
|
|
2957
|
+
padding: '4px 0',
|
|
2958
|
+
}}
|
|
2959
|
+
>
|
|
2960
|
+
{(() => {
|
|
2961
|
+
const thread = splitThread(comments, `note-${String(note.id)}`);
|
|
2962
|
+
|
|
2963
|
+
return thread.hidden > 0 ? (
|
|
2964
|
+
<div style={{ padding: '4px 14px 0' }}>
|
|
2965
|
+
{renderThreadToggle(
|
|
2966
|
+
thread.hidden,
|
|
2967
|
+
`note-${String(note.id)}`,
|
|
2968
|
+
)}
|
|
2969
|
+
</div>
|
|
2970
|
+
) : null;
|
|
2971
|
+
})()}
|
|
2972
|
+
|
|
2973
|
+
{splitThread(comments, `note-${String(note.id)}`).visible.map(
|
|
2974
|
+
({ edit, parsed }) =>
|
|
2975
|
+
renderComment(
|
|
2976
|
+
edit,
|
|
2977
|
+
parsed,
|
|
2978
|
+
() =>
|
|
2979
|
+
openRecord({
|
|
2980
|
+
objectNameSingular: 'note',
|
|
2981
|
+
recordId: String(note.id),
|
|
2982
|
+
label: title,
|
|
2983
|
+
}),
|
|
2984
|
+
t('Open note'),
|
|
2985
|
+
),
|
|
2986
|
+
)}
|
|
2987
|
+
</div>
|
|
2988
|
+
)}
|
|
2989
|
+
</div>
|
|
2990
|
+
);
|
|
2991
|
+
};
|
|
2992
|
+
|
|
2993
|
+
// A thread reads oldest-first, but when it grows the recent replies are the
|
|
2994
|
+
// ones that matter — so the older head is folded away.
|
|
2995
|
+
const VISIBLE_COMMENTS = 3;
|
|
2996
|
+
|
|
2997
|
+
const splitThread = <T,>(comments: T[], key: string) => {
|
|
2998
|
+
if (comments.length <= VISIBLE_COMMENTS || expandedThreads.includes(key)) {
|
|
2999
|
+
return { hidden: 0, visible: comments };
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
return {
|
|
3003
|
+
hidden: comments.length - VISIBLE_COMMENTS,
|
|
3004
|
+
visible: comments.slice(-VISIBLE_COMMENTS),
|
|
3005
|
+
};
|
|
3006
|
+
};
|
|
3007
|
+
|
|
3008
|
+
const renderThreadToggle = (hidden: number, key: string) => (
|
|
3009
|
+
<button
|
|
3010
|
+
type="button"
|
|
3011
|
+
onClick={(event) => {
|
|
3012
|
+
event.stopPropagation();
|
|
3013
|
+
setExpandedThreads((keys) =>
|
|
3014
|
+
keys.includes(key) ? keys.filter((k) => k !== key) : [...keys, key],
|
|
3015
|
+
);
|
|
3016
|
+
}}
|
|
3017
|
+
style={{
|
|
3018
|
+
border: 'none',
|
|
3019
|
+
background: 'transparent',
|
|
3020
|
+
padding: 0,
|
|
3021
|
+
marginTop: '6px',
|
|
3022
|
+
fontFamily: 'inherit',
|
|
3023
|
+
fontSize: '0.92rem',
|
|
3024
|
+
fontWeight: 400,
|
|
3025
|
+
color: palette.textMid,
|
|
3026
|
+
cursor: 'pointer',
|
|
3027
|
+
textDecoration: 'underline',
|
|
3028
|
+
}}
|
|
3029
|
+
>
|
|
3030
|
+
{hidden === 1
|
|
3031
|
+
? t('1 more comment')
|
|
3032
|
+
: t('{hidden} more comments', { hidden })}
|
|
3033
|
+
</button>
|
|
3034
|
+
);
|
|
3035
|
+
|
|
3036
|
+
const renderBulk = (
|
|
3037
|
+
entry: { key: string; items: TimelineRecord[] },
|
|
3038
|
+
unread: boolean,
|
|
3039
|
+
) => {
|
|
3040
|
+
const [head] = entry.items;
|
|
3041
|
+
const described = describe(head);
|
|
3042
|
+
const author = described.author;
|
|
3043
|
+
const isExpanded = expandedKeys.includes(entry.key);
|
|
3044
|
+
const classColor = getObjectColor(described.objectNameSingular);
|
|
3045
|
+
const isActive = unread;
|
|
3046
|
+
// Twelve files uploaded to deals is "12 × document", not "12 × Deal": the
|
|
3047
|
+
// target object is what they hang on, not what appeared.
|
|
3048
|
+
const linkedMessage =
|
|
3049
|
+
described.linkedKind === null
|
|
3050
|
+
? undefined
|
|
3051
|
+
: LINKED_KIND_LABELS[
|
|
3052
|
+
described.linkedKind as keyof typeof LINKED_KIND_LABELS
|
|
3053
|
+
];
|
|
3054
|
+
const verbMessage =
|
|
3055
|
+
LINKED_ACTION_LABELS[
|
|
3056
|
+
String(head.name ?? '').split('.')[1] as keyof typeof LINKED_ACTION_LABELS
|
|
3057
|
+
];
|
|
3058
|
+
const bulkLabel =
|
|
3059
|
+
linkedMessage !== undefined ? t(linkedMessage) : described.objectLabel;
|
|
3060
|
+
// `actionLabel` already reads "document added" for linked events, which
|
|
3061
|
+
// would repeat the label above — the bare verb is enough there.
|
|
3062
|
+
const bulkAction =
|
|
3063
|
+
linkedMessage !== undefined && verbMessage !== undefined
|
|
3064
|
+
? t(verbMessage)
|
|
3065
|
+
: described.actionLabel;
|
|
3066
|
+
|
|
3067
|
+
const isRowHovered = hoveredCard === entry.key;
|
|
3068
|
+
|
|
3069
|
+
return (
|
|
3070
|
+
<div
|
|
3071
|
+
key={entry.key}
|
|
3072
|
+
onMouseEnter={() => setHoveredCard(entry.key)}
|
|
3073
|
+
onMouseLeave={() => setHoveredCard(null)}
|
|
3074
|
+
style={{
|
|
3075
|
+
margin: '10px 12px',
|
|
3076
|
+
paddingBottom: '12px',
|
|
3077
|
+
border: `1px solid ${
|
|
3078
|
+
isRowHovered ? palette.cardHoverBorder : palette.border
|
|
3079
|
+
}`,
|
|
3080
|
+
borderRadius: '10px',
|
|
3081
|
+
background: unread
|
|
3082
|
+
? isRowHovered
|
|
3083
|
+
? palette.unreadHover
|
|
3084
|
+
: palette.unread
|
|
3085
|
+
: colorScheme === 'dark'
|
|
3086
|
+
? '#1B1B1B'
|
|
3087
|
+
: '#FFFFFF',
|
|
3088
|
+
overflow: 'hidden',
|
|
3089
|
+
transition: 'background 140ms ease, border-color 140ms ease',
|
|
3090
|
+
}}
|
|
3091
|
+
>
|
|
3092
|
+
<div style={{ display: 'flex', gap: '11px', padding: '10px 16px 0' }}>
|
|
3093
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
3094
|
+
<div
|
|
3095
|
+
style={{
|
|
3096
|
+
display: 'flex',
|
|
3097
|
+
alignItems: 'baseline',
|
|
3098
|
+
justifyContent: 'space-between',
|
|
3099
|
+
gap: '10px',
|
|
3100
|
+
}}
|
|
3101
|
+
>
|
|
3102
|
+
<div
|
|
3103
|
+
style={{
|
|
3104
|
+
display: 'flex',
|
|
3105
|
+
alignItems: 'center',
|
|
3106
|
+
gap: '6px',
|
|
3107
|
+
minWidth: 0,
|
|
3108
|
+
}}
|
|
3109
|
+
>
|
|
3110
|
+
<InlineAvatar
|
|
3111
|
+
size={15}
|
|
3112
|
+
label={bulkLabel}
|
|
3113
|
+
color={isActive ? `${classColor}22` : palette.mutedFill}
|
|
3114
|
+
textColor={isActive ? classColor : palette.mutedGlyph}
|
|
3115
|
+
/>
|
|
3116
|
+
<span
|
|
3117
|
+
style={{
|
|
3118
|
+
fontSize: '0.92rem',
|
|
3119
|
+
fontWeight: unread ? 500 : 400,
|
|
3120
|
+
color: palette.text,
|
|
3121
|
+
}}
|
|
3122
|
+
>
|
|
3123
|
+
{t('{count} × {object}', {
|
|
3124
|
+
count: entry.items.length,
|
|
3125
|
+
object: bulkLabel,
|
|
3126
|
+
})}
|
|
3127
|
+
</span>
|
|
3128
|
+
<span
|
|
3129
|
+
style={{
|
|
3130
|
+
fontSize: '0.92rem',
|
|
3131
|
+
fontWeight: 400,
|
|
3132
|
+
color: palette.textLight,
|
|
3133
|
+
}}
|
|
3134
|
+
>
|
|
3135
|
+
{bulkAction}
|
|
3136
|
+
</span>
|
|
3137
|
+
</div>
|
|
3138
|
+
|
|
3139
|
+
<span
|
|
3140
|
+
style={{
|
|
3141
|
+
fontSize: '0.92rem',
|
|
3142
|
+
fontWeight: 400,
|
|
3143
|
+
color: palette.textLight,
|
|
3144
|
+
whiteSpace: 'nowrap',
|
|
3145
|
+
}}
|
|
3146
|
+
>
|
|
3147
|
+
{formatAgo(String(head.happensAt))}
|
|
3148
|
+
</span>
|
|
3149
|
+
</div>
|
|
3150
|
+
|
|
3151
|
+
<div
|
|
3152
|
+
style={{
|
|
3153
|
+
marginTop: '2px',
|
|
3154
|
+
fontSize: '0.92rem',
|
|
3155
|
+
fontWeight: 400,
|
|
3156
|
+
color: palette.textLight,
|
|
3157
|
+
}}
|
|
3158
|
+
>
|
|
3159
|
+
{t('bulk change')}
|
|
3160
|
+
{author !== '' ? ` · ${author}` : ''}
|
|
3161
|
+
</div>
|
|
3162
|
+
|
|
3163
|
+
<button
|
|
3164
|
+
type="button"
|
|
3165
|
+
onClick={() =>
|
|
3166
|
+
setExpandedKeys((keys) =>
|
|
3167
|
+
keys.includes(entry.key)
|
|
3168
|
+
? keys.filter((key) => key !== entry.key)
|
|
3169
|
+
: [...keys, entry.key],
|
|
3170
|
+
)
|
|
3171
|
+
}
|
|
3172
|
+
style={{
|
|
3173
|
+
marginTop: '6px',
|
|
3174
|
+
border: 'none',
|
|
3175
|
+
background: 'transparent',
|
|
3176
|
+
padding: 0,
|
|
3177
|
+
fontFamily: 'inherit',
|
|
3178
|
+
fontSize: '0.92rem',
|
|
3179
|
+
fontWeight: 400,
|
|
3180
|
+
color: palette.textMid,
|
|
3181
|
+
cursor: 'pointer',
|
|
3182
|
+
textDecoration: 'underline',
|
|
3183
|
+
}}
|
|
3184
|
+
>
|
|
3185
|
+
{isExpanded ? t('Collapse') : t('Show records')}
|
|
3186
|
+
</button>
|
|
3187
|
+
|
|
3188
|
+
{isExpanded &&
|
|
3189
|
+
entry.items.slice(0, 20).map((item) => {
|
|
3190
|
+
const target = resolveTarget(item);
|
|
3191
|
+
|
|
3192
|
+
return (
|
|
3193
|
+
<div
|
|
3194
|
+
key={String(item.id)}
|
|
3195
|
+
onClick={
|
|
3196
|
+
target === null ? undefined : () => openRecord(target)
|
|
3197
|
+
}
|
|
3198
|
+
style={{
|
|
3199
|
+
marginTop: '4px',
|
|
3200
|
+
display: 'flex',
|
|
3201
|
+
alignItems: 'center',
|
|
3202
|
+
gap: '5px',
|
|
3203
|
+
fontSize: '0.92rem',
|
|
3204
|
+
fontWeight: 400,
|
|
3205
|
+
color: target === null ? palette.textLight : palette.text,
|
|
3206
|
+
cursor: target === null ? 'default' : 'pointer',
|
|
3207
|
+
overflow: 'hidden',
|
|
3208
|
+
textOverflow: 'ellipsis',
|
|
3209
|
+
whiteSpace: 'nowrap',
|
|
3210
|
+
}}
|
|
3211
|
+
>
|
|
3212
|
+
{typeof item.linkedRecordCachedName === 'string' &&
|
|
3213
|
+
item.linkedRecordCachedName !== ''
|
|
3214
|
+
? // A linked note or task names itself; the click opens
|
|
3215
|
+
// the record it hangs on.
|
|
3216
|
+
`${item.linkedRecordCachedName}${
|
|
3217
|
+
target !== null && target.label !== ''
|
|
3218
|
+
? ` · ${target.label}`
|
|
3219
|
+
: ''
|
|
3220
|
+
}`
|
|
3221
|
+
: target !== null && target.label !== ''
|
|
3222
|
+
? target.label
|
|
3223
|
+
: bulkLabel}
|
|
3224
|
+
</div>
|
|
3225
|
+
);
|
|
3226
|
+
})}
|
|
3227
|
+
</div>
|
|
3228
|
+
</div>
|
|
3229
|
+
</div>
|
|
3230
|
+
);
|
|
3231
|
+
};
|
|
3232
|
+
|
|
3233
|
+
const renderSectionHeader = (label: string, count?: number) => (
|
|
3234
|
+
<div
|
|
3235
|
+
style={{
|
|
3236
|
+
display: 'flex',
|
|
3237
|
+
alignItems: 'center',
|
|
3238
|
+
gap: '10px',
|
|
3239
|
+
padding: '16px 16px 6px',
|
|
3240
|
+
}}
|
|
3241
|
+
>
|
|
3242
|
+
<span
|
|
3243
|
+
style={{ fontSize: '0.85rem', fontWeight: 400, color: palette.textLight }}
|
|
3244
|
+
>
|
|
3245
|
+
{label}
|
|
3246
|
+
{count !== undefined ? ` · ${count}` : ''}
|
|
3247
|
+
</span>
|
|
3248
|
+
<div style={{ flex: 1, height: '1px', background: palette.border }} />
|
|
3249
|
+
</div>
|
|
3250
|
+
);
|
|
3251
|
+
|
|
3252
|
+
return (
|
|
3253
|
+
<div
|
|
3254
|
+
onClick={markActive}
|
|
3255
|
+
onKeyDown={markActive}
|
|
3256
|
+
style={{
|
|
3257
|
+
display: 'flex',
|
|
3258
|
+
flexDirection: 'column',
|
|
3259
|
+
height: '100%',
|
|
3260
|
+
color: palette.text,
|
|
3261
|
+
fontFamily:
|
|
3262
|
+
'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
3263
|
+
}}
|
|
3264
|
+
>
|
|
3265
|
+
<div
|
|
3266
|
+
style={{
|
|
3267
|
+
display: 'flex',
|
|
3268
|
+
alignItems: 'center',
|
|
3269
|
+
justifyContent: 'space-between',
|
|
3270
|
+
gap: '8px',
|
|
3271
|
+
padding: '10px 16px',
|
|
3272
|
+
borderBottom: `1px solid ${palette.border}`,
|
|
3273
|
+
// A narrow panel must not stretch or wrap the controls — the strip
|
|
3274
|
+
// scrolls sideways instead.
|
|
3275
|
+
flexWrap: 'nowrap',
|
|
3276
|
+
overflowX: 'auto',
|
|
3277
|
+
scrollbarWidth: 'thin',
|
|
3278
|
+
}}
|
|
3279
|
+
>
|
|
3280
|
+
<div
|
|
3281
|
+
style={{
|
|
3282
|
+
display: 'flex',
|
|
3283
|
+
alignItems: 'center',
|
|
3284
|
+
gap: '7px',
|
|
3285
|
+
flexShrink: 0,
|
|
3286
|
+
whiteSpace: 'nowrap',
|
|
3287
|
+
}}
|
|
3288
|
+
>
|
|
3289
|
+
{/* Three tabs that never move. A breadcrumb here meant the strip was
|
|
3290
|
+
rebuilt on every switch and the whole row jumped sideways. */}
|
|
3291
|
+
<ToolbarButton
|
|
3292
|
+
label={t('Feed')}
|
|
3293
|
+
title={t('Everything that changed in the workspace.')}
|
|
3294
|
+
isActive={view === 'feed'}
|
|
3295
|
+
activeColor={ACCENT_BLUE}
|
|
3296
|
+
onClick={() => setView('feed')}
|
|
3297
|
+
background={palette.buttonBackground}
|
|
3298
|
+
color={palette.textMid}
|
|
3299
|
+
/>
|
|
3300
|
+
<ToolbarButton
|
|
3301
|
+
label="Buzz"
|
|
3302
|
+
title={t('Team notes and the comments on them.')}
|
|
3303
|
+
isActive={view === 'buzz'}
|
|
3304
|
+
activeColor={ACCENT_BLUE}
|
|
3305
|
+
onClick={() => setView('buzz')}
|
|
3306
|
+
background={palette.buttonBackground}
|
|
3307
|
+
color={palette.textMid}
|
|
3308
|
+
/>
|
|
3309
|
+
<ToolbarButton
|
|
3310
|
+
label={t('Tasks')}
|
|
3311
|
+
title={t('Open tasks: overdue first, then by due date.')}
|
|
3312
|
+
isActive={view === 'tasks'}
|
|
3313
|
+
activeColor={ACCENT_BLUE}
|
|
3314
|
+
onClick={() => setView('tasks')}
|
|
3315
|
+
background={palette.buttonBackground}
|
|
3316
|
+
color={palette.textMid}
|
|
3317
|
+
/>
|
|
3318
|
+
{view === 'tasks' && overdueTasks.length > 0 && (
|
|
3319
|
+
<span
|
|
3320
|
+
title={t("Overdue: {count}", { count: overdueTasks.length })}
|
|
3321
|
+
style={{
|
|
3322
|
+
display: 'inline-flex',
|
|
3323
|
+
alignItems: 'center',
|
|
3324
|
+
justifyContent: 'center',
|
|
3325
|
+
minWidth: '19px',
|
|
3326
|
+
height: '19px',
|
|
3327
|
+
padding: '0 5px',
|
|
3328
|
+
borderRadius: '10px',
|
|
3329
|
+
border: '1px solid #D45453',
|
|
3330
|
+
fontSize: '0.85rem',
|
|
3331
|
+
fontWeight: 400,
|
|
3332
|
+
color: '#D45453',
|
|
3333
|
+
}}
|
|
3334
|
+
>
|
|
3335
|
+
{overdueTasks.length}
|
|
3336
|
+
</span>
|
|
3337
|
+
)}
|
|
3338
|
+
{view === 'tasks' &&
|
|
3339
|
+
assignedOpenCount !== null &&
|
|
3340
|
+
assignedOpenCount > 0 && (
|
|
3341
|
+
<span
|
|
3342
|
+
title={t('Assigned to you and not done: {count}', {
|
|
3343
|
+
count: assignedOpenCount,
|
|
3344
|
+
})}
|
|
3345
|
+
style={{
|
|
3346
|
+
display: 'inline-flex',
|
|
3347
|
+
alignItems: 'center',
|
|
3348
|
+
justifyContent: 'center',
|
|
3349
|
+
minWidth: '19px',
|
|
3350
|
+
height: '19px',
|
|
3351
|
+
padding: '0 5px',
|
|
3352
|
+
borderRadius: '10px',
|
|
3353
|
+
border: `1px solid ${ACCENT_BLUE}`,
|
|
3354
|
+
fontSize: '0.85rem',
|
|
3355
|
+
fontWeight: 400,
|
|
3356
|
+
color: ACCENT_BLUE,
|
|
3357
|
+
}}
|
|
3358
|
+
>
|
|
3359
|
+
{assignedOpenCount}
|
|
3360
|
+
</span>
|
|
3361
|
+
)}
|
|
3362
|
+
{view === 'feed' && unreadCount > 0 && (
|
|
3363
|
+
<span
|
|
3364
|
+
style={{
|
|
3365
|
+
display: 'inline-flex',
|
|
3366
|
+
alignItems: 'center',
|
|
3367
|
+
justifyContent: 'center',
|
|
3368
|
+
minWidth: '19px',
|
|
3369
|
+
height: '19px',
|
|
3370
|
+
padding: '0 5px',
|
|
3371
|
+
borderRadius: '10px',
|
|
3372
|
+
border: `1px solid ${palette.rail}`,
|
|
3373
|
+
fontSize: '0.85rem',
|
|
3374
|
+
fontWeight: 400,
|
|
3375
|
+
color: palette.textMid,
|
|
3376
|
+
}}
|
|
3377
|
+
>
|
|
3378
|
+
{unreadCount}
|
|
3379
|
+
</span>
|
|
3380
|
+
)}
|
|
3381
|
+
{view === 'feed' && unreadCount > 0 && (
|
|
3382
|
+
<ToolbarButton
|
|
3383
|
+
label={t("Mark all read")}
|
|
3384
|
+
title={t("Mark every event as read")}
|
|
3385
|
+
onClick={() => void markAllAsRead()}
|
|
3386
|
+
background={palette.buttonBackground}
|
|
3387
|
+
color={palette.textMid}
|
|
3388
|
+
/>
|
|
3389
|
+
)}
|
|
3390
|
+
</div>
|
|
3391
|
+
|
|
3392
|
+
<div
|
|
3393
|
+
style={{ display: 'flex', gap: '6px', flexShrink: 0, whiteSpace: 'nowrap' }}
|
|
3394
|
+
>
|
|
3395
|
+
{view === 'buzz' ? (
|
|
3396
|
+
// Buzz is the team wall: everyone's notes, always. A personal
|
|
3397
|
+
// filter there would contradict what the section is for.
|
|
3398
|
+
<></>
|
|
3399
|
+
) : enforcement === 'server' ? (
|
|
3400
|
+
<span
|
|
3401
|
+
title={t("Row-level permissions are on: Twenty scopes the data itself and the panel adds no filtering.")}
|
|
3402
|
+
style={{
|
|
3403
|
+
padding: '4px 9px',
|
|
3404
|
+
borderRadius: '4px',
|
|
3405
|
+
background: palette.buttonBackground,
|
|
3406
|
+
color: palette.textMid,
|
|
3407
|
+
fontSize: '0.85rem',
|
|
3408
|
+
fontWeight: 400,
|
|
3409
|
+
whiteSpace: 'nowrap',
|
|
3410
|
+
flexShrink: 0,
|
|
3411
|
+
}}
|
|
3412
|
+
>
|
|
3413
|
+
{t('Access: server')}
|
|
3414
|
+
</span>
|
|
3415
|
+
) : null}
|
|
3416
|
+
</div>
|
|
3417
|
+
</div>
|
|
3418
|
+
|
|
3419
|
+
<div
|
|
3420
|
+
onScroll={markActive}
|
|
3421
|
+
style={{ flex: 1, overflowY: 'auto', paddingBottom: '12px' }}
|
|
3422
|
+
>
|
|
3423
|
+
{isPaused && (
|
|
3424
|
+
<div
|
|
3425
|
+
style={{
|
|
3426
|
+
padding: '8px 16px',
|
|
3427
|
+
fontSize: '0.85rem',
|
|
3428
|
+
color: palette.textLight,
|
|
3429
|
+
borderBottom: `1px solid ${palette.border}`,
|
|
3430
|
+
}}
|
|
3431
|
+
>
|
|
3432
|
+
{t('Updates are paused while the panel is idle. Click to resume.')}
|
|
3433
|
+
</div>
|
|
3434
|
+
)}
|
|
3435
|
+
|
|
3436
|
+
{isLoading && (
|
|
3437
|
+
<div style={{ padding: '16px', fontSize: '0.92rem', color: palette.textLight }}>
|
|
3438
|
+
{t('Loading…')}
|
|
3439
|
+
</div>
|
|
3440
|
+
)}
|
|
3441
|
+
|
|
3442
|
+
{error !== null && (
|
|
3443
|
+
<div style={{ padding: '16px', fontSize: '0.92rem', color: '#D45453' }}>
|
|
3444
|
+
{t('Could not load the feed: {error}', { error })}
|
|
3445
|
+
</div>
|
|
3446
|
+
)}
|
|
3447
|
+
|
|
3448
|
+
{view === 'feed' && !isLoading && error === null && items.length === 0 && (
|
|
3449
|
+
<div style={{ padding: '16px', fontSize: '0.92rem', color: palette.textLight }}>
|
|
3450
|
+
{t('Nothing has happened yet. Create or change any record and the event shows up here.')}
|
|
3451
|
+
</div>
|
|
3452
|
+
)}
|
|
3453
|
+
|
|
3454
|
+
{view === 'feed' && (
|
|
3455
|
+
<>
|
|
3456
|
+
{unreadEntries.length > 0 &&
|
|
3457
|
+
renderSectionHeader(t('New'), unreadEntries.length)}
|
|
3458
|
+
{unreadEntries.map((entry) =>
|
|
3459
|
+
entry.kind === 'bulk'
|
|
3460
|
+
? renderBulk(entry, true)
|
|
3461
|
+
: renderGroup(entry, true),
|
|
3462
|
+
)}
|
|
3463
|
+
|
|
3464
|
+
{readEntries.length > 0 &&
|
|
3465
|
+
unreadEntries.length > 0 &&
|
|
3466
|
+
renderSectionHeader(t('Earlier'), readEntries.length)}
|
|
3467
|
+
{readEntries.map((entry) =>
|
|
3468
|
+
entry.kind === 'bulk'
|
|
3469
|
+
? renderBulk(entry, false)
|
|
3470
|
+
: renderGroup(entry, false),
|
|
3471
|
+
)}
|
|
3472
|
+
|
|
3473
|
+
{!isLoading && changes.length > 0 && (
|
|
3474
|
+
<div
|
|
3475
|
+
style={{
|
|
3476
|
+
display: 'flex',
|
|
3477
|
+
alignItems: 'center',
|
|
3478
|
+
justifyContent: 'center',
|
|
3479
|
+
gap: '8px',
|
|
3480
|
+
padding: '14px 16px 4px',
|
|
3481
|
+
}}
|
|
3482
|
+
>
|
|
3483
|
+
{!isFeedExhausted &&
|
|
3484
|
+
loadedEvents.length < FEED_LIMIT &&
|
|
3485
|
+
loadedEvents.length < feedTotal && (
|
|
3486
|
+
<ToolbarButton
|
|
3487
|
+
label={isLoadingMore ? t('Loading…') : t('Show more')}
|
|
3488
|
+
title={t('Load the previous page of events')}
|
|
3489
|
+
onClick={() => void loadMore()}
|
|
3490
|
+
background={palette.buttonBackground}
|
|
3491
|
+
color={palette.textMid}
|
|
3492
|
+
/>
|
|
3493
|
+
)}
|
|
3494
|
+
{/* Everything here is personal, so the count is personal
|
|
3495
|
+
too: the updates on your records this week. A workspace-wide
|
|
3496
|
+
denominator would be a number this feed never reaches. What
|
|
3497
|
+
was scanned to find them lives in the tooltip. */}
|
|
3498
|
+
<span
|
|
3499
|
+
title={t(
|
|
3500
|
+
'Scanned {loaded} of {total} workspace updates from the last {days} days',
|
|
3501
|
+
{
|
|
3502
|
+
loaded: loadedEvents.length,
|
|
3503
|
+
total: feedTotal,
|
|
3504
|
+
days: FEED_WINDOW_DAYS,
|
|
3505
|
+
},
|
|
3506
|
+
)}
|
|
3507
|
+
style={{ fontSize: '0.85rem', color: palette.textLight }}
|
|
3508
|
+
>
|
|
3509
|
+
{t('{count} updates on your records this week', {
|
|
3510
|
+
count: visibleEventCount,
|
|
3511
|
+
})}
|
|
3512
|
+
</span>
|
|
3513
|
+
</div>
|
|
3514
|
+
)}
|
|
3515
|
+
|
|
3516
|
+
|
|
3517
|
+
</>
|
|
3518
|
+
)}
|
|
3519
|
+
|
|
3520
|
+
{view === 'buzz' && (
|
|
3521
|
+
<>
|
|
3522
|
+
{buzzPosts.map(renderBuzzPost)}
|
|
3523
|
+
{buzzPosts.length === 0 && (
|
|
3524
|
+
<div
|
|
3525
|
+
style={{
|
|
3526
|
+
padding: '16px',
|
|
3527
|
+
fontSize: '0.92rem',
|
|
3528
|
+
color: palette.textLight,
|
|
3529
|
+
}}
|
|
3530
|
+
>
|
|
3531
|
+
{t('No notes yet.')}
|
|
3532
|
+
</div>
|
|
3533
|
+
)}
|
|
3534
|
+
</>
|
|
3535
|
+
)}
|
|
3536
|
+
|
|
3537
|
+
{view === 'tasks' && (
|
|
3538
|
+
<>
|
|
3539
|
+
{overdueTasks.length > 0 &&
|
|
3540
|
+
renderSectionHeader(t('Overdue'), overdueTasks.length)}
|
|
3541
|
+
{overdueTasks.map(renderTask)}
|
|
3542
|
+
|
|
3543
|
+
{upcomingTasks.length > 0 &&
|
|
3544
|
+
renderSectionHeader(
|
|
3545
|
+
overdueTasks.length > 0 ? t('By due date') : t('Upcoming'),
|
|
3546
|
+
upcomingTasks.length,
|
|
3547
|
+
)}
|
|
3548
|
+
{upcomingTasks.map(renderTask)}
|
|
3549
|
+
|
|
3550
|
+
{openTasks.length === 0 && (
|
|
3551
|
+
<div
|
|
3552
|
+
style={{
|
|
3553
|
+
padding: '16px',
|
|
3554
|
+
fontSize: '0.92rem',
|
|
3555
|
+
color: palette.textLight,
|
|
3556
|
+
}}
|
|
3557
|
+
>
|
|
3558
|
+
{t('No open tasks.')}
|
|
3559
|
+
</div>
|
|
3560
|
+
)}
|
|
3561
|
+
</>
|
|
3562
|
+
)}
|
|
3563
|
+
</div>
|
|
3564
|
+
</div>
|
|
3565
|
+
);
|
|
3566
|
+
};
|
|
3567
|
+
|
|
3568
|
+
export default defineFrontComponent({
|
|
3569
|
+
universalIdentifier: ACTIVITY_FEED_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
|
3570
|
+
name: 'activity-feed',
|
|
3571
|
+
description: 'Workspace activity feed built on the standard timelineActivity',
|
|
3572
|
+
component: ActivityFeed,
|
|
3573
|
+
});
|