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.
@@ -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
+ });