1/** 2 * TaskGroupSync â live task-page refresh over the notification hub's TaskPage groups. 3 * 4 * The modern home of the legacy refreshTaskComments flow: when someone comments on or 5 * updates a task you have open, the backend broadcasts to TaskPage_{company}_{threadId} 6 * and your comment list refreshes without a reload (legacy smrtbldSignalR.js:1806-1809 â 7 * 'app:refreshComments'). 8 * 9 * Leader-election aware: only the leader tab holds the SignalR connection, so the leader 10 * joins the union of every tab's interests (relayed via CrossTabSync) and re-broadcasts 11 * group events to the other tabs. Interests from other tabs carry a keepalive timestamp so 12 * a dead tab's groups get pruned. 13 * 14 * Usage (page module init/destroy): 15 * import { subscribeTaskUpdates } from '../realtime/TaskGroupSync.js'; 16 * const sub = subscribeTaskUpdates(`asgn-${taskId}`, () => refreshComments()); 17 * // in destroy/back-to-list: 18 * sub.dispose(); 19 */ 20 21import { pubsub } from '../utils/pubsub.js'; 22import CrossTabSync from '../notifications/CrossTabSync.js'; 23import { getNotificationHubClient, isConnected } from './NotificationHubClient.js'; 24import { offlineStore } from '../offline/offline-store.js'; 25 26// threadId â Set<callback> (this tab's interests) 27const localSubscriptions = new Map(); 28 29// threadId â Map<tabId, lastSeenMs> (other tabs' interests â leader only acts on these) 30const remoteInterests = new Map(); 31 32// "{partitionKey}|{projectId}" â Set<callback> (this tab's Markups register interests) 33const localMarkupSubscriptions = new Map(); 34const localMarkupRegisterSubscriptions = new Map(); 35 36// interestKey â { partitionKey, projectId, tabs: Map<tabId,lastSeenMs> } 37const remoteMarkupInterests = new Map(); 38 39// interestKey -> latest awaited join owner. A monotonic token prevents an old 40// completion from publishing into a dispose/remount successor with the same key. 41const markupJoinAttempts = new Map(); 42let markupJoinAttemptSequence = 0; 43 44// "{partitionKey}|{projectId}" â Map<subscriptionId, local Activity subscriber> 45const localActivitySubscriptions = new Map(); 46// interestKey â { partitionKey, projectId, subscriptions: Map<subscriptionId,{tabId,lastSeen}> } 47const remoteActivityInterests = new Map(); 48const activityGroupStates = new Map(); 49const activityJoinAttempts = new Map(); 50const activityTransitions = new Map(); 51let activitySubscriptionSequence = 0; 52let activityJoinAttemptSequence = 0; 53let activityConnectionGeneration = 0; 54 55const KEEPALIVE_MS = 60_000; 56const REMOTE_TTL_MS = KEEPALIVE_MS * 2.5; 57 58let initialized = false; 59let keepaliveTimer = null; 60let pubsubSubscriptions = []; 61 62function ensureInitialized() { 63 if (initialized) return; 64 initialized = true; 65 66 pubsubSubscriptions = [ 67 // Group events arrive on the leader's connection â dispatch locally + relay. 68 pubsub.subscribe('signalr:task:refreshComments', (data) => dispatchAndRelay(data)), 69 pubsub.subscribe('signalr:task:updated', (data) => dispatchAndRelay(data)), 70 pubsub.subscribe('signalr:task:refreshList', (data) => dispatchAndRelay(data)), 71 pubsub.subscribe('signalr:markup:processingChanged', (data) =>
71 dispatchMarkupAndRelay(data)), 72 pubsub.subscribe('signalr:markup:registerChanged', (data) => dispatchMarkupRegisterAndRelay(data)), 73 pubsub.subscribe('signalr:projectActivity', (data) => dispatchActivityAndRelay(data)), 74 75 // Relayed group events from the leader tab. 76 pubsub.subscribe('crosstab:taskgroup:event', (data) => dispatchLocal(data)), 77 pubsub.subscribe('crosstab:markup-processing-event', (payload) => dispatchMarkupLocal(payload)), 78 pubsub.subscribe('crosstab:markup-register-event', (payload) => dispatchMarkupRegisterLocal(payload)), 79 pubsub.subscribe('crosstab:project-activity-event', (payload) => dispatchActivityLocal(payload)), 80 pubsub.subscribe('crosstab:project-activity-ready', (payload) => receiveActivityReady(payload)), 81 82 pubsub.subscribe('crosstab:project-activity-interest', (payload) => { 83 const { action, partitionKey, projectId, tabId, subscriptionId } = payload || {}; 84 const numericProjectId = positiveSafeInteger(projectId); 85 if (!partitionKey || !numericProjectId || !tabId || !subscriptionId) return; 86 const key = activityInterestKey(partitionKey, numericProjectId); 87 if (action === 'leave') { 88 const interest = remoteActivityInterests.get(key); 89 if (!interest) return; 90 const remote = interest.subscriptions.get(subscriptionId); 91 if (!remote || remote.tabId !== tabId) return; 92 interest.subscriptions.delete(subscriptionId); 93 if (interest.subscriptions.size === 0) { 94 remoteActivityInterests.delete(key); 95 leaveActivityIfUnwanted(interest); 96 } 97 return; 98 } 99 if (partitionKey !== currentPartitionKey()) return; 100 let interest = remoteActivityInterests.get(key); 101 if (!interest) { 102 interest = { partitionKey, projectId: numericProjectId, subscriptions: new Map() }; 103 remoteActivityInterests.set(key, interest); 104 } 105 const existing = interest.subscriptions.get(subscriptionId); 106 if (existing && existing.tabId !== tabId) return; 107 interest.subscriptions.set(subscriptionId, { tabId, lastSeen: Date.now() }); 108 // An existing interest is also the server-side TTL renewal. The fast path 109 // below renews authorization/lease without re-firing onReady. 110 void joinActivityIfLeader(interest); 111 }), 112 113 // Markup register interests are partition-bound. A mixed-login tab can neither 114 // register through another partition's leader nor consume its relayed payload.
115 pubsub.subscribe('crosstab:markup-processing-interest', (payload) => { 116 const { action, partitionKey, projectId, tabId } = payload || {}; 117 if (!partitionKey || !projectId || !tabId) return; 118 const key = markupInterestKey(partitionKey, projectId); 119 if (action === 'leave') { 120 const interest = remoteMarkupInterests.get(key); 121 if (!interest) return; 122 interest.tabs.delete(tabId); 123 if (interest.tabs.size === 0) { 124 remoteMarkupInterests.delete(key); 125 leaveMarkupIfUnwanted(interest); 126 } 127 return; 128 } 129 if (partitionKey !== currentPartitionKey()) return; 130 let interest = remoteMarkupInterests.get(key); 131 if (!interest) { 132 interest = { partitionKey, projectId: Number(projectId), tabs: new Map() }; 133 remoteMarkupInterests.set(key, interest); 134 } 135 interest.tabs.set(tabId, Date.now()); 136 // Every follower keepalive refreshes the leader's server-side TTL. Redis 137 // registration is idempotent for the one leader connection. 138 joinMarkupIfLeader(interest); 139 }), 140 141 // Other tabs' interest management (leader maintains the union).
142 pubsub.subscribe('crosstab:taskgroup:join', ({ threadId, tabId }) => { 143 if (!threadId) return; 144 let tabs = remoteInterests.get(threadId); 145 if (!tabs) { tabs = new Map(); remoteInterests.set(threadId, tabs); } 146 const isNew = !tabs.has(tabId); 147 tabs.set(tabId, Date.now()); 148 if (isNew) joinIfLeader(threadId); 149 }), 150 pubsub.subscribe('crosstab:taskgroup:leave', ({ threadId, tabId }) => { 151 const tabs = remoteInterests.get(threadId); 152 if (!tabs) return; 153 tabs.delete(tabId); 154 if (tabs.size === 0) { 155 remoteInterests.delete(threadId); 156 leaveIfUnwanted(threadId); 157 } 158 }), 159 160 // SignalR groups are connection-scoped â re-join everything after a reconnect. 161 pubsub.subscribe('signalr:notification:reconnected', () => { 162 invalidateActivityConnection(); 163 rejoinAll(); 164 }), 165 pubsub.subscribe('signalr:notification:connected', () => { 166 invalidateActivityConnection(); 167 rejoinAll(); 168 }), 169 pubsub.subscribe('signalr:notification:disconnected', () => invalidateActivityConnection()), 170 171 // New leader = new connection holder. Every tab re-broadcasts its interests so the 172 // new leader rebuilds the union; the new leader joins its own + incoming ones.
173 pubsub.subscribe('crosstab:leaderElected', () => { 174 invalidateActivityConnection(); 175 broadcastInterests(); 176 rejoinAll(); 177 }) 178 ]; 179 180 // Keepalive: re-announce interests so the leader can prune dead tabs' groups. 181 keepaliveTimer = setInterval(() => { 182 broadcastInterests(); 183 pruneRemoteInterests(); 184 }, KEEPALIVE_MS); 185} 186 187/** 188 * Subscribe to live updates for a task thread. 189 * @param {string} threadId - "asgn-{taskId}" or "calagsn-{eventId}" 190 * @param {Function} callback - receives the event payload ({ threadId, comments? , ... }) 191 * @returns {{ dispose: Function }} 192 */ 193export function subscribeTaskUpdates(threadId, callback) { 194 if (!threadId || typeof callback !== 'function') { 195 return { dispose() {} }; 196 } 197 198 ensureInitialized(); 199 200 let callbacks = localSubscriptions.get(threadId); 201 if (!callbacks) { 202 callbacks = new Set(); 203 localSubscriptions.set(threadId, callbacks); 204 joinIfLeader(threadId); 205 CrossTabSync.broadcast('taskgroup:join', { threadId, tabId: CrossTabSync.getTabId() }); 206 } 207 callbacks.add(callback); 208 209 let disposed = false; 210 return { 211 dispose() { 212 if (disposed) return; 213 disposed = true; 214 215 const cbs = localSubscriptions.get(threadId); 216 if (!cbs) return; 217 cbs.delete(callback); 218 if (cbs.size === 0) { 219 localSubscriptions.delete(threadId); 220 CrossTabSync.broadcast('taskgroup:leave', { threadId, tabId: CrossTabSync.getTabId() });
221 leaveIfUnwanted(threadId); 222 } 223 } 224 }; 225} 226 227/** Initialize leader-side relay listeners even when this tab has no local detail/register mount. */ 228export function initTaskGroupSync() { 229 ensureInitialized(); 230} 231 232/** 233 * Subscribe a mounted Project Activity feed through the existing notification leader. 234 * Partition identity is always derived from the active authenticated offline partition. 235 */ 236export function subscribeProjectActivityUpdates(projectId, callback, options = {}) { 237 const numericProjectId = positiveSafeInteger(projectId); 238 const partitionKey = currentPartitionKey(); 239 if (!numericProjectId || !partitionKey || typeof callback !== 'function' || options.signal?.aborted) { 240 return { subscriptionId: null, dispose() {} }; 241 } 242 ensureInitialized(); 243 244 const key = activityInterestKey(partitionKey, numericProjectId); 245 const subscriptionId = `${CrossTabSync.getTabId()}:${++activitySubscriptionSequence}`; 246 const record = { 247 subscriptionId, 248 partitionKey, 249 projectId: numericProjectId, 250 callback, 251 onReady: typeof options.onReady === 'function' ? options.onReady : null, 252 signal: options.signal || null, 253 lastReadyToken: null, 254 }; 255 let subscriptions = localActivitySubscriptions.get(key); 256 if (!subscriptions) { 257 subscriptions = new Map(); 258 localActivitySubscriptions.set(key, subscriptions); 259 } 260 subscriptions.set(subscriptionId, record); 261 262 // Local callback ownership exists before the asynchronous group admission begins. 263 broadcastActivityInterest('join', record); 264 void joinActivityIfLeader(record); 265 266 let disposed = false; 267 const dispose = () => { 268 if (disposed) return; 269 disposed = true; 270 record.signal?.removeEventListener('abort', dispose); 271 const current = localActivitySubscriptions.get(key); 272 if (!current?.delete(subscriptionId)) return; 273 broadcastActivityInterest('leave', record); 274 if (current.size === 0) { 275 localActivitySubscriptions.delete(key); 276 leaveActivityIfUnwanted(record); 277 } 278 }; 279 record.signal?.addEventListener('abort', dispose, { once: true }); 280 return { subscriptionId, dispose }; 281} 282 283/** 284 * Subscribe a mounted Markups register to exact active-partition/project processing pushes. 285 * Follower tabs relay interest to the elected leader; no follower opens a hub connection. 286 */ 287export function subscribeMarkupProcessingUpdates(projectId, partitionKey, callback) { 288 const numericProjectId = Number(projectId); 289 if (!numericProjectId || !partitionKey || typeof callback !== 'function') return { dispose() {} }; 290 ensureInitialized(); 291 292 const key = markupInterestKey(partitionKey, numericProjectId); 293 let callbacks = localMarkupSubscriptions.get(key); 294 if (!callbacks) { 295 const alreadyInterested = localMarkupRegisterSubscriptions.has(key); 296 callbacks = new Set(); 297 localMarkupSubscriptions.set(key, callbacks); 298 const interest = { partitionKey, projectId: numericProjectId }; 299 joinMarkupIfLeader(interest); 300 if (!alreadyInterested) broadcastMarkupInterest('join', interest); 301 } 302 callbacks.add(callback); 303 304 let disposed = false; 305 return { 306 dispose() { 307 if (disposed) return; 308 disposed = true; 309 const current = localMarkupSubscriptions.get(key); 310 if (!current) return; 311 current.delete(callback); 312 if (current.size === 0) { 313 localMarkupSubscriptions.delete(key); 314 const interest = { partitionKey, projectId: numericProjectId }; 315 if (!localMarkupRegisterSubscriptions.has(key)) broadcastMarkupInterest('leave', interest); 316 leaveMarkupIfUnwanted(interest); 317 } 318 } 319 }; 320} 321 322/** Subscribe to committed Markups-register mutations over the same authorized project group. */ 323export function subscribeMarkupRegisterUpdates(projectId, partitionKey, callback) { 324 const numericProjectId = Number(projectId); 325 if (!numericProjectId || !partitionKey || typeof callback !== 'function') return { dispose() {} }; 326 ensureInitialized();
327 const key = markupInterestKey(partitionKey, numericProjectId); 328 let callbacks = localMarkupRegisterSubscriptions.get(key); 329 if (!callbacks) { 330 const alreadyInterested = localMarkupSubscriptions.has(key); 331 callbacks = new Set(); 332 localMarkupRegisterSubscriptions.set(key, callbacks); 333 const interest = { partitionKey, projectId: numericProjectId }; 334 joinMarkupIfLeader(interest); 335 if (!alreadyInterested) broadcastMarkupInterest('join', interest); 336 } 337 callbacks.add(callback); 338 let disposed = false; 339 return { dispose() { 340 if (disposed) return; 341 disposed = true; 342 const current = localMarkupRegisterSubscriptions.get(key); 343 if (!current) return; 344 current.delete(callback); 345 if (current.size === 0) { 346 localMarkupRegisterSubscriptions.delete(key); 347 const interest = { partitionKey, projectId: numericProjectId }; 348 if (!localMarkupSubscriptions.has(key)) broadcastMarkupInterest('leave', interest); 349 leaveMarkupIfUnwanted(interest); 350 } 351 } }; 352} 353 354// ============================================================================= 355// INTERNAL 356// ============================================================================= 357 358function dispatchLocal(data) { 359 const threadId = data?.threadId ?? data?.ThreadId; 360 if (!threadId) return; 361 const callbacks = localSubscriptions.get(threadId); 362 if (!callbacks) return; 363 for (const cb of callbacks) { 364 try { cb(data); } catch (e) { 365 console.error('[TaskGroupSync] Subscriber callback failed:', e); 366 } 367 } 368} 369 370function dispatchAndRelay(data) { 371 dispatchLocal(data); 372 // Relay to other tabs (they aren't on the connection) 373 CrossTabSync.broadcast('taskgroup:event', data); 374} 375 376function dispatchMarkupLocal(payload) { 377 const partitionKey = payload?.partitionKey; 378 const data = payload?.data; 379 const projectId = Number(data?.projectId ?? data?.ProjectId); 380 if (!partitionKey || !projectId || partitionKey !== currentPartitionKey()) return; 381 const callbacks = localMarkupSubscriptions.get(markupInterestKey(partitionKey, projectId)); 382 if (!callbacks) return; 383 for (const cb of callbacks) { 384 try { cb(data); } catch (e) { 385 console.error('[TaskGroupSync] Markup processing subscriber failed:', e); 386 } 387 } 388} 389
390function dispatchMarkupAndRelay(data) { 391 const partitionKey = currentPartitionKey(); 392 if (!partitionKey) return; 393 const payload = { partitionKey, data }; 394 dispatchMarkupLocal(payload); 395 CrossTabSync.broadcast('markup-processing-event', payload); 396} 397 398function dispatchMarkupRegisterLocal(payload) { 399 const partitionKey = payload?.partitionKey; 400 const data = payload?.data; 401 const projectId = Number(data?.projectId ?? data?.ProjectId); 402 if (!partitionKey || !projectId || partitionKey !== currentPartitionKey()) return; 403 const callbacks = localMarkupRegisterSubscriptions.get(markupInterestKey(partitionKey, projectId)); 404 if (!callbacks) return; 405 for (const cb of callbacks) { 406 try { cb(data); } catch (e) { console.error('[TaskGroupSync] Markup register subscriber failed:', e); } 407 } 408} 409 410function dispatchMarkupRegisterAndRelay(data) { 411 const partitionKey = currentPartitionKey(); 412 if (!partitionKey) return; 413 const payload = { partitionKey, data }; 414 dispatchMarkupRegisterLocal(payload); 415 CrossTabSync.broadcast('markup-register-event', payload); 416} 417 418function dispatchActivityLocal(payload) { 419 const partitionKey = payload?.partitionKey; 420 const data = payload?.data; 421 const projectId = positiveSafeInteger(data?.projectId ?? data?.ProjectId); 422 if (!partitionKey || !projectId || partitionKey !== currentPartitionKey()) return; 423 const subscriptions = localActivitySubscriptions.get(activityInterestKey(partitionKey, projectId)); 424 if (!subscriptions) return; 425 for (const record of subscriptions.values()) { 426 if (!isCurrentActivitySubscription(record)) continue; 427 try { record.callback(data); } catch (error) { 428 console.error('[TaskGroupSync] Project Activity subscriber failed:', error); 429 } 430 } 431} 432 433function dispatchActivityAndRelay(data) { 434 if (!isLeaderAndConnected()) return; 435 const partitionKey = currentPartitionKey(); 436 const projectId = positiveSafeInteger(data?.projectId ?? data?.ProjectId); 437 if (!partitionKey || !projectId) return; 438 const payload = { partitionKey, data }; 439 dispatchActivityLocal(payload); 440 CrossTabSync.broadcast('project-activity-event', payload); 441} 442 443function isLeaderAndConnected() { 444 return CrossTabSync.isThisTabLeader() && isConnected(); 445} 446 447function joinIfLeader(threadId) { 448 if (!isLeaderAndConnected()) return; 449 getNotificationHubClient().joinTaskGroup(threadId); 450} 451 452async function joinMarkupIfLeader(interest) { 453 if (!isLeaderAndConnected() || interest.partitionKey !== currentPartitionKey()) return; 454 const key = markupInterestKey(interest.partitionKey, interest.projectId); 455 const attempt = nextMarkupJoinAttempt(); 456 markupJoinAttempts.set(key, attempt); 457 const joined = await getNotificationHubClient().joinMarkupProcessingGroup(interest.projectId); 458 if (!isLeaderAndConnected() 459 || interest.partitionKey !== currentPartitionKey() 460 || markupJoinAttempts.get(key) !== attempt 461 || !isMarkupWanted(interest)) return; 462 if (joined === false) { 463 const payload = { 464 partitionKey: interest.partitionKey, 465 data: { projectId: interest.projectId, unavailable: true } 466 }; 467 dispatchMarkupLocal(payload); 468 CrossTabSync.broadcast('markup-processing-event', payload); 469 } 470} 471 472function joinActivityIfLeader(interest) { 473 const numericProjectId = positiveSafeInteger(interest?.projectId); 474 if (!numericProjectId || !isLeaderAndConnected() || interest.partitionKey !== currentPartitionKey()) { 475 return Promise.resolve(false); 476 } 477 const normalized = { partitionKey: interest.partitionKey, projectId: numericProjectId }; 478 const key = activityInterestKey(normalized.partitionKey, normalized.projectId); 479 return queueActivityTransition(key, async () => { 480 if (!isLeaderAndConnected() 481 || normalized.partitionKey !== currentPartitionKey() 482 || !isActivityWanted(normalized)) return false; 483 484 const admissionToken = currentActivityAdmissionToken(); 485 if (activityGroupStates.get(key)?.admissionToken === admissionToken) { 486 try { 487 await getNotificationHubClient().invoke('RenewProjectActivityGroup', normalized.projectId); 488 publishActivityReady(normalized, admissionToken); 489 return true; 490 } catch (error) { 491 activityGroupStates.delete(key); 492 console.debug('[TaskGroupSync] Could not renew Project Activity group:', error); 493 return false;
494 } 495 } 496 497 const attempt = ++activityJoinAttemptSequence; 498 activityJoinAttempts.set(key, attempt); 499 let joined = false; 500 try { 501 await getNotificationHubClient().invoke('JoinProjectActivityGroup', normalized.projectId); 502 joined = true; 503 } catch (error) { 504 console.debug('[TaskGroupSync] Could not join Project Activity group:', error); 505 } 506 if (!joined 507 || !isLeaderAndConnected() 508 || normalized.partitionKey !== currentPartitionKey() 509 || currentActivityAdmissionToken() !== admissionToken 510 || activityJoinAttempts.get(key) !== attempt 511 || !isActivityWanted(normalized)) return false; 512 513 activityGroupStates.set(key, { admissionToken }); 514 publishActivityReady(normalized, admissionToken); 515 return true; 516 }); 517} 518 519function leaveActivityIfUnwanted(interest) { 520 const normalized = { 521 partitionKey: interest.partitionKey, 522 projectId: positiveSafeInteger(interest.projectId), 523 }; 524 if (!normalized.projectId || isActivityWanted(normalized)) return; 525 const key = activityInterestKey(normalized.partitionKey, normalized.projectId); 526 activityJoinAttempts.delete(key); 527 activityGroupStates.delete(key); 528 void queueActivityTransition(key, async () => { 529 if (isActivityWanted(normalized) 530 || !isLeaderAndConnected() 531 || normalized.partitionKey !== currentPartitionKey()) return false; 532 try { 533 await getNotificationHubClient().invoke('LeaveProjectActivityGroup', normalized.projectId); 534 } catch (error) { 535 console.debug('[TaskGroupSync] Could not leave Project Activity group:', error); 536 return false; 537 } 538 if (isActivityWanted(normalized)) void joinActivityIfLeader(normalized); 539 return true; 540 }); 541} 542 543function queueActivityTransition(key, work) { 544 const previous = activityTransitions.get(key) || Promise.resolve(); 545 const pending = previous.then(work, work); 546 const settled = pending.then(() => undefined, () => undefined); 547 activityTransitions.set(key, settled); 548 settled.finally(() => { 549 if (activityTransitions.get(key) === settled) activityTransitions.delete(key); 550 }); 551 return pending; 552} 553 554function isActivityWanted(interest) { 555 const key = activityInterestKey(interest.partitionKey, interest.projectId); 556 return localActivitySubscriptions.get(key)?.size > 0 557 || remoteActivityInterests.get(key)?.subscriptions.size > 0; 558} 559 560function publishActivityReady(interest, admissionToken) { 561 const key = activityInterestKey(interest.partitionKey, interest.projectId); 562 for (const record of localActivitySubscriptions.get(key)?.values() || []) { 563 admitLocalActivityReady(record, admissionToken); 564 } 565 for (const [subscriptionId, remote] of remoteActivityInterests.get(key)?.subscriptions || []) { 566 CrossTabSync.broadcast('project-activity-ready', { 567 partitionKey: interest.partitionKey, 568 projectId: interest.projectId, 569 tabId: remote.tabId, 570 subscriptionId, 571 admissionToken, 572 }); 573 } 574} 575 576function receiveActivityReady(payload) { 577 const { partitionKey, projectId, tabId, subscriptionId, admissionToken } = payload || {}; 578 const numericProjectId = positiveSafeInteger(projectId); 579 if (!partitionKey || !numericProjectId || !subscriptionId || !admissionToken 580 || tabId !== CrossTabSync.getTabId() || partitionKey !== currentPartitionKey()) return; 581 const record = localActivitySubscriptions 582 .get(activityInterestKey(partitionKey, numericProjectId)) 583 ?.get(subscriptionId); 584 if (record) admitLocalActivityReady(record, admissionToken); 585} 586 587function admitLocalActivityReady(record, admissionToken) { 588 if (!isCurrentActivitySubscription(record) || record.lastReadyToken === admissionToken) return; 589 record.lastReadyToken = admissionToken; 590 try { record.onReady?.({ projectId: record.projectId, admissionToken }); } catch (error) { 591 console.error('[TaskGroupSync] Project Activity ready callback failed:', error); 592 } 593} 594 595function isCurrentActivitySubscription(record) { 596 if (record.signal?.aborted || record.partitionKey !== currentPartitionKey()) return false;
597 return localActivitySubscriptions 598 .get(activityInterestKey(record.partitionKey, record.projectId)) 599 ?.get(record.subscriptionId) === record; 600} 601 602function invalidateActivityConnection() { 603 activityConnectionGeneration += 1; 604 activityGroupStates.clear(); 605 activityJoinAttempts.clear(); 606} 607 608function currentActivityAdmissionToken() { 609 return `${CrossTabSync.getTabId()}:${activityConnectionGeneration}`; 610} 611 612function leaveMarkupIfUnwanted(interest) { 613 const key = markupInterestKey(interest.partitionKey, interest.projectId); 614 if (isMarkupWanted(interest)) return; 615 markupJoinAttempts.delete(key); 616 if (!isLeaderAndConnected() || interest.partitionKey !== currentPartitionKey()) return; 617 getNotificationHubClient().leaveMarkupProcessingGroup(interest.projectId); 618} 619 620function isMarkupWanted(interest) { 621 const key = markupInterestKey(interest.partitionKey, interest.projectId); 622 return localMarkupSubscriptions.has(key) 623 || localMarkupRegisterSubscriptions.has(key) 624 || remoteMarkupInterests.get(key)?.tabs.size > 0; 625} 626 627function nextMarkupJoinAttempt() { 628 markupJoinAttemptSequence += 1; 629 return markupJoinAttemptSequence; 630} 631
632function leaveIfUnwanted(threadId) { 633 if (!isLeaderAndConnected()) return; 634 // Still wanted by this tab or any live remote tab? Keep the membership. 635 if (localSubscriptions.has(threadId)) return; 636 if (remoteInterests.get(threadId)?.size > 0) return; 637 getNotificationHubClient().leaveTaskGroup(threadId); 638} 639 640function rejoinAll() { 641 if (!isLeaderAndConnected()) return; 642 const wanted = new Set([...localSubscriptions.keys(), ...remoteInterests.keys()]); 643 for (const threadId of wanted) { 644 getNotificationHubClient().joinTaskGroup(threadId); 645 } 646 for (const key of new Set([...localMarkupSubscriptions.keys(), ...localMarkupRegisterSubscriptions.keys(), ...remoteMarkupInterests.keys()])) { 647 const interest = remoteMarkupInterests.get(key) || parseMarkupInterestKey(key); 648 if (interest) joinMarkupIfLeader(interest); 649 } 650 for (const key of new Set([...localActivitySubscriptions.keys(), ...remoteActivityInterests.keys()])) { 651 const interest = remoteActivityInterests.get(key) || parseActivityInterestKey(key); 652 if (interest) void joinActivityIfLeader(interest); 653 } 654} 655 656function broadcastInterests() { 657 const tabId = CrossTabSync.getTabId(); 658 for (const threadId of localSubscriptions.keys()) { 659 CrossTabSync.broadcast('taskgroup:join', { threadId, tabId }); 660 } 661 for (const key of localMarkupSubscriptions.keys()) { 662 const interest = parseMarkupInterestKey(key); 663 if (interest) { 664 broadcastMarkupInterest('join', interest); 665 joinMarkupIfLeader(interest); 666 } 667 } 668 for (const key of localMarkupRegisterSubscriptions.keys()) { 669 if (localMarkupSubscriptions.has(key)) continue; 670 const interest = parseMarkupInterestKey(key); 671 if (interest) { 672 broadcastMarkupInterest('join', interest); 673 joinMarkupIfLeader(interest); 674 } 675 } 676 for (const subscriptions of localActivitySubscriptions.values()) { 677 for (const record of subscriptions.values()) { 678 broadcastActivityInterest('join', record); 679 void joinActivityIfLeader(record); 680 } 681 } 682} 683 684function pruneRemoteInterests() { 685 const cutoff = Date.now() - REMOTE_TTL_MS; 686 for (const [threadId, tabs] of remoteInterests) { 687 for (const [tabId, lastSeen] of tabs) { 688 if (lastSeen < cutoff) tabs.delete(tabId); 689 } 690 if (tabs.size === 0) { 691 remoteInterests.delete(threadId);
692 leaveIfUnwanted(threadId); 693 } 694 } 695 for (const [key, interest] of remoteMarkupInterests) { 696 for (const [tabId, lastSeen] of interest.tabs) { 697 if (lastSeen < cutoff) interest.tabs.delete(tabId); 698 } 699 if (interest.tabs.size === 0) { 700 remoteMarkupInterests.delete(key); 701 leaveMarkupIfUnwanted(interest); 702 } 703 } 704 for (const [key, interest] of remoteActivityInterests) { 705 for (const [subscriptionId, remote] of interest.subscriptions) { 706 if (remote.lastSeen < cutoff) interest.subscriptions.delete(subscriptionId); 707 } 708 if (interest.subscriptions.size === 0) { 709 remoteActivityInterests.delete(key); 710 leaveActivityIfUnwanted(interest); 711 } 712 } 713} 714 715function currentPartitionKey() { 716 return offlineStore.getActivePartition()?.key || null; 717} 718 719function markupInterestKey(partitionKey, projectId) { 720 return `${partitionKey}|${Number(projectId)}`; 721} 722 723function parseMarkupInterestKey(key) { 724 const splitAt = key.lastIndexOf('|'); 725 if (splitAt <= 0) return null; 726 const partitionKey = key.slice(0, splitAt); 727 const projectId = Number(key.slice(splitAt + 1)); 728 return projectId ? { partitionKey, projectId } : null; 729} 730 731function broadcastMarkupInterest(action, interest) { 732 CrossTabSync.broadcast('markup-processing-interest', { 733 action, 734 partitionKey: interest.partitionKey, 735 projectId: interest.projectId, 736 tabId: CrossTabSync.getTabId() 737 }); 738} 739 740function activityInterestKey(partitionKey, projectId) { 741 return `${partitionKey}|${Number(projectId)}`; 742} 743 744function parseActivityInterestKey(key) { 745 const splitAt = key.lastIndexOf('|'); 746 if (splitAt <= 0) return null; 747 const projectId = positiveSafeInteger(key.slice(splitAt + 1)); 748 return projectId ? { partitionKey: key.slice(0, splitAt), projectId } : null; 749} 750 751function positiveSafeInteger(value) { 752 const number = Number(value); 753 return Number.isSafeInteger(number) && number > 0 ? number : null; 754} 755 756function broadcastActivityInterest(action, record) { 757 CrossTabSync.broadcast('project-activity-interest', { 758 action, 759 partitionKey: record.partitionKey, 760 projectId: record.projectId, 761 tabId: CrossTabSync.getTabId(), 762 subscriptionId: record.subscriptionId, 763 }); 764} 765 766/** 767 * Tear down (logout). Page modules use their own dispose() â this is for app teardown. 768 */ 769export function destroyTaskGroupSync() { 770 pubsubSubscriptions.forEach(unsub => unsub()); 771 pubsubSubscriptions = []; 772 if (keepaliveTimer) { clearInterval(keepaliveTimer); keepaliveTimer = null; } 773 localSubscriptions.clear(); 774 remoteInterests.clear(); 775 localMarkupSubscriptions.clear(); 776 localMarkupRegisterSubscriptions.clear(); 777 remoteMarkupInterests.clear(); 778 markupJoinAttempts.clear(); 779 localActivitySubscriptions.clear(); 780 remoteActivityInterests.clear(); 781 activityGroupStates.clear(); 782 activityJoinAttempts.clear(); 783 activityTransitions.clear(); 784 activityConnectionGeneration += 1; 785 initialized = false; 786} 787 788export default { initTaskGroupSync, subscribeTaskUpdates, subscribeMarkupProcessingUpdates, subscribeMarkupRegisterUpdates, subscribeProjectActivityUpdates, destroyTaskGroupSync };
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.