anear-js-api 1.2.0 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/api/ApiService.js +1 -1
- package/lib/models/AnearEvent.js +8 -0
- package/lib/state_machines/AnearEventMachine.js +325 -17
- package/lib/state_machines/AnearParticipantMachine.js +87 -30
- package/lib/utils/AppMachineTransition.js +25 -2
- package/lib/utils/DisplayEventProcessor.js +2 -2
- package/lib/utils/PugHelpers.js +75 -0
- package/package.json +1 -1
- package/tests/JsonApiArrayResource.test.js +1 -1
package/lib/api/ApiService.js
CHANGED
|
@@ -101,7 +101,7 @@ class ApiService {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
prepareGetRequest(resource, params={}) {
|
|
104
|
-
const urlString = this.idParamUrlString(resource, params)
|
|
104
|
+
const urlString = this.idParamUrlString(resource, params)
|
|
105
105
|
return new fetch.Request(
|
|
106
106
|
urlString, {
|
|
107
107
|
method: 'GET',
|
package/lib/models/AnearEvent.js
CHANGED
|
@@ -97,6 +97,14 @@ class AnearEvent extends JsonApiResource {
|
|
|
97
97
|
this.send({ type: "BOOT_PARTICIPANT", data: { participantId, reason } })
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
cancelAllParticipantTimeouts() {
|
|
101
|
+
// Cancel all active participant-level timeouts. This sends CANCEL_TIMEOUT
|
|
102
|
+
// to all APMs, which will benignly clear any active timeout timers.
|
|
103
|
+
// Useful for scenarios where a game event (e.g., LIAR call) should immediately
|
|
104
|
+
// cancel all individual participant timeouts.
|
|
105
|
+
this.send({ type: "CANCEL_ALL_PARTICIPANT_TIMEOUTS" })
|
|
106
|
+
}
|
|
107
|
+
|
|
100
108
|
render(viewPath, displayType, appContext, event, timeout = null, props = {}) {
|
|
101
109
|
// Explicit render method for guaranteed rendering control
|
|
102
110
|
// This complements the meta: {} approach for when you need explicit control
|
|
@@ -162,7 +162,11 @@ const AnearEventMachineContext = (
|
|
|
162
162
|
spectatorsDisplayChannel: null, // display all spectators
|
|
163
163
|
participants: {},
|
|
164
164
|
participantMachines: {},
|
|
165
|
-
participantsActionTimeout: null
|
|
165
|
+
participantsActionTimeout: null,
|
|
166
|
+
suppressParticipantTimeouts: false, // Flag to suppress PARTICIPANT_TIMEOUT events (cleared when new timers are set up via render)
|
|
167
|
+
eachParticipantCancelActionType: null, // ACTION type that triggers cancellation for eachParticipant timeouts
|
|
168
|
+
pendingCancelConfirmations: null, // Set of participantIds waiting for TIMER_CANCELED confirmation
|
|
169
|
+
pendingCancelAction: null // ACTION to forward after cancellation completes: { participantId, appEventName, payload }
|
|
166
170
|
})
|
|
167
171
|
|
|
168
172
|
const ActiveEventGlobalEvents = {
|
|
@@ -378,6 +382,7 @@ const ActiveEventStatesConfig = {
|
|
|
378
382
|
input: ({ context, event }) => ({ context, event }),
|
|
379
383
|
onDone: {
|
|
380
384
|
target: 'notifyingPausedRenderComplete',
|
|
385
|
+
actions: ['clearParticipantTimeoutSuppression']
|
|
381
386
|
// v5 note: internal transitions are the default (v4 had `internal: true`)
|
|
382
387
|
},
|
|
383
388
|
onError: {
|
|
@@ -416,6 +421,7 @@ const ActiveEventStatesConfig = {
|
|
|
416
421
|
input: ({ context, event }) => ({ context, event }),
|
|
417
422
|
onDone: {
|
|
418
423
|
target: 'notifyingRenderComplete',
|
|
424
|
+
actions: ['clearParticipantTimeoutSuppression']
|
|
419
425
|
// v5 note: internal transitions are the default (v4 had `internal: true`)
|
|
420
426
|
},
|
|
421
427
|
onError: {
|
|
@@ -484,6 +490,9 @@ const ActiveEventStatesConfig = {
|
|
|
484
490
|
BOOT_PARTICIPANT: {
|
|
485
491
|
actions: 'sendBootEventToParticipantMachine'
|
|
486
492
|
},
|
|
493
|
+
CANCEL_ALL_PARTICIPANT_TIMEOUTS: {
|
|
494
|
+
actions: 'cancelAllParticipantTimeouts'
|
|
495
|
+
},
|
|
487
496
|
SPECTATOR_ENTER: {
|
|
488
497
|
actions: 'sendSpectatorEnterToAppEventMachine'
|
|
489
498
|
}
|
|
@@ -527,6 +536,7 @@ const ActiveEventStatesConfig = {
|
|
|
527
536
|
input: ({ context, event }) => ({ context, event }),
|
|
528
537
|
onDone: {
|
|
529
538
|
target: 'notifyingRenderComplete',
|
|
539
|
+
actions: ['clearParticipantTimeoutSuppression']
|
|
530
540
|
// v5 note: internal transitions are the default (v4 had `internal: true`)
|
|
531
541
|
},
|
|
532
542
|
onError: {
|
|
@@ -616,6 +626,9 @@ const ActiveEventStatesConfig = {
|
|
|
616
626
|
},
|
|
617
627
|
BOOT_PARTICIPANT: {
|
|
618
628
|
actions: 'sendBootEventToParticipantMachine'
|
|
629
|
+
},
|
|
630
|
+
CANCEL_ALL_PARTICIPANT_TIMEOUTS: {
|
|
631
|
+
actions: 'cancelAllParticipantTimeouts'
|
|
619
632
|
}
|
|
620
633
|
},
|
|
621
634
|
states: {
|
|
@@ -634,6 +647,34 @@ const ActiveEventStatesConfig = {
|
|
|
634
647
|
waitingForActions: {
|
|
635
648
|
id: 'waitingForActions',
|
|
636
649
|
entry: () => logger.debug('[AEM] live state...waiting for actions'),
|
|
650
|
+
initial: 'idle',
|
|
651
|
+
states: {
|
|
652
|
+
idle: {},
|
|
653
|
+
waitingForCancelConfirmations: {
|
|
654
|
+
entry: ['sendCancelTimeoutToAllAPMs', 'setupPendingCancelConfirmations'],
|
|
655
|
+
on: {
|
|
656
|
+
TIMER_CANCELED: {
|
|
657
|
+
actions: ['removeFromPendingCancelConfirmations']
|
|
658
|
+
},
|
|
659
|
+
PARTICIPANT_EXIT: {
|
|
660
|
+
actions: ['removeFromPendingCancelConfirmations']
|
|
661
|
+
},
|
|
662
|
+
// Drop only ACTION and PARTICIPANT_TIMEOUT events during cancellation wait
|
|
663
|
+
// All other events (presence events, etc.) will bubble up to parent handlers
|
|
664
|
+
ACTION: {
|
|
665
|
+
actions: ['dropEventDuringCancellation']
|
|
666
|
+
},
|
|
667
|
+
PARTICIPANT_TIMEOUT: {
|
|
668
|
+
actions: ['dropEventDuringCancellation']
|
|
669
|
+
}
|
|
670
|
+
},
|
|
671
|
+
always: {
|
|
672
|
+
guard: 'allCancelConfirmationsReceived',
|
|
673
|
+
actions: ['forwardPendingCancelAction', 'clearCancelState'],
|
|
674
|
+
target: '#waitingForActions.idle'
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
},
|
|
637
678
|
on: {
|
|
638
679
|
ACTION: {
|
|
639
680
|
actions: ['processParticipantAction']
|
|
@@ -642,6 +683,13 @@ const ActiveEventStatesConfig = {
|
|
|
642
683
|
PARTICIPANT_TIMEOUT: {
|
|
643
684
|
actions: ['processParticipantTimeout'],
|
|
644
685
|
// v5 note: internal transitions are the default (v4 had `internal: true`)
|
|
686
|
+
},
|
|
687
|
+
TRIGGER_CANCEL_SEQUENCE: {
|
|
688
|
+
target: '.waitingForCancelConfirmations'
|
|
689
|
+
},
|
|
690
|
+
TIMER_CANCELED: {
|
|
691
|
+
// Ignore if not in cancellation state (defensive)
|
|
692
|
+
actions: []
|
|
645
693
|
}
|
|
646
694
|
}
|
|
647
695
|
},
|
|
@@ -654,10 +702,11 @@ const ActiveEventStatesConfig = {
|
|
|
654
702
|
{
|
|
655
703
|
guard: 'isParticipantsTimeoutActive',
|
|
656
704
|
target: 'notifyingRenderCompleteWithTimeout',
|
|
657
|
-
actions: 'setupParticipantsTimeout'
|
|
705
|
+
actions: ['setupParticipantsTimeout', 'setupCancelActionType', 'clearParticipantTimeoutSuppression']
|
|
658
706
|
},
|
|
659
707
|
{
|
|
660
708
|
target: 'notifyingRenderComplete',
|
|
709
|
+
actions: ['setupCancelActionType', 'clearParticipantTimeoutSuppression']
|
|
661
710
|
// v5 note: internal transitions are the default (v4 had `internal: true`)
|
|
662
711
|
}
|
|
663
712
|
],
|
|
@@ -693,13 +742,70 @@ const ActiveEventStatesConfig = {
|
|
|
693
742
|
target: 'handleParticipantsTimeout'
|
|
694
743
|
}
|
|
695
744
|
},
|
|
745
|
+
initial: 'waiting',
|
|
746
|
+
states: {
|
|
747
|
+
waiting: {
|
|
748
|
+
// The actual waiting state. Timer continues running when we transition to .rendering.
|
|
749
|
+
},
|
|
750
|
+
rendering: {
|
|
751
|
+
invoke: {
|
|
752
|
+
src: 'renderDisplay',
|
|
753
|
+
input: ({ context, event }) => ({ context, event }),
|
|
754
|
+
onDone: [
|
|
755
|
+
{
|
|
756
|
+
guard: 'isParticipantsTimeoutActive',
|
|
757
|
+
target: 'notifyingRenderComplete',
|
|
758
|
+
actions: ['setupCancelActionType', 'clearParticipantTimeoutSuppression']
|
|
759
|
+
// Note: We do NOT call setupParticipantsTimeout here because we're already in waitAllParticipantsResponse
|
|
760
|
+
// and the timer is still running from the parent state (preserved by nested state pattern).
|
|
761
|
+
},
|
|
762
|
+
{
|
|
763
|
+
target: '#waitingForActions',
|
|
764
|
+
actions: ['setupCancelActionType', 'clearParticipantTimeoutSuppression', 'notifyAppMachineRendered']
|
|
765
|
+
}
|
|
766
|
+
],
|
|
767
|
+
onError: {
|
|
768
|
+
target: '#activeEvent.failure'
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
},
|
|
772
|
+
notifyingRenderComplete: {
|
|
773
|
+
after: {
|
|
774
|
+
timeoutRendered: {
|
|
775
|
+
// Target sibling state (stays within parent, timer continues)
|
|
776
|
+
target: 'waiting',
|
|
777
|
+
actions: ['notifyAppMachineRendered']
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
},
|
|
696
782
|
on: {
|
|
697
|
-
ACTION:
|
|
698
|
-
|
|
699
|
-
|
|
783
|
+
ACTION: [
|
|
784
|
+
{
|
|
785
|
+
// Check if this is a cancel ACTION for allParticipants timeout
|
|
786
|
+
guard: ({ context, event }) => {
|
|
787
|
+
const { cancelActionType } = context.participantsActionTimeout || {}
|
|
788
|
+
if (!cancelActionType) return false
|
|
789
|
+
const eventMessagePayload = JSON.parse(event.data.payload)
|
|
790
|
+
const [appEventName] = Object.entries(eventMessagePayload)[0]
|
|
791
|
+
return cancelActionType === appEventName
|
|
792
|
+
},
|
|
793
|
+
actions: ['storeCancelActionForAllParticipants'],
|
|
794
|
+
target: 'handleParticipantsTimeoutCanceled'
|
|
795
|
+
},
|
|
796
|
+
{
|
|
797
|
+
actions: ['processAndForwardAction', 'processParticipantResponse'],
|
|
798
|
+
// v5 note: internal transitions are the default (v4 had `internal: true`)
|
|
799
|
+
}
|
|
800
|
+
],
|
|
801
|
+
CANCEL_ALL_PARTICIPANT_TIMEOUTS: {
|
|
802
|
+
actions: ['cancelAllParticipantTimeouts'],
|
|
803
|
+
target: 'handleParticipantsTimeoutCanceled'
|
|
700
804
|
},
|
|
701
805
|
RENDER_DISPLAY: {
|
|
702
|
-
|
|
806
|
+
// Re-render without exiting waitAllParticipantsResponse.
|
|
807
|
+
// This preserves the parent's after: timer because we stay within the parent state.
|
|
808
|
+
target: '.rendering'
|
|
703
809
|
},
|
|
704
810
|
PARTICIPANT_LEAVE: [
|
|
705
811
|
{
|
|
@@ -720,6 +826,10 @@ const ActiveEventStatesConfig = {
|
|
|
720
826
|
entry: ['sendActionsTimeoutToAppM', 'clearParticipantsTimeout'],
|
|
721
827
|
always: 'waitingForActions'
|
|
722
828
|
},
|
|
829
|
+
handleParticipantsTimeoutCanceled: {
|
|
830
|
+
entry: ['sendActionsTimeoutToAppMCanceled', 'clearParticipantsTimeout', 'forwardPendingCancelActionIfExists'],
|
|
831
|
+
always: 'waitingForActions'
|
|
832
|
+
},
|
|
723
833
|
participantEntering: {
|
|
724
834
|
// a PARTICIPANT_ENTER received from a new user JOIN click. Unless already exists,
|
|
725
835
|
// create an AnearParticipantMachine instance.
|
|
@@ -1591,6 +1701,114 @@ const AnearEventMachineFunctions = ({
|
|
|
1591
1701
|
sendParticipantExitEvents: ({ context }) => {
|
|
1592
1702
|
Object.values(context.participantMachines).forEach(pm => pm.send({ type: 'PARTICIPANT_EXIT' }))
|
|
1593
1703
|
},
|
|
1704
|
+
cancelAllParticipantTimeouts: assign(({ context }) => {
|
|
1705
|
+
// Send CANCEL_TIMEOUT to all participant machines to cancel any active
|
|
1706
|
+
// participant-level timeouts. This is useful when a game event (e.g., LIAR call)
|
|
1707
|
+
// should immediately cancel all individual participant timeouts.
|
|
1708
|
+
// APMs that are not in waitParticipantResponse state will benignly ignore this event.
|
|
1709
|
+
logger.info(`[AEM] Cancelling all participant timeouts (manual) for ${Object.keys(context.participantMachines).length} participants`)
|
|
1710
|
+
Object.values(context.participantMachines).forEach(pm => pm.send({ type: 'CANCEL_TIMEOUT' }))
|
|
1711
|
+
|
|
1712
|
+
// Note: If we're in waitAllParticipantsResponse state, the nested state's handler
|
|
1713
|
+
// will also handle this event and cancel the allParticipants timeout.
|
|
1714
|
+
|
|
1715
|
+
// Set suppression flag to ignore stale PARTICIPANT_TIMEOUT events from cancelled timers.
|
|
1716
|
+
// This flag will be cleared when new timers are set up (via render completion).
|
|
1717
|
+
return {
|
|
1718
|
+
suppressParticipantTimeouts: true
|
|
1719
|
+
}
|
|
1720
|
+
}),
|
|
1721
|
+
sendCancelTimeoutToAllAPMs: ({ context }) => {
|
|
1722
|
+
const participantCount = Object.keys(context.participantMachines).length
|
|
1723
|
+
logger.info(`[AEM] Sending CANCEL_TIMEOUT to all ${participantCount} APMs for declarative cancel ACTION`)
|
|
1724
|
+
Object.values(context.participantMachines).forEach(pm => {
|
|
1725
|
+
pm.send({ type: 'CANCEL_TIMEOUT' })
|
|
1726
|
+
})
|
|
1727
|
+
},
|
|
1728
|
+
setupPendingCancelConfirmations: assign(({ context, event }) => {
|
|
1729
|
+
// Get all active participants with timers (participants in waitParticipantResponse state)
|
|
1730
|
+
const activeParticipantIds = getActiveParticipantIds(context)
|
|
1731
|
+
const pendingConfirmations = new Set(activeParticipantIds)
|
|
1732
|
+
|
|
1733
|
+
logger.info(`[AEM] Starting cancellation sequence for ACTION ${event.data.appEventName}. Waiting for ${pendingConfirmations.size} APM confirmations: ${[...pendingConfirmations].join(', ')}`)
|
|
1734
|
+
|
|
1735
|
+
return {
|
|
1736
|
+
pendingCancelConfirmations: pendingConfirmations,
|
|
1737
|
+
pendingCancelAction: {
|
|
1738
|
+
participantId: event.data.participantId,
|
|
1739
|
+
appEventName: event.data.appEventName,
|
|
1740
|
+
payload: event.data.payload
|
|
1741
|
+
},
|
|
1742
|
+
suppressParticipantTimeouts: true // Drop PARTICIPANT_TIMEOUT events
|
|
1743
|
+
}
|
|
1744
|
+
}),
|
|
1745
|
+
removeFromPendingCancelConfirmations: assign(({ context, event }) => {
|
|
1746
|
+
const participantId = event.participantId || event.data?.id
|
|
1747
|
+
if (!context.pendingCancelConfirmations || !participantId) return {}
|
|
1748
|
+
|
|
1749
|
+
const newPending = new Set(context.pendingCancelConfirmations)
|
|
1750
|
+
newPending.delete(participantId)
|
|
1751
|
+
const remaining = newPending.size
|
|
1752
|
+
|
|
1753
|
+
logger.debug(`[AEM] TIMER_CANCELED received from ${participantId}. ${remaining} confirmations remaining.`)
|
|
1754
|
+
|
|
1755
|
+
return {
|
|
1756
|
+
pendingCancelConfirmations: newPending
|
|
1757
|
+
}
|
|
1758
|
+
}),
|
|
1759
|
+
forwardPendingCancelAction: ({ context }) => {
|
|
1760
|
+
if (!context.pendingCancelAction) {
|
|
1761
|
+
logger.warn(`[AEM] forwardPendingCancelAction called but pendingCancelAction is null`)
|
|
1762
|
+
return
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
const { participantId, appEventName, payload } = context.pendingCancelAction
|
|
1766
|
+
|
|
1767
|
+
const actionEventPayload = {
|
|
1768
|
+
participantId,
|
|
1769
|
+
payload
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
logger.info(`[AEM] All cancellation confirmations received. Forwarding cancel ACTION ${appEventName} from ${participantId} to AppM and APM`)
|
|
1773
|
+
|
|
1774
|
+
// Forward to AppM
|
|
1775
|
+
context.appEventMachine.send({ type: appEventName, ...actionEventPayload })
|
|
1776
|
+
|
|
1777
|
+
// Forward to APM
|
|
1778
|
+
const participantMachine = context.participantMachines[participantId]
|
|
1779
|
+
if (participantMachine) {
|
|
1780
|
+
participantMachine.send({ type: 'ACTION', ...actionEventPayload })
|
|
1781
|
+
} else {
|
|
1782
|
+
logger.warn(`[AEM] Participant machine not found for ${participantId} when forwarding cancel ACTION`)
|
|
1783
|
+
}
|
|
1784
|
+
},
|
|
1785
|
+
clearCancelState: assign({
|
|
1786
|
+
pendingCancelConfirmations: null,
|
|
1787
|
+
pendingCancelAction: null,
|
|
1788
|
+
suppressParticipantTimeouts: false
|
|
1789
|
+
}),
|
|
1790
|
+
dropEventDuringCancellation: ({ context, event }) => {
|
|
1791
|
+
// Drop/ignore ACTION and PARTICIPANT_TIMEOUT events received during cancellation wait
|
|
1792
|
+
// All other events (presence events, etc.) are not handled here and will bubble up to parent handlers
|
|
1793
|
+
logger.debug(`[AEM] Dropping ${event.type} event during cancellation wait`)
|
|
1794
|
+
},
|
|
1795
|
+
clearParticipantTimeoutSuppression: assign({
|
|
1796
|
+
suppressParticipantTimeouts: false
|
|
1797
|
+
}),
|
|
1798
|
+
setupCancelActionType: assign(({ context, event }) => {
|
|
1799
|
+
const cancelActionType =
|
|
1800
|
+
(event && event.output && event.output.cancelActionType) ||
|
|
1801
|
+
(event && event.data && event.data.cancelActionType) ||
|
|
1802
|
+
null
|
|
1803
|
+
|
|
1804
|
+
if (cancelActionType) {
|
|
1805
|
+
logger.debug(`[AEM] Setting up cancel action type for eachParticipant timeout: ${cancelActionType}`)
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
return {
|
|
1809
|
+
eachParticipantCancelActionType: cancelActionType
|
|
1810
|
+
}
|
|
1811
|
+
}),
|
|
1594
1812
|
updateParticipantPresence: ({ context, event }) => {
|
|
1595
1813
|
const participantId = event.data.id;
|
|
1596
1814
|
const participant = context.participants[participantId];
|
|
@@ -1613,7 +1831,7 @@ const AnearEventMachineFunctions = ({
|
|
|
1613
1831
|
const eventName = getPresenceEventName(participant, 'UPDATE');
|
|
1614
1832
|
context.appEventMachine.send({ type: eventName, data: event.data });
|
|
1615
1833
|
},
|
|
1616
|
-
processParticipantAction: ({ context, event }) => {
|
|
1834
|
+
processParticipantAction: ({ context, event, self }) => {
|
|
1617
1835
|
// event.data.participantId,
|
|
1618
1836
|
// event.data.payload: {"appEventMachineACTION": {action event keys and values}}
|
|
1619
1837
|
// e.g. {"MOVE":{"x":1, "y":2}}
|
|
@@ -1623,7 +1841,25 @@ const AnearEventMachineFunctions = ({
|
|
|
1623
1841
|
const [appEventName, payload] = Object.entries(eventMessagePayload)[0]
|
|
1624
1842
|
|
|
1625
1843
|
logger.debug(`[AEM] got Event ${appEventName} from payload from participant ${participantId}`)
|
|
1844
|
+
logger.debug(`[AEM] eachParticipantCancelActionType is: ${context.eachParticipantCancelActionType}`)
|
|
1845
|
+
|
|
1846
|
+
// Check if this ACTION should trigger cancellation for eachParticipant timeout
|
|
1847
|
+
if (context.eachParticipantCancelActionType === appEventName) {
|
|
1848
|
+
logger.info(`[AEM] Intercepting cancel ACTION ${appEventName} from ${participantId} (eachParticipant timeout cancellation)`)
|
|
1849
|
+
// Trigger cancellation sequence via self-send
|
|
1850
|
+
self.send({
|
|
1851
|
+
type: 'TRIGGER_CANCEL_SEQUENCE',
|
|
1852
|
+
data: {
|
|
1853
|
+
participantId,
|
|
1854
|
+
appEventName,
|
|
1855
|
+
payload,
|
|
1856
|
+
event // Store original event for reference
|
|
1857
|
+
}
|
|
1858
|
+
})
|
|
1859
|
+
return // Don't forward yet, wait for cancellation to complete
|
|
1860
|
+
}
|
|
1626
1861
|
|
|
1862
|
+
// Normal flow: forward immediately
|
|
1627
1863
|
const actionEventPayload = {
|
|
1628
1864
|
participantId,
|
|
1629
1865
|
payload
|
|
@@ -1643,6 +1879,9 @@ const AnearEventMachineFunctions = ({
|
|
|
1643
1879
|
// This assumes the current participant IS a non-responder.
|
|
1644
1880
|
const isFinalAction = nonResponders.size === 1 && nonResponders.has(participantId);
|
|
1645
1881
|
|
|
1882
|
+
if (isFinalAction) {
|
|
1883
|
+
logger.debug(`[AEM] Final ACTION received from ${participantId}, allParticipants timeout cancelled (all responded)`)
|
|
1884
|
+
}
|
|
1646
1885
|
logger.info(`[AEM] Participants FINAL ACTION is ${isFinalAction}`)
|
|
1647
1886
|
|
|
1648
1887
|
// Forward to AppM with the finalAction flag
|
|
@@ -1662,10 +1901,35 @@ const AnearEventMachineFunctions = ({
|
|
|
1662
1901
|
participantMachine.send({ type: 'ACTION', ...apm_Payload });
|
|
1663
1902
|
}
|
|
1664
1903
|
},
|
|
1665
|
-
|
|
1666
|
-
|
|
1904
|
+
storeCancelActionForAllParticipants: assign(({ context, event }) => {
|
|
1905
|
+
const participantId = event.data.participantId
|
|
1906
|
+
const eventMessagePayload = JSON.parse(event.data.payload)
|
|
1907
|
+
const [appEventName, payload] = Object.entries(eventMessagePayload)[0]
|
|
1908
|
+
|
|
1909
|
+
logger.info(`[AEM] Intercepting cancel ACTION ${appEventName} for allParticipants timeout from ${participantId}`)
|
|
1910
|
+
|
|
1911
|
+
return {
|
|
1912
|
+
pendingCancelAction: {
|
|
1913
|
+
participantId,
|
|
1914
|
+
appEventName,
|
|
1915
|
+
payload
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
}),
|
|
1919
|
+
processParticipantTimeout: ({ context, event }) => {
|
|
1920
|
+
// Suppress PARTICIPANT_TIMEOUT events after cancelAllParticipantTimeouts() until new timers
|
|
1921
|
+
// are set up via render. This handles race conditions where an APM's timer fires just
|
|
1922
|
+
// before receiving CANCEL_TIMEOUT. The flag is cleared when renders complete (new timers set up).
|
|
1923
|
+
if (context.suppressParticipantTimeouts) {
|
|
1924
|
+
logger.debug(`[AEM] Suppressing PARTICIPANT_TIMEOUT from ${event.participantId} (cancelled timers, waiting for new render)`)
|
|
1925
|
+
return
|
|
1926
|
+
}
|
|
1927
|
+
context.appEventMachine.send({ type: 'PARTICIPANT_TIMEOUT', participantId: event.participantId })
|
|
1928
|
+
},
|
|
1667
1929
|
setupParticipantsTimeout: assign(({ context, event }) => {
|
|
1668
|
-
// Only set up a new timeout if one is provided by the display render workflow
|
|
1930
|
+
// Only set up a new timeout if one is provided by the display render workflow
|
|
1931
|
+
// AND we don't already have an active timeout (i.e., we're entering from waitingForActions,
|
|
1932
|
+
// not re-rendering during waitAllParticipantsResponse).
|
|
1669
1933
|
//
|
|
1670
1934
|
// v5 note: `renderDisplay` is implemented as a Promise actor (fromPromise).
|
|
1671
1935
|
// The actor's resolved value is delivered on the done event as `event.output`
|
|
@@ -1676,21 +1940,29 @@ const AnearEventMachineFunctions = ({
|
|
|
1676
1940
|
(event && event.data && event.data.participantsTimeout) ||
|
|
1677
1941
|
null
|
|
1678
1942
|
|
|
1679
|
-
//
|
|
1680
|
-
|
|
1943
|
+
// Only set up a NEW timeout if one is provided and we don't already have an active timeout.
|
|
1944
|
+
// If we're re-rendering during waitAllParticipantsResponse, the timer is still running
|
|
1945
|
+
// from the parent state (preserved by nested state pattern), so we don't overwrite it.
|
|
1946
|
+
if (participantsTimeout && !context.participantsActionTimeout) {
|
|
1681
1947
|
const timeoutMsecs = participantsTimeout.msecs
|
|
1682
1948
|
const allParticipantIds = getActiveParticipantIds(context)
|
|
1683
|
-
|
|
1949
|
+
const cancelActionType =
|
|
1950
|
+
(event && event.output && event.output.cancelActionType) ||
|
|
1951
|
+
(event && event.data && event.data.cancelActionType) ||
|
|
1952
|
+
null
|
|
1953
|
+
|
|
1954
|
+
logger.debug(`[AEM] Setting up allParticipants timeout: ${timeoutMsecs}ms for ${allParticipantIds.length} participants: ${allParticipantIds.join(', ')}${cancelActionType ? `, cancel: ${cancelActionType}` : ''}`)
|
|
1684
1955
|
|
|
1685
1956
|
return {
|
|
1686
1957
|
participantsActionTimeout: {
|
|
1687
1958
|
msecs: timeoutMsecs,
|
|
1688
1959
|
startedAt: Date.now(),
|
|
1689
|
-
nonResponders: new Set(allParticipantIds)
|
|
1960
|
+
nonResponders: new Set(allParticipantIds),
|
|
1961
|
+
cancelActionType: cancelActionType
|
|
1690
1962
|
}
|
|
1691
1963
|
}
|
|
1692
1964
|
}
|
|
1693
|
-
// If no timeout data, return empty object to not change context
|
|
1965
|
+
// If no timeout data or timeout already exists, return empty object to not change context
|
|
1694
1966
|
return {};
|
|
1695
1967
|
}),
|
|
1696
1968
|
processParticipantResponse: assign(({ context, event }) => {
|
|
@@ -1698,6 +1970,9 @@ const AnearEventMachineFunctions = ({
|
|
|
1698
1970
|
const { nonResponders, ...rest } = context.participantsActionTimeout
|
|
1699
1971
|
const newNonResponders = new Set(nonResponders)
|
|
1700
1972
|
newNonResponders.delete(participantId)
|
|
1973
|
+
const remainingCount = newNonResponders.size
|
|
1974
|
+
|
|
1975
|
+
logger.debug(`[AEM] Participant ${participantId} responded, removing from allParticipants timeout. ${remainingCount} participants remaining.`)
|
|
1701
1976
|
|
|
1702
1977
|
return {
|
|
1703
1978
|
participantsActionTimeout: {
|
|
@@ -1729,12 +2004,41 @@ const AnearEventMachineFunctions = ({
|
|
|
1729
2004
|
sendActionsTimeoutToAppM: ({ context }) => {
|
|
1730
2005
|
const { nonResponders, msecs } = context.participantsActionTimeout
|
|
1731
2006
|
const nonResponderIds = [...nonResponders]
|
|
1732
|
-
logger.info(`[AEM]
|
|
2007
|
+
logger.info(`[AEM] allParticipants timeout expired after ${msecs}ms. Non-responders: ${nonResponderIds.join(', ')}`)
|
|
1733
2008
|
|
|
1734
2009
|
if (context.appEventMachine) {
|
|
1735
2010
|
context.appEventMachine.send({ type: 'ACTIONS_TIMEOUT', timeout: msecs, nonResponderIds })
|
|
1736
2011
|
}
|
|
1737
2012
|
},
|
|
2013
|
+
sendActionsTimeoutToAppMCanceled: ({ context }) => {
|
|
2014
|
+
const { nonResponders, msecs } = context.participantsActionTimeout
|
|
2015
|
+
const nonResponderIds = [...nonResponders]
|
|
2016
|
+
logger.info(`[AEM] allParticipants timeout cancelled after ${msecs}ms. Non-responders at cancellation: ${nonResponderIds.join(', ')}`)
|
|
2017
|
+
|
|
2018
|
+
if (context.appEventMachine) {
|
|
2019
|
+
context.appEventMachine.send({ type: 'ACTIONS_TIMEOUT', timeout: msecs, nonResponderIds, canceled: true })
|
|
2020
|
+
}
|
|
2021
|
+
},
|
|
2022
|
+
forwardPendingCancelActionIfExists: ({ context }) => {
|
|
2023
|
+
if (!context.pendingCancelAction) return // No cancel ACTION to forward
|
|
2024
|
+
|
|
2025
|
+
const { participantId, appEventName, payload } = context.pendingCancelAction
|
|
2026
|
+
const actionEventPayload = { participantId, payload }
|
|
2027
|
+
|
|
2028
|
+
// Forward to AppM
|
|
2029
|
+
context.appEventMachine.send({ type: appEventName, ...actionEventPayload })
|
|
2030
|
+
|
|
2031
|
+
// Forward to APM
|
|
2032
|
+
const participantMachine = context.participantMachines[participantId]
|
|
2033
|
+
if (participantMachine) {
|
|
2034
|
+
participantMachine.send({ type: 'ACTION', ...actionEventPayload })
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
logger.info(`[AEM] Forwarded cancel ACTION ${appEventName} from ${participantId} to AppM and APM after allParticipants cancellation`)
|
|
2038
|
+
|
|
2039
|
+
// Clear the pending action
|
|
2040
|
+
context.pendingCancelAction = null
|
|
2041
|
+
},
|
|
1738
2042
|
clearParticipantsTimeout: assign({
|
|
1739
2043
|
participantsActionTimeout: null
|
|
1740
2044
|
}),
|
|
@@ -1985,8 +2289,9 @@ const AnearEventMachineFunctions = ({
|
|
|
1985
2289
|
renderDisplay: fromPromise(async ({ input }) => {
|
|
1986
2290
|
const { context, event } = input
|
|
1987
2291
|
const displayEventProcessor = new DisplayEventProcessor(context)
|
|
2292
|
+
const cancelActionType = event.cancelActionType || null
|
|
1988
2293
|
|
|
1989
|
-
return await displayEventProcessor.processAndPublish(event.displayEvents)
|
|
2294
|
+
return await displayEventProcessor.processAndPublish(event.displayEvents, cancelActionType)
|
|
1990
2295
|
}),
|
|
1991
2296
|
getAttachedCreatorOrHost: fromPromise(async ({ input }) => {
|
|
1992
2297
|
const { context } = input
|
|
@@ -2162,6 +2467,9 @@ const AnearEventMachineFunctions = ({
|
|
|
2162
2467
|
},
|
|
2163
2468
|
allParticipantsResponded: ({ context }) => {
|
|
2164
2469
|
return context.participantsActionTimeout && context.participantsActionTimeout.nonResponders.size === 0
|
|
2470
|
+
},
|
|
2471
|
+
allCancelConfirmationsReceived: ({ context }) => {
|
|
2472
|
+
return !context.pendingCancelConfirmations || context.pendingCancelConfirmations.size === 0
|
|
2165
2473
|
}
|
|
2166
2474
|
},
|
|
2167
2475
|
delays: {
|
|
@@ -161,6 +161,7 @@ const AnearParticipantMachineConfig = participantId => ({
|
|
|
161
161
|
initial: 'idle',
|
|
162
162
|
states: {
|
|
163
163
|
idle: {
|
|
164
|
+
entry: ['ensureTimerCleared'],
|
|
164
165
|
on: {
|
|
165
166
|
RENDER_DISPLAY: {
|
|
166
167
|
target: '#renderDisplay'
|
|
@@ -168,6 +169,12 @@ const AnearParticipantMachineConfig = participantId => ({
|
|
|
168
169
|
PRIVATE_DISPLAY: {
|
|
169
170
|
target: '#renderDisplay'
|
|
170
171
|
},
|
|
172
|
+
CANCEL_TIMEOUT: {
|
|
173
|
+
// Acknowledge CANCEL_TIMEOUT even when idle (no active timer).
|
|
174
|
+
// This ensures the AEM's cancellation sequence completes for all participants.
|
|
175
|
+
actions: ['sendTimerCanceled'],
|
|
176
|
+
// v5 note: internal transitions are the default (v4 had `internal: true`)
|
|
177
|
+
},
|
|
171
178
|
PARTICIPANT_DISCONNECT: {
|
|
172
179
|
target: 'waitReconnect'
|
|
173
180
|
},
|
|
@@ -235,8 +242,7 @@ const AnearParticipantMachineConfig = participantId => ({
|
|
|
235
242
|
onDone: [
|
|
236
243
|
{ guard: 'hasActionTimeout', actions: 'updateActionTimeout', target: 'waitParticipantResponse' },
|
|
237
244
|
// If this display does not configure a timeout, explicitly clear any
|
|
238
|
-
// previously-running action timeout.
|
|
239
|
-
// from an older prompt can be "resumed" later and immediately fire.
|
|
245
|
+
// previously-running action timeout.
|
|
240
246
|
{ actions: 'nullActionTimeout', target: 'idle' }
|
|
241
247
|
],
|
|
242
248
|
onError: {
|
|
@@ -251,6 +257,7 @@ const AnearParticipantMachineConfig = participantId => ({
|
|
|
251
257
|
}
|
|
252
258
|
},
|
|
253
259
|
waitParticipantResponse: {
|
|
260
|
+
entry: 'logWaitParticipantResponseEntry',
|
|
254
261
|
always: {
|
|
255
262
|
guard: 'isTimeoutImmediate',
|
|
256
263
|
actions: 'logImmediateTimeout',
|
|
@@ -262,16 +269,41 @@ const AnearParticipantMachineConfig = participantId => ({
|
|
|
262
269
|
target: '#participantTimedOut'
|
|
263
270
|
}
|
|
264
271
|
},
|
|
272
|
+
initial: 'waiting',
|
|
273
|
+
states: {
|
|
274
|
+
waiting: {
|
|
275
|
+
// The actual waiting state. Timer continues running when we transition to .rendering.
|
|
276
|
+
},
|
|
277
|
+
rendering: {
|
|
278
|
+
invoke: {
|
|
279
|
+
src: 'publishPrivateDisplay',
|
|
280
|
+
input: ({ context, event }) => ({ context, event }),
|
|
281
|
+
onDone: {
|
|
282
|
+
// Transition back to waiting - timer continues because we never exited waitParticipantResponse
|
|
283
|
+
// Target sibling state (stays within parent, timer continues)
|
|
284
|
+
target: 'waiting'
|
|
285
|
+
},
|
|
286
|
+
onError: {
|
|
287
|
+
target: '#error'
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
on: {
|
|
291
|
+
PARTICIPANT_EXIT: {
|
|
292
|
+
actions: 'logExit',
|
|
293
|
+
target: '#cleanupAndExit'
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
},
|
|
265
298
|
on: {
|
|
266
299
|
RENDER_DISPLAY: {
|
|
267
|
-
// Re-render the current prompt
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
// actionTimeoutStart so the participant does not get extra time.
|
|
271
|
-
target: '#renderDisplay'
|
|
300
|
+
// Re-render the current prompt without exiting waitParticipantResponse.
|
|
301
|
+
// This preserves the parent's after: timer because we stay within the parent state.
|
|
302
|
+
target: '.rendering'
|
|
272
303
|
},
|
|
273
304
|
PRIVATE_DISPLAY: {
|
|
274
|
-
|
|
305
|
+
// Same as RENDER_DISPLAY - re-render without interrupting timer
|
|
306
|
+
target: '.rendering'
|
|
275
307
|
},
|
|
276
308
|
PARTICIPANT_EXIT: {
|
|
277
309
|
actions: 'logExit',
|
|
@@ -285,6 +317,14 @@ const AnearParticipantMachineConfig = participantId => ({
|
|
|
285
317
|
],
|
|
286
318
|
target: 'idle'
|
|
287
319
|
},
|
|
320
|
+
CANCEL_TIMEOUT: {
|
|
321
|
+
// Cancel the active timeout and transition to idle. This is used
|
|
322
|
+
// when the AppM wants to immediately cancel all participant timeouts
|
|
323
|
+
// (e.g., when a LIAR call interrupts the wait state).
|
|
324
|
+
// Always send TIMER_CANCELED to acknowledge, even if no timer was active.
|
|
325
|
+
actions: ['nullActionTimeout', 'sendTimerCanceled'],
|
|
326
|
+
target: 'idle'
|
|
327
|
+
},
|
|
288
328
|
PARTICIPANT_DISCONNECT: {
|
|
289
329
|
actions: 'updateRemainingTimeoutOnDisconnect',
|
|
290
330
|
target: 'waitReconnect'
|
|
@@ -297,7 +337,7 @@ const AnearParticipantMachineConfig = participantId => ({
|
|
|
297
337
|
},
|
|
298
338
|
participantTimedOut: {
|
|
299
339
|
id: 'participantTimedOut',
|
|
300
|
-
entry: ['sendTimeoutEvents', 'nullActionTimeout'],
|
|
340
|
+
entry: ['logParticipantTimedOut', 'sendTimeoutEvents', 'nullActionTimeout'],
|
|
301
341
|
always: 'idle'
|
|
302
342
|
},
|
|
303
343
|
cleanupAndExit: {
|
|
@@ -412,6 +452,7 @@ const AnearParticipantMachineFunctions = {
|
|
|
412
452
|
}
|
|
413
453
|
}),
|
|
414
454
|
sendTimeoutEvents: ({ context }) => {
|
|
455
|
+
logger.info(`[APM] Timer expired for ${context.anearParticipant.id}, sending PARTICIPANT_TIMEOUT to AEM`)
|
|
415
456
|
// AEM currently expects `event.participantId` (not nested under `data`).
|
|
416
457
|
context.anearEvent.send({
|
|
417
458
|
type: 'PARTICIPANT_TIMEOUT',
|
|
@@ -419,6 +460,13 @@ const AnearParticipantMachineFunctions = {
|
|
|
419
460
|
})
|
|
420
461
|
if (context.appParticipantMachine) context.appParticipantMachine.send({ type: 'PARTICIPANT_TIMEOUT' })
|
|
421
462
|
},
|
|
463
|
+
sendTimerCanceled: ({ context }) => {
|
|
464
|
+
logger.debug(`[APM] Sending TIMER_CANCELED to AEM for ${context.anearParticipant.id}`)
|
|
465
|
+
context.anearEvent.send({
|
|
466
|
+
type: 'TIMER_CANCELED',
|
|
467
|
+
participantId: context.anearParticipant.id
|
|
468
|
+
})
|
|
469
|
+
},
|
|
422
470
|
sendActionToAppParticipantMachine: ({ context, event }) => {
|
|
423
471
|
if (context.appParticipantMachine) context.appParticipantMachine.send(event)
|
|
424
472
|
},
|
|
@@ -429,34 +477,41 @@ const AnearParticipantMachineFunctions = {
|
|
|
429
477
|
const timeoutFromEvent = event?.output?.timeout ?? event?.data?.timeout
|
|
430
478
|
const now = CurrentDateTimestamp()
|
|
431
479
|
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
if (
|
|
438
|
-
|
|
439
|
-
const base = context.actionTimeoutMsecs != null ? context.actionTimeoutMsecs : timeoutFromEvent
|
|
440
|
-
const remaining = base - elapsed
|
|
441
|
-
const remainingMsecs = remaining > 0 ? remaining : 0
|
|
442
|
-
|
|
443
|
-
logger.debug(`[APM] Resuming timer for ${context.anearParticipant.id} with ${remainingMsecs}ms remaining`)
|
|
444
|
-
return {
|
|
445
|
-
actionTimeoutMsecs: remainingMsecs,
|
|
446
|
-
actionTimeoutStart: now
|
|
447
|
-
}
|
|
480
|
+
// Start a new timer. This is only called when transitioning from idle to waitParticipantResponse.
|
|
481
|
+
// When RENDER_DISPLAY arrives during waitParticipantResponse, we transition to the nested
|
|
482
|
+
// .rendering state, which preserves the parent's after: timer (no resume needed).
|
|
483
|
+
const existingMsecs = context.actionTimeoutMsecs
|
|
484
|
+
const existingStart = context.actionTimeoutStart
|
|
485
|
+
if (existingMsecs != null || existingStart != null) {
|
|
486
|
+
logger.debug(`[APM] WARNING: Starting new timer for ${context.anearParticipant.id} but existing timer state found: msecs=${existingMsecs}, start=${existingStart}`)
|
|
448
487
|
}
|
|
449
|
-
|
|
450
|
-
// First time this participant sees this prompt: start a fresh timer.
|
|
451
488
|
logger.debug(`[APM] Starting new timer for ${context.anearParticipant.id} with ${timeoutFromEvent}ms`)
|
|
452
489
|
return {
|
|
453
490
|
actionTimeoutMsecs: timeoutFromEvent,
|
|
454
491
|
actionTimeoutStart: now
|
|
455
492
|
}
|
|
456
493
|
}),
|
|
457
|
-
nullActionTimeout: assign({
|
|
458
|
-
|
|
459
|
-
|
|
494
|
+
nullActionTimeout: assign(({ context }) => {
|
|
495
|
+
if (context.actionTimeoutMsecs != null || context.actionTimeoutStart != null) {
|
|
496
|
+
logger.debug(`[APM] Cancelling timer for ${context.anearParticipant.id} (ACTION received or timeout cleared)`)
|
|
497
|
+
}
|
|
498
|
+
return {
|
|
499
|
+
actionTimeoutMsecs: null,
|
|
500
|
+
actionTimeoutStart: null
|
|
501
|
+
}
|
|
502
|
+
}),
|
|
503
|
+
ensureTimerCleared: assign(({ context }) => {
|
|
504
|
+
// Defensive: Ensure timer state is cleared when entering idle state.
|
|
505
|
+
// This prevents stale timer state from affecting new displays, especially
|
|
506
|
+
// if a RENDER_DISPLAY arrives before timeout cleanup completes.
|
|
507
|
+
if (context.actionTimeoutMsecs != null || context.actionTimeoutStart != null) {
|
|
508
|
+
logger.debug(`[APM] Clearing stale timer state for ${context.anearParticipant.id} on idle entry (msecs=${context.actionTimeoutMsecs}, start=${context.actionTimeoutStart})`)
|
|
509
|
+
return {
|
|
510
|
+
actionTimeoutMsecs: null,
|
|
511
|
+
actionTimeoutStart: null
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return {}
|
|
460
515
|
}),
|
|
461
516
|
updateRemainingTimeoutOnDisconnect: assign(({ context }) => {
|
|
462
517
|
if (context.actionTimeoutStart) {
|
|
@@ -492,6 +547,8 @@ const AnearParticipantMachineFunctions = {
|
|
|
492
547
|
},
|
|
493
548
|
logAttached: ({ context }) => logger.debug(`[APM] Got ATTACHED for privateChannel for ${context.anearParticipant.id}`),
|
|
494
549
|
logLive: ({ context }) => logger.debug(`[APM] Participant ${context.anearParticipant.id} is LIVE!`),
|
|
550
|
+
logWaitParticipantResponseEntry: ({ context }) => logger.debug(`[APM] ENTERING waitParticipantResponse state for ${context.anearParticipant.id} - timer will be (re)started if context provides timeout`),
|
|
551
|
+
logParticipantTimedOut: ({ context }) => logger.debug(`[APM] ENTERING participantTimedOut state for ${context.anearParticipant.id} - timeout occurred, clearing timer and sending PARTICIPANT_TIMEOUT`),
|
|
495
552
|
logExit: (_args) => logger.debug('[APM] got PARTICIPANT_EXIT. Exiting...'),
|
|
496
553
|
logDisconnected: ({ context }) => logger.info(`[APM] Participant ${context.anearParticipant.id} has DISCONNECTED`),
|
|
497
554
|
logActionTimeoutWhileDisconnected: ({ context }) => logger.info(`[APM] Participant ${context.anearParticipant.id} timed out on action while disconnected.`),
|
|
@@ -15,6 +15,9 @@ const REFRESH_EVENT_TYPES = new Set([
|
|
|
15
15
|
// Temporary disconnects (useful for "halt/pause" and UI indicators):
|
|
16
16
|
'PARTICIPANT_DISCONNECT',
|
|
17
17
|
'HOST_DISCONNECT',
|
|
18
|
+
// Permanent exits (should update participant counts and UI):
|
|
19
|
+
'PARTICIPANT_EXIT',
|
|
20
|
+
'HOST_EXIT',
|
|
18
21
|
// Location/heartbeat updates can also want a "refresh current view":
|
|
19
22
|
'PARTICIPANT_UPDATE',
|
|
20
23
|
'HOST_UPDATE'
|
|
@@ -91,6 +94,7 @@ const AppMachineTransition = (anearEvent) => {
|
|
|
91
94
|
const appStateName = _stringifiedState(value)
|
|
92
95
|
|
|
93
96
|
const displayEvents = []
|
|
97
|
+
let cancelActionType = null // Track cancel property from meta configuration
|
|
94
98
|
|
|
95
99
|
// Process all meta objects to handle unpredictable AppM structures
|
|
96
100
|
metaObjects.forEach(meta => {
|
|
@@ -109,6 +113,11 @@ const AppMachineTransition = (anearEvent) => {
|
|
|
109
113
|
const { viewPath, props } = _extractViewAndProps(meta.allParticipants)
|
|
110
114
|
timeoutFn = RenderContextBuilder.buildTimeoutFn(viewer, meta.allParticipants.timeout)
|
|
111
115
|
|
|
116
|
+
// Extract cancel property if present
|
|
117
|
+
if (meta.allParticipants.cancel && !cancelActionType) {
|
|
118
|
+
cancelActionType = meta.allParticipants.cancel
|
|
119
|
+
}
|
|
120
|
+
|
|
112
121
|
displayEvent = RenderContextBuilder.buildDisplayEvent(
|
|
113
122
|
viewPath,
|
|
114
123
|
RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, viewer, timeoutFn),
|
|
@@ -124,7 +133,8 @@ const AppMachineTransition = (anearEvent) => {
|
|
|
124
133
|
// Check if participant is a function (new selective rendering format)
|
|
125
134
|
viewer = 'eachParticipant'
|
|
126
135
|
if (typeof meta.eachParticipant === 'function') {
|
|
127
|
-
// New selective participant rendering
|
|
136
|
+
// New selective participant rendering - cancel property is not supported in function format
|
|
137
|
+
// (would need to be in the object format, see legacy handling below)
|
|
128
138
|
const participantRenderFunc = meta.eachParticipant
|
|
129
139
|
const participantDisplays = participantRenderFunc(appContext, event)
|
|
130
140
|
|
|
@@ -162,6 +172,11 @@ const AppMachineTransition = (anearEvent) => {
|
|
|
162
172
|
const { viewPath, props } = _extractViewAndProps(meta.eachParticipant)
|
|
163
173
|
const timeoutFn = RenderContextBuilder.buildTimeoutFn('participant', meta.eachParticipant.timeout)
|
|
164
174
|
|
|
175
|
+
// Extract cancel property if present (for legacy eachParticipant format)
|
|
176
|
+
if (meta.eachParticipant.cancel && !cancelActionType) {
|
|
177
|
+
cancelActionType = meta.eachParticipant.cancel
|
|
178
|
+
}
|
|
179
|
+
|
|
165
180
|
const renderContext = RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, 'eachParticipant', timeoutFn)
|
|
166
181
|
displayEvent = RenderContextBuilder.buildDisplayEvent(
|
|
167
182
|
viewPath,
|
|
@@ -210,7 +225,15 @@ const AppMachineTransition = (anearEvent) => {
|
|
|
210
225
|
|
|
211
226
|
if (displayEvents.length > 0) {
|
|
212
227
|
logger.debug(`[AppMachineTransition] sending RENDER_DISPLAY with ${displayEvents.length} displayEvents`)
|
|
213
|
-
|
|
228
|
+
const renderDisplayPayload = {
|
|
229
|
+
type: 'RENDER_DISPLAY',
|
|
230
|
+
displayEvents
|
|
231
|
+
}
|
|
232
|
+
// Include cancel property only if defined
|
|
233
|
+
if (cancelActionType) {
|
|
234
|
+
renderDisplayPayload.cancelActionType = cancelActionType
|
|
235
|
+
}
|
|
236
|
+
anearEvent.send(renderDisplayPayload)
|
|
214
237
|
}
|
|
215
238
|
}
|
|
216
239
|
|
|
@@ -56,7 +56,7 @@ class DisplayEventProcessor {
|
|
|
56
56
|
this.participantsIndex = this._buildParticipantsIndex(anearEventMachineContext.participants)
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
processAndPublish(displayEvents) {
|
|
59
|
+
processAndPublish(displayEvents, cancelActionType = null) {
|
|
60
60
|
let participantsTimeout = null
|
|
61
61
|
|
|
62
62
|
// Pre-compute allParticipants timeout for this render batch so other viewers
|
|
@@ -88,7 +88,7 @@ class DisplayEventProcessor {
|
|
|
88
88
|
})
|
|
89
89
|
|
|
90
90
|
return Promise.all(publishPromises).then(() => {
|
|
91
|
-
return { participantsTimeout }
|
|
91
|
+
return { participantsTimeout, cancelActionType }
|
|
92
92
|
})
|
|
93
93
|
}
|
|
94
94
|
|
package/lib/utils/PugHelpers.js
CHANGED
|
@@ -19,6 +19,81 @@ const PugHelpers = s3ImageAssetsUrl => ({
|
|
|
19
19
|
} catch (error) {
|
|
20
20
|
throw new Error("Invalid JSON for data-click-action:", finalPayload);
|
|
21
21
|
}
|
|
22
|
+
},
|
|
23
|
+
/**
|
|
24
|
+
* Format a timestamp in the event's timezone (or fallback to local time)
|
|
25
|
+
* General-purpose helper that works for both time and date formatting.
|
|
26
|
+
*
|
|
27
|
+
* @param {number|Date} timestamp - Timestamp in milliseconds or Date object
|
|
28
|
+
* @param {string|null} timezone - IANA timezone ID (e.g., 'America/New_York') or null for local time
|
|
29
|
+
* @param {object} options - Intl.DateTimeFormat options
|
|
30
|
+
* - For time: { hour: 'numeric', minute: '2-digit', hour12: true }
|
|
31
|
+
* - For date: { weekday: 'short', month: 'short', day: 'numeric' }
|
|
32
|
+
* - For both: { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }
|
|
33
|
+
* @returns {string} Formatted date/time string
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* // Format time only
|
|
37
|
+
* formatDateTime(timestamp, app.eventTimezone)
|
|
38
|
+
* // => "3:08 PM"
|
|
39
|
+
*
|
|
40
|
+
* // Format date only
|
|
41
|
+
* formatDateTime(timestamp, app.eventTimezone, { weekday: 'short', month: 'short', day: 'numeric' })
|
|
42
|
+
* // => "Thu, Jan 1"
|
|
43
|
+
*
|
|
44
|
+
* // Format date and time
|
|
45
|
+
* formatDateTime(timestamp, app.eventTimezone, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
|
|
46
|
+
* // => "Jan 1, 3:08 PM"
|
|
47
|
+
*/
|
|
48
|
+
formatDateTime: (timestamp, timezone, options = { hour: 'numeric', minute: '2-digit', hour12: true }) => {
|
|
49
|
+
const date = timestamp instanceof Date ? timestamp : new Date(timestamp)
|
|
50
|
+
|
|
51
|
+
if (timezone && typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
|
|
52
|
+
const formatter = new Intl.DateTimeFormat('en-US', { ...options, timeZone: timezone })
|
|
53
|
+
return formatter.format(date)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Fallback: format based on what options were requested
|
|
57
|
+
const hasTimeOptions = options.hour !== undefined || options.minute !== undefined
|
|
58
|
+
const hasDateOptions = options.weekday !== undefined || options.month !== undefined || options.day !== undefined || options.year !== undefined
|
|
59
|
+
|
|
60
|
+
// If only time options, use simple time fallback
|
|
61
|
+
if (hasTimeOptions && !hasDateOptions) {
|
|
62
|
+
const hours = date.getHours()
|
|
63
|
+
const minutes = date.getMinutes()
|
|
64
|
+
const ampm = hours >= 12 ? 'PM' : 'AM'
|
|
65
|
+
const displayHours = hours % 12 || 12
|
|
66
|
+
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${ampm}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// If date options (with or without time), use UTC fallback for consistency
|
|
70
|
+
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
|
71
|
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
|
72
|
+
const parts = []
|
|
73
|
+
|
|
74
|
+
if (options.weekday === 'short' || options.weekday === 'long') {
|
|
75
|
+
parts.push(days[date.getUTCDay()])
|
|
76
|
+
}
|
|
77
|
+
if (options.month === 'short' || options.month === 'long') {
|
|
78
|
+
parts.push(months[date.getUTCMonth()])
|
|
79
|
+
}
|
|
80
|
+
if (options.day === 'numeric') {
|
|
81
|
+
parts.push(date.getUTCDate())
|
|
82
|
+
}
|
|
83
|
+
if (options.year === 'numeric') {
|
|
84
|
+
parts.push(date.getUTCFullYear())
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Add time if requested
|
|
88
|
+
if (hasTimeOptions) {
|
|
89
|
+
const hours = date.getUTCHours()
|
|
90
|
+
const minutes = date.getUTCMinutes()
|
|
91
|
+
const ampm = hours >= 12 ? 'PM' : 'AM'
|
|
92
|
+
const displayHours = hours % 12 || 12
|
|
93
|
+
parts.push(`${displayHours}:${minutes.toString().padStart(2, '0')} ${ampm}`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return parts.join(' ')
|
|
22
97
|
}
|
|
23
98
|
})
|
|
24
99
|
|
package/package.json
CHANGED