diff --git a/CHANGELOG.md b/CHANGELOG.md index d92e28125d..0431a7f28f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -330,7 +330,7 @@ Notes: web developers are advised to use [`~` (tilde range)](https://github.com/ - Fixed citation links are not properly matched against markdown links, in PR [#5614](https://github.com/microsoft/BotFramework-WebChat/pull/5614), by [@OEvgeny](https://github.com/OEvgeny) - Fixed `botframework-webchat/decorator` import in legacy CommonJS environments, in [#5616](https://github.com/microsoft/BotFramework-WebChat/pull/5616), by [@OEvgeny](https://github.com/OEvgeny) - Fixed `npm start` for efficiency and reliability, in PR [#5621](https://github.com/microsoft/BotFramework-WebChat/pull/5621) and [#5629](https://github.com/microsoft/BotFramework-WebChat/pull/5629), by [@compulim](https://github.com/compulim) -- Fixed activity sorting introduced in PR [#5622](https://github.com/microsoft/BotFramework-WebChat/pull/5622), part grouping, and livestreaming, by [@compulim](https://github.com/compulim) in PR [#XXX](https://github.com/microsoft/BotFramework-WebChat/pull/XXX) +- Fixed activity sorting introduced in PR [#5622](https://github.com/microsoft/BotFramework-WebChat/pull/5622), part grouping, and livestreaming, by [@compulim](https://github.com/compulim) in PR [#5635](https://github.com/microsoft/BotFramework-WebChat/pull/5635) ### Removed diff --git a/__tests__/html2/activity/collapsible.html b/__tests__/html2/activity/collapsible.html index 6f53af446d..8611aeda1b 100644 --- a/__tests__/html2/activity/collapsible.html +++ b/__tests__/html2/activity/collapsible.html @@ -71,9 +71,7 @@ await host.sendDevToolsCommand('Emulation.setEmulatedMedia', { features: [ { name: 'prefers-reduced-motion', value: 'reduce' }, - ...(theme === 'dark' || theme === 'light' - ? [{ name: 'prefers-color-scheme', value: theme }] - : []) + ...(theme === 'dark' || theme === 'light' ? [{ name: 'prefers-color-scheme', value: theme }] : []) ] }); @@ -82,7 +80,7 @@ let fluentTheme; let codeBlockTheme; - if (theme === 'dark' || window.matchMedia('(prefers-color-scheme: dark)').matches && theme !== 'light') { + if (theme === 'dark' || (window.matchMedia('(prefers-color-scheme: dark)').matches && theme !== 'light')) { fluentTheme = { ...createDarkTheme({ 10: '#12174c', @@ -111,35 +109,35 @@ fluentTheme = { ...webLightTheme, // Original is #242424 which is too light for fui-FluentProvider to pass our a11y - colorNeutralForeground1: '#1b1b1b', + colorNeutralForeground1: '#1b1b1b' }; codeBlockTheme = 'github-light-default'; } // TODO: code block github theme triggers accessibility violation if (variant) { - window.checkAccessibility = async () => {} + window.checkAccessibility = async () => {}; } const webChatProps = { directLine, store, styleOptions: { codeBlockTheme } }; root.render( - variant ? - React.createElement( - FluentProvider, - { className: 'fui-FluentProvider', theme: fluentTheme }, - React.createElement( - FluentThemeProvider, - { variant: variant }, - React.createElement(ReactWebChat, webChatProps) + variant + ? React.createElement( + FluentProvider, + { className: 'fui-FluentProvider', theme: fluentTheme }, + React.createElement( + FluentThemeProvider, + { variant: variant }, + React.createElement(ReactWebChat, webChatProps) + ) ) - ) : - React.createElement(ReactWebChat, webChatProps) + : React.createElement(ReactWebChat, webChatProps) ); await pageConditions.uiConnected(); - const CODE = `import numpy as np + const CODE = `import numpy as np import matplotlib.pyplot as plt def plot_sine_waves(): @@ -182,7 +180,8 @@ # Generate the visualization plot_sine_waves()`; - const botIcon = 'data:image/png;base64,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'; + const botIcon = + 'data:image/png;base64,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'; const aiMessageEntity = { '@context': 'https://schema.org', @@ -194,7 +193,7 @@ '@context': 'https://schema.org', '@type': 'Person', image: botIcon, - name: 'Agent', + name: 'Agent' } }; @@ -219,7 +218,6 @@ `)}`; - directLine.emulateIncomingActivity({ from: { role: 'user' }, type: 'message', @@ -228,15 +226,17 @@ directLine.emulateIncomingActivity({ from: { role: 'bot' }, - entities: [{ - ...aiMessageEntity, - keywords: [] - }], - id: "a4c0c01d-c06e-4dde-9278-265c607b545b", - type: "typing", - text: "Informative…", + entities: [ + { + ...aiMessageEntity, + keywords: [] + } + ], + id: 'a4c0c01d-c06e-4dde-9278-265c607b545b', + type: 'typing', + text: 'Informative…', channelData: { - streamType: "informative", + streamType: 'informative', streamSequence: 1 } }); @@ -249,15 +249,16 @@ { ...aiMessageEntity, keywords: [], - abstract: 'Only abstract…', + abstract: 'Only abstract…' } ], channelData: { - streamType: "informative", + streamId: 'a4c0c01d-c06e-4dde-9278-265c607b545b', + streamType: 'informative', streamSequence: 2 }, type: 'typing', - id: "a4c0c01d-c06e-4dde-9278-265c607b545b", + id: 'a4c0c01d-c06e-4dde-9278-265c607b545b:1' }); await host.snapshot('local'); @@ -277,11 +278,12 @@ } ], channelData: { - streamType: "informative", + streamId: 'a4c0c01d-c06e-4dde-9278-265c607b545b', + streamType: 'informative', streamSequence: 3 }, type: 'typing', - id: "a4c0c01d-c06e-4dde-9278-265c607b545b", + id: 'a4c0c01d-c06e-4dde-9278-265c607b545b:3', text: '' }); @@ -308,12 +310,13 @@ } ], channelData: { - streamType: "final", + streamId: 'a4c0c01d-c06e-4dde-9278-265c607b545b', + streamType: 'final', streamSequence: 4 }, - id: "a4c0c01d-c06e-4dde-9278-265c607b545b", + id: 'a4c0c01d-c06e-4dde-9278-265c607b545b:4', type: 'message', - text: `The final message has no View Code button as it is collapsible`, + text: `The final message has no View Code button as it is collapsible` }); await host.snapshot('local'); @@ -333,12 +336,8 @@ } } ], - channelData: { - streamType: "final", - streamSequence: 2 - }, type: 'message', - text: `The non-collapsible message should have Show Code button`, + text: `The non-collapsible message should have Show Code button` }); await host.snapshot('local'); @@ -352,7 +351,6 @@ // Generated from https://placeholder.pics/svg/640x180. const WIDE_SVG = `data:image/svg+xml;utf8,640×180 (32:9)`; - directLine.emulateIncomingActivity({ from: { role: 'bot' }, id: '41.0', diff --git a/__tests__/html2/activityGrouping/activityGrouping.disableTimestamp.html b/__tests__/html2/activityGrouping/activityGrouping.disableTimestamp.html index 52c243490d..604528a8ec 100644 --- a/__tests__/html2/activityGrouping/activityGrouping.disableTimestamp.html +++ b/__tests__/html2/activityGrouping/activityGrouping.disableTimestamp.html @@ -54,6 +54,8 @@ ) ).resolveAll(); + clock.tick(1000); + const { resolveAll: resolveAll1 } = await directLine.emulateOutgoingActivity( 'Elit adipisicing laborum sit anim.' ); diff --git a/__tests__/html2/activityGrouping/activityGrouping.groupingActivityStatus.html b/__tests__/html2/activityGrouping/activityGrouping.groupingActivityStatus.html index 6f53c8ea52..ae6c197f53 100644 --- a/__tests__/html2/activityGrouping/activityGrouping.groupingActivityStatus.html +++ b/__tests__/html2/activityGrouping/activityGrouping.groupingActivityStatus.html @@ -50,6 +50,8 @@ await sendMessage1.echoBack(); sendMessage1.resolvePostActivity(); + clock.tick(1000); + const sendMessage2 = await directLine.emulateOutgoingActivity('A retry prompt must show on this activity.'); clock.tick(1000); @@ -61,6 +63,8 @@ await sendMessage3.echoBack(); sendMessage3.resolvePostActivity(); + clock.tick(1000); + const sendMessage4 = await directLine.emulateOutgoingActivity( 'The timestamp is shown because the next activity is not sent. When it is sent, the timestamp will be hidden.' ); diff --git a/__tests__/html2/activityOrdering/livestreamWithMovingTimestamp.html b/__tests__/html2/activityOrdering/livestreamWithMovingTimestamp.html new file mode 100644 index 0000000000..94baf8d999 --- /dev/null +++ b/__tests__/html2/activityOrdering/livestreamWithMovingTimestamp.html @@ -0,0 +1,108 @@ + + + + + + +
+ + + + diff --git a/__tests__/html2/activityOrdering/livestreamWithMovingTimestamp.html.snap-1.png b/__tests__/html2/activityOrdering/livestreamWithMovingTimestamp.html.snap-1.png new file mode 100644 index 0000000000..24c918274e Binary files /dev/null and b/__tests__/html2/activityOrdering/livestreamWithMovingTimestamp.html.snap-1.png differ diff --git a/__tests__/html2/activityOrdering/livestreamWithMovingTimestamp.html.snap-2.png b/__tests__/html2/activityOrdering/livestreamWithMovingTimestamp.html.snap-2.png new file mode 100644 index 0000000000..846a723578 Binary files /dev/null and b/__tests__/html2/activityOrdering/livestreamWithMovingTimestamp.html.snap-2.png differ diff --git a/__tests__/html2/activityOrdering/livestreamWithMovingTimestamp.html.snap-3.png b/__tests__/html2/activityOrdering/livestreamWithMovingTimestamp.html.snap-3.png new file mode 100644 index 0000000000..a29a76fb4b Binary files /dev/null and b/__tests__/html2/activityOrdering/livestreamWithMovingTimestamp.html.snap-3.png differ diff --git a/__tests__/html2/activityOrdering/partGroupingAtTheEndOfChatHistory.html b/__tests__/html2/activityOrdering/partGroupingAtTheEndOfChatHistory.html index 9d50764776..aa05b04cb3 100644 --- a/__tests__/html2/activityOrdering/partGroupingAtTheEndOfChatHistory.html +++ b/__tests__/html2/activityOrdering/partGroupingAtTheEndOfChatHistory.html @@ -42,7 +42,7 @@ '@id': '', '@type': 'Message', type: 'https://schema.org/Message', - isPartOf: { '@id': 'c-00001', '@type': 'HowTo' }, + isPartOf: { '@id': '_:c-00001', '@type': 'HowTo' }, position: 1 } ], @@ -83,7 +83,7 @@ '@id': '', '@type': 'Message', type: 'https://schema.org/Message', - isPartOf: { '@id': 'c-00001', '@type': 'HowTo' }, + isPartOf: { '@id': '_:c-00001', '@type': 'HowTo' }, position: 2 } ], @@ -95,9 +95,9 @@ }); expect(pageElements.activityContents().map(({ textContent }) => textContent)).toEqual([ + 'a-00002: Hello, World at t = 1', 'a-00001: Chain 1 thought 1 at t = 0', - 'a-00003: Chain 1 thought 2 at t = 2', - 'a-00002: Hello, World at t = 1' + 'a-00003: Chain 1 thought 2 at t = 2' ]); await host.snapshot('local'); @@ -109,7 +109,7 @@ '@id': '', '@type': 'Message', type: 'https://schema.org/Message', - isPartOf: { '@id': 'c-00002', '@type': 'HowTo' }, + isPartOf: { '@id': '_:c-00002', '@type': 'HowTo' }, position: 1 } ], @@ -121,9 +121,9 @@ }); expect(pageElements.activityContents().map(({ textContent }) => textContent)).toEqual([ + 'a-00002: Hello, World at t = 1', 'a-00001: Chain 1 thought 1 at t = 0', 'a-00003: Chain 1 thought 2 at t = 2', - 'a-00002: Hello, World at t = 1', 'a-00004: Chain 2 thought 1 at t = 3' ]); @@ -136,7 +136,7 @@ '@id': '', '@type': 'Message', type: 'https://schema.org/Message', - isPartOf: { '@id': 'c-00002', '@type': 'HowTo' }, + isPartOf: { '@id': '_:c-00002', '@type': 'HowTo' }, position: 2 } ], @@ -144,15 +144,16 @@ id: 'a-00005', text: 'a-00005: Chain 2 thought 2 at t = 0', // Intentionally roll back the date. - // It should be grouped and not appear before "Hello, World!' + // It should be grouped but not appear before "Hello, World!" + // This is because the part grouping timestamp is max of all parts, i.e. 3 * 86_400_000. timestamp: new Date(0).toISOString(), type: 'message' }); expect(pageElements.activityContents().map(({ textContent }) => textContent)).toEqual([ + 'a-00002: Hello, World at t = 1', 'a-00001: Chain 1 thought 1 at t = 0', 'a-00003: Chain 1 thought 2 at t = 2', - 'a-00002: Hello, World at t = 1', 'a-00004: Chain 2 thought 1 at t = 3', 'a-00005: Chain 2 thought 2 at t = 0' ]); diff --git a/__tests__/html2/activityOrdering/partGroupingAtTheEndOfChatHistory.html.snap-3.png b/__tests__/html2/activityOrdering/partGroupingAtTheEndOfChatHistory.html.snap-3.png index ed0124243b..90126d7052 100644 Binary files a/__tests__/html2/activityOrdering/partGroupingAtTheEndOfChatHistory.html.snap-3.png and b/__tests__/html2/activityOrdering/partGroupingAtTheEndOfChatHistory.html.snap-3.png differ diff --git a/__tests__/html2/activityOrdering/partGroupingAtTheEndOfChatHistory.html.snap-4.png b/__tests__/html2/activityOrdering/partGroupingAtTheEndOfChatHistory.html.snap-4.png index 0264c6a97f..214fc2d0dd 100644 Binary files a/__tests__/html2/activityOrdering/partGroupingAtTheEndOfChatHistory.html.snap-4.png and b/__tests__/html2/activityOrdering/partGroupingAtTheEndOfChatHistory.html.snap-4.png differ diff --git a/__tests__/html2/activityOrdering/partGroupingAtTheEndOfChatHistory.html.snap-5.png b/__tests__/html2/activityOrdering/partGroupingAtTheEndOfChatHistory.html.snap-5.png index 770d285183..30cdceaa07 100644 Binary files a/__tests__/html2/activityOrdering/partGroupingAtTheEndOfChatHistory.html.snap-5.png and b/__tests__/html2/activityOrdering/partGroupingAtTheEndOfChatHistory.html.snap-5.png differ diff --git a/__tests__/html2/activityOrdering/partGroupingShouldUseMaxTimestamp.html b/__tests__/html2/activityOrdering/partGroupingShouldUseMaxTimestamp.html new file mode 100644 index 0000000000..04b302d31e --- /dev/null +++ b/__tests__/html2/activityOrdering/partGroupingShouldUseMaxTimestamp.html @@ -0,0 +1,156 @@ + + + + + + +
+ + + + diff --git a/__tests__/html2/activityOrdering/partGroupingShouldUseMaxTimestamp.html.snap-1.png b/__tests__/html2/activityOrdering/partGroupingShouldUseMaxTimestamp.html.snap-1.png new file mode 100644 index 0000000000..ad28b2f901 Binary files /dev/null and b/__tests__/html2/activityOrdering/partGroupingShouldUseMaxTimestamp.html.snap-1.png differ diff --git a/__tests__/html2/activityOrdering/partGroupingShouldUseMaxTimestamp.html.snap-2.png b/__tests__/html2/activityOrdering/partGroupingShouldUseMaxTimestamp.html.snap-2.png new file mode 100644 index 0000000000..09d67189a6 Binary files /dev/null and b/__tests__/html2/activityOrdering/partGroupingShouldUseMaxTimestamp.html.snap-2.png differ diff --git a/__tests__/html2/activityOrdering/partGroupingShouldUseMaxTimestamp.html.snap-3.png b/__tests__/html2/activityOrdering/partGroupingShouldUseMaxTimestamp.html.snap-3.png new file mode 100644 index 0000000000..7c6fc8356d Binary files /dev/null and b/__tests__/html2/activityOrdering/partGroupingShouldUseMaxTimestamp.html.snap-3.png differ diff --git a/__tests__/html2/activityOrdering/partGroupingShouldUseMaxTimestamp.html.snap-4.png b/__tests__/html2/activityOrdering/partGroupingShouldUseMaxTimestamp.html.snap-4.png new file mode 100644 index 0000000000..92b1aa1f5f Binary files /dev/null and b/__tests__/html2/activityOrdering/partGroupingShouldUseMaxTimestamp.html.snap-4.png differ diff --git a/__tests__/html2/livestream/activityOrder.entity.html.snap-5.png b/__tests__/html2/livestream/activityOrder.entity.html.snap-5.png index 549c38f207..4d471c12be 100644 Binary files a/__tests__/html2/livestream/activityOrder.entity.html.snap-5.png and b/__tests__/html2/livestream/activityOrder.entity.html.snap-5.png differ diff --git a/__tests__/html2/livestream/activityOrder.html b/__tests__/html2/livestream/activityOrder.html index 6e4b1cfe07..983e368a72 100644 --- a/__tests__/html2/livestream/activityOrder.html +++ b/__tests__/html2/livestream/activityOrder.html @@ -233,11 +233,11 @@ ); expect(pageElements.activityContents()[1]).toHaveProperty( 'textContent', - 'A quick brown fox jumped over the lazy dogs.' + 'Amet consequat enim incididunt excepteur aliquip magna duis et tempor.' ); expect(pageElements.activityContents()[2]).toHaveProperty( 'textContent', - 'Amet consequat enim incididunt excepteur aliquip magna duis et tempor.' + 'A quick brown fox jumped over the lazy dogs.' ); expect(pageElements.typingIndicator()).toBeFalsy(); await host.snapshot('local'); @@ -245,8 +245,8 @@ // THEN: Should have 3 activity keys. expect(currentActivityKeysWithId).toEqual([ [firstActivityKey, ['a-00001']], - [secondActivityKey, ['t-00001', 't-00002', 't-00003', 'a-00002']], - [thirdActivityKey, ['a-00003']] + [thirdActivityKey, ['a-00003']], + [secondActivityKey, ['t-00001', 't-00002', 't-00003', 'a-00002']] ]); }); diff --git a/__tests__/html2/livestream/activityOrder.html.snap-5.png b/__tests__/html2/livestream/activityOrder.html.snap-5.png index 549c38f207..4d471c12be 100644 Binary files a/__tests__/html2/livestream/activityOrder.html.snap-5.png and b/__tests__/html2/livestream/activityOrder.html.snap-5.png differ diff --git a/__tests__/html2/livestream/raceBetweenLivestreamAndTypingIndicator.html b/__tests__/html2/livestream/raceBetweenLivestreamAndTypingIndicator.html index 367dbcbb94..9345559ce3 100644 --- a/__tests__/html2/livestream/raceBetweenLivestreamAndTypingIndicator.html +++ b/__tests__/html2/livestream/raceBetweenLivestreamAndTypingIndicator.html @@ -109,7 +109,7 @@ addLivestreamingMetadata( { from: { id: 'bot', role: 'bot' }, - id: streamId, + id: `${streamId}:2`, text: 'A quick brown fox', type: 'typing' }, @@ -136,7 +136,7 @@ addLivestreamingMetadata( { from: { id: 'bot', role: 'bot' }, - id: streamId, + id: `${streamId}:3`, text: 'A quick brown fox jumped over the lazy dogs.', type: 'message' }, diff --git a/__tests__/html2/livestream/simultaneous.entity.html.snap-5.png b/__tests__/html2/livestream/simultaneous.entity.html.snap-5.png index c1a488c2b2..ddedb82f4f 100644 Binary files a/__tests__/html2/livestream/simultaneous.entity.html.snap-5.png and b/__tests__/html2/livestream/simultaneous.entity.html.snap-5.png differ diff --git a/__tests__/html2/livestream/simultaneous.html b/__tests__/html2/livestream/simultaneous.html index a92fe53d33..52c53e4488 100644 --- a/__tests__/html2/livestream/simultaneous.html +++ b/__tests__/html2/livestream/simultaneous.html @@ -239,19 +239,19 @@ 'textContent', 'Adipisicing cupidatat eu Lorem anim ut aute magna occaecat id cillum.' ); - expect(pageElements.activityContents()[1]).toHaveProperty( + expect(pageElements.activityContents()[1]).toHaveProperty('textContent', 'Falsches Üben von Xylophonmusik'); + expect(pageElements.activityContents()[2]).toHaveProperty( 'textContent', 'A quick brown fox jumped over the lazy dogs.' ); - expect(pageElements.activityContents()[2]).toHaveProperty('textContent', 'Falsches Üben von Xylophonmusik'); expect(pageElements.typingIndicator()).toBeFalsy(); await host.snapshot('local'); // THEN: Should have 3 activity keys. expect(currentActivityKeysWithId).toEqual([ [firstActivityKey, ['a-00001']], - [secondActivityKey, ['t-00001', 't-00002', 'a-00002']], - [thirdActivityKey, ['t-10001', 't-10002']] + [thirdActivityKey, ['t-10001', 't-10002']], + [secondActivityKey, ['t-00001', 't-00002', 'a-00002']] ]); // --- diff --git a/__tests__/html2/livestream/simultaneous.html.snap-5.png b/__tests__/html2/livestream/simultaneous.html.snap-5.png index c1a488c2b2..ddedb82f4f 100644 Binary files a/__tests__/html2/livestream/simultaneous.html.snap-5.png and b/__tests__/html2/livestream/simultaneous.html.snap-5.png differ diff --git a/__tests__/html2/part-grouping/folded.skip.html b/__tests__/html2/part-grouping/folded.skip.html index aa2c5706ee..e09ba1c61c 100644 --- a/__tests__/html2/part-grouping/folded.skip.html +++ b/__tests__/html2/part-grouping/folded.skip.html @@ -174,7 +174,7 @@ abstract: 'Considering equation solutions', position: 1, isPartOf: { - '@id': 'h-00001', + '@id': '_:h-00001', '@type': 'HowTo' } } @@ -192,7 +192,7 @@ abstract: 'Planning plot implementation', position: 2, isPartOf: { - '@id': 'h-00001', + '@id': '_:h-00001', '@type': 'HowTo' } } @@ -209,7 +209,7 @@ abstract: 'Writing plot code', position: 3, isPartOf: { - '@id': 'h-00001', + '@id': '_:h-00001', '@type': 'HowTo' } } @@ -237,7 +237,7 @@ } ], isPartOf: { - '@id': 'h-00001', + '@id': '_:h-00001', '@type': 'HowTo' }, } @@ -266,7 +266,7 @@ } ], isPartOf: { - '@id': 'h-00001', + '@id': '_:h-00001', '@type': 'HowTo' }, keywords: ['Collapsible'] diff --git a/__tests__/html2/part-grouping/index.html b/__tests__/html2/part-grouping/index.html index 80054ed331..e96495328e 100644 --- a/__tests__/html2/part-grouping/index.html +++ b/__tests__/html2/part-grouping/index.html @@ -234,7 +234,7 @@ abstract: 'Considering equation solutions', position: 1, isPartOf: { - '@id': 'h-00001', + '@id': '_:h-00001', '@type': 'HowTo' } } @@ -252,7 +252,7 @@ abstract: 'Planning plot implementation', position: 2, isPartOf: { - '@id': 'h-00001', + '@id': '_:h-00001', '@type': 'HowTo' } } @@ -269,7 +269,7 @@ abstract: 'Writing plot code', position: 3, isPartOf: { - '@id': 'h-00001', + '@id': '_:h-00001', '@type': 'HowTo' } } @@ -297,7 +297,7 @@ } ], isPartOf: { - '@id': 'h-00001', + '@id': '_:h-00001', '@type': 'HowTo' }, } @@ -325,7 +325,7 @@ } ], isPartOf: { - '@id': 'h-00001', + '@id': '_:h-00001', '@type': 'HowTo' }, keywords: ['Collapsible'] diff --git a/__tests__/html2/part-grouping/keyboard.html b/__tests__/html2/part-grouping/keyboard.html index 59a47efa29..0dc09d41c2 100644 --- a/__tests__/html2/part-grouping/keyboard.html +++ b/__tests__/html2/part-grouping/keyboard.html @@ -129,10 +129,10 @@ // Setup: Send all activities to create two groups. directLine.emulateIncomingActivity(createActivity('activity-1', 'Activity 1')); - directLine.emulateIncomingActivity(createActivity('g1-activity-1', 'Group 1, Activity 1', 'g-00001')); - directLine.emulateIncomingActivity(createActivity('g1-activity-2', 'Group 1, Activity 2', 'g-00001')); - directLine.emulateIncomingActivity(createActivity('g2-activity-1', 'Group 2, Activity 1', 'g-00002')); - directLine.emulateIncomingActivity(createActivity('g2-activity-2', 'Group 2, Activity 2', 'g-00002')); + directLine.emulateIncomingActivity(createActivity('g1-activity-1', 'Group 1, Activity 1', '_:g-00001')); + directLine.emulateIncomingActivity(createActivity('g1-activity-2', 'Group 1, Activity 2', '_:g-00001')); + directLine.emulateIncomingActivity(createActivity('g2-activity-1', 'Group 2, Activity 1', '_:g-00002')); + directLine.emulateIncomingActivity(createActivity('g2-activity-2', 'Group 2, Activity 2', '_:g-00002')); directLine.emulateIncomingActivity(createActivity('activity-2', 'Activity 2')); await pageConditions.numActivitiesShown(7); @@ -256,4 +256,4 @@ }); - \ No newline at end of file + diff --git a/__tests__/html2/part-grouping/navigation.html b/__tests__/html2/part-grouping/navigation.html index dfb3b2f93e..263a0ce000 100644 --- a/__tests__/html2/part-grouping/navigation.html +++ b/__tests__/html2/part-grouping/navigation.html @@ -127,10 +127,10 @@ // Setup: Send all activities. directLine.emulateIncomingActivity(createActivity('activity-1', 'Activity 1')); - directLine.emulateIncomingActivity(createActivity('g1-activity-1', 'Group 1, Activity 1', 'g-00001')); - directLine.emulateIncomingActivity(createActivity('g1-activity-2', 'Group 1, Activity 2', 'g-00001')); - directLine.emulateIncomingActivity(createActivity('g2-activity-1', 'Group 2, Activity 1', 'g-00002')); - directLine.emulateIncomingActivity(createActivity('g2-activity-2', 'Group 2, Activity 2', 'g-00002')); + directLine.emulateIncomingActivity(createActivity('g1-activity-1', 'Group 1, Activity 1', '_:g-00001')); + directLine.emulateIncomingActivity(createActivity('g1-activity-2', 'Group 1, Activity 2', '_:g-00001')); + directLine.emulateIncomingActivity(createActivity('g2-activity-1', 'Group 2, Activity 1', '_:g-00002')); + directLine.emulateIncomingActivity(createActivity('g2-activity-2', 'Group 2, Activity 2', '_:g-00002')); directLine.emulateIncomingActivity(createActivity('activity-2', 'Activity 2')); await pageConditions.numActivitiesShown(7); @@ -267,4 +267,4 @@ }); - \ No newline at end of file + diff --git a/__tests__/html2/part-grouping/position.html b/__tests__/html2/part-grouping/position.html index a164e4c0f1..3c19f0b4e0 100644 --- a/__tests__/html2/part-grouping/position.html +++ b/__tests__/html2/part-grouping/position.html @@ -146,7 +146,7 @@ name: 'Research', }, isPartOf: { - '@id': 'h-00001', + '@id': '_:h-00001', '@type': 'HowTo', } }; @@ -246,7 +246,7 @@ // When: updating an existing activity to remove its position directLine.emulateIncomingActivity(withPosition(activities.at(0), undefined, 'two updated to no position')); - // Then: show the activity moved to end (no position) + // Then: show the activity unmoved await pageConditions.numActivitiesShown(6); await host.snapshot('local'); @@ -254,7 +254,7 @@ // When: updating an activity's position from 3 to 1 directLine.emulateIncomingActivity(withPosition(activities.at(4), 1, 'three updated to one')); - // Then: show the activity moved to the beginning + // Then: show the activity moved before "four" await pageConditions.numActivitiesShown(6); await host.snapshot('local'); @@ -262,7 +262,7 @@ // When: updating an activity's position from 5 to 2 directLine.emulateIncomingActivity(withPosition(activities.at(3), 2, 'five updated to two')); - // Then: show the activity moved to position 2 + // Then: show the activity moved before "four" await pageConditions.numActivitiesShown(6); await host.snapshot('local'); @@ -271,7 +271,7 @@ directLine.emulateIncomingActivity(withPosition(activities.at(1), 6, 'four updated to six')); directLine.emulateIncomingActivity(withPosition(activities.at(2), 3, 'one updated to three')); - // Then: show all activities repositioned according to new positions + // Then: show the "one" activity moved before "foru" await pageConditions.numActivitiesShown(6); await host.snapshot('local'); }); diff --git a/__tests__/html2/part-grouping/position.html.snap-5.png b/__tests__/html2/part-grouping/position.html.snap-5.png index fb5301bda2..c8ba47c6a9 100644 Binary files a/__tests__/html2/part-grouping/position.html.snap-5.png and b/__tests__/html2/part-grouping/position.html.snap-5.png differ diff --git a/__tests__/html2/part-grouping/position.html.snap-6.png b/__tests__/html2/part-grouping/position.html.snap-6.png index c8dc262c35..543ed7ec8d 100644 Binary files a/__tests__/html2/part-grouping/position.html.snap-6.png and b/__tests__/html2/part-grouping/position.html.snap-6.png differ diff --git a/__tests__/html2/part-grouping/position.html.snap-7.png b/__tests__/html2/part-grouping/position.html.snap-7.png index 6014799f9b..61e6885d06 100644 Binary files a/__tests__/html2/part-grouping/position.html.snap-7.png and b/__tests__/html2/part-grouping/position.html.snap-7.png differ diff --git a/__tests__/html2/part-grouping/position.html.snap-8.png b/__tests__/html2/part-grouping/position.html.snap-8.png index 0023abc705..71ed67ca2c 100644 Binary files a/__tests__/html2/part-grouping/position.html.snap-8.png and b/__tests__/html2/part-grouping/position.html.snap-8.png differ diff --git a/__tests__/html2/part-grouping/status.html b/__tests__/html2/part-grouping/status.html index 4eaaefac6e..eb5052e0dc 100644 --- a/__tests__/html2/part-grouping/status.html +++ b/__tests__/html2/part-grouping/status.html @@ -144,7 +144,7 @@ name: 'Research', }, isPartOf: { - '@id': 'h-00001', + '@id': '_:h-00001', '@type': 'HowTo', } }; @@ -220,7 +220,9 @@ directLine.emulateIncomingActivity(withModifiers(activities.at(3), { position: 4, status: undefined, abstract: 'four' })); directLine.emulateIncomingActivity(withModifiers(activities.at(4), { position: 5, status: undefined, abstract: 'five' })); await pageConditions.numActivitiesShown(6); + await host.snapshot('local'); + // Become 1-5-2-3-4. directLine.emulateIncomingActivity(withModifiers(activities.at(4), { position: 1, status: 'Incomplete', abstract: 'five moved to one and in progress' })); // Then: show the activity moved to the new position with a loading indicator @@ -232,6 +234,7 @@ directLine.emulateIncomingActivity(withModifiers(activities.at(3), { status: 'Incomplete', abstract: 'four in progress' })); await host.snapshot('local'); + // Become 5-2-3-4-1. directLine.emulateIncomingActivity(withModifiers(activities.at(0), { position: 3, status: 'Incomplete', abstract: 'one (was done) moved to three and in progress' })); // Then: show the new item inserted with a loading indicator, and the other items shifted @@ -251,4 +254,4 @@ - \ No newline at end of file + diff --git a/__tests__/html2/part-grouping/status.html.snap-10.png b/__tests__/html2/part-grouping/status.html.snap-10.png new file mode 100644 index 0000000000..d8b9562bc1 Binary files /dev/null and b/__tests__/html2/part-grouping/status.html.snap-10.png differ diff --git a/__tests__/html2/part-grouping/status.html.snap-3.png b/__tests__/html2/part-grouping/status.html.snap-3.png index ed4b036f9d..03fcacbf39 100644 Binary files a/__tests__/html2/part-grouping/status.html.snap-3.png and b/__tests__/html2/part-grouping/status.html.snap-3.png differ diff --git a/__tests__/html2/part-grouping/status.html.snap-4.png b/__tests__/html2/part-grouping/status.html.snap-4.png index 2f01aa34ee..db7dad1a89 100644 Binary files a/__tests__/html2/part-grouping/status.html.snap-4.png and b/__tests__/html2/part-grouping/status.html.snap-4.png differ diff --git a/__tests__/html2/part-grouping/status.html.snap-5.png b/__tests__/html2/part-grouping/status.html.snap-5.png index f91343df28..ffa8b60e04 100644 Binary files a/__tests__/html2/part-grouping/status.html.snap-5.png and b/__tests__/html2/part-grouping/status.html.snap-5.png differ diff --git a/__tests__/html2/part-grouping/status.html.snap-6.png b/__tests__/html2/part-grouping/status.html.snap-6.png index 306c84d947..fcb0798bcf 100644 Binary files a/__tests__/html2/part-grouping/status.html.snap-6.png and b/__tests__/html2/part-grouping/status.html.snap-6.png differ diff --git a/__tests__/html2/part-grouping/status.html.snap-7.png b/__tests__/html2/part-grouping/status.html.snap-7.png index 39d6674b9b..7883ad9760 100644 Binary files a/__tests__/html2/part-grouping/status.html.snap-7.png and b/__tests__/html2/part-grouping/status.html.snap-7.png differ diff --git a/__tests__/html2/part-grouping/status.html.snap-8.png b/__tests__/html2/part-grouping/status.html.snap-8.png index 05377d1c8c..ed100624d1 100644 Binary files a/__tests__/html2/part-grouping/status.html.snap-8.png and b/__tests__/html2/part-grouping/status.html.snap-8.png differ diff --git a/__tests__/html2/part-grouping/status.html.snap-9.png b/__tests__/html2/part-grouping/status.html.snap-9.png index 7fdc9715ba..05377d1c8c 100644 Binary files a/__tests__/html2/part-grouping/status.html.snap-9.png and b/__tests__/html2/part-grouping/status.html.snap-9.png differ diff --git a/__tests__/html2/typing/typingIndicator.html b/__tests__/html2/typing/typingIndicator.html index 287dcc5241..2df415548d 100644 --- a/__tests__/html2/typing/typingIndicator.html +++ b/__tests__/html2/typing/typingIndicator.html @@ -61,20 +61,13 @@ await directLine.emulateIncomingActivity({ from: { id: 'u-00001', name: 'Bot', role: 'bot' }, id: 'a-00002', - text: 'Excepteur enim tempor ex do magna elit consectetur elit incididunt.', - type: 'message' - }); - - await directLine.emulateIncomingActivity({ - from: { id: 'u-00001', name: 'Bot', role: 'bot' }, - id: 'a-00003', text: 'Ad minim fugiat sint et laboris consectetur eu ut in nisi fugiat cillum est labore.', type: 'message' }); await directLine.emulateIncomingActivity({ from: { id: 'u-00001', name: 'Bot', role: 'bot' }, - id: 'a-00004', + id: 'a-00003', text: 'Est voluptate eiusmod ad Lorem irure amet sint ea aliqua labore eu do nostrud exercitation.', type: 'message' }); @@ -90,7 +83,7 @@ } : {}), from: { id: 'u-00001', name: 'Bot', role: 'bot' }, - id: 'a-00002', + id: 'a-00004', type: 'typing' }); @@ -107,7 +100,7 @@ ...(isLivestream ? { channelData: { - streamId: 'a-00002', + streamId: 'a-00004', streamType: 'final' } } @@ -119,7 +112,7 @@ } }), from: { id: 'u-00001', name: 'Bot', role: 'bot' }, - id: 'a-00002', + id: 'a-00004', type: 'typing' }); diff --git a/__tests__/html2/typing/typingIndicator.scroll.html b/__tests__/html2/typing/typingIndicator.scroll.html index 4e111f7806..1004ddfcc7 100644 --- a/__tests__/html2/typing/typingIndicator.scroll.html +++ b/__tests__/html2/typing/typingIndicator.scroll.html @@ -61,20 +61,13 @@ await directLine.emulateIncomingActivity({ from: { id: 'u-00001', name: 'Bot', role: 'bot' }, id: 'a-00002', - text: 'Excepteur enim tempor ex do magna elit consectetur elit incididunt. Amet reprehenderit cupidatat amet velit nostrud esse est dolor proident ex ut deserunt. Velit veniam minim esse laboris irure esse duis dolor sint culpa. Sit ullamco eiusmod consectetur enim elit cillum sit elit irure ut commodo ad. Cillum ad mollit est labore culpa proident sunt tempor culpa pariatur elit laborum.', - type: 'message' - }); - - await directLine.emulateIncomingActivity({ - from: { id: 'u-00001', name: 'Bot', role: 'bot' }, - id: 'a-00003', text: 'Ad minim fugiat sint et laboris consectetur eu ut in nisi fugiat cillum est labore. Et proident tempor veniam ex est incididunt Lorem. Culpa sit id eu voluptate.', type: 'message' }); await directLine.emulateIncomingActivity({ from: { id: 'u-00001', name: 'Bot', role: 'bot' }, - id: 'a-00004', + id: 'a-00003', text: 'Est voluptate eiusmod ad Lorem irure amet sint ea aliqua labore eu do nostrud exercitation. Non adipisicing non amet laborum. Anim fugiat minim cupidatat consequat ipsum minim ex mollit commodo ut aliqua quis consequat dolore. Cupidatat tempor laborum consectetur eiusmod cillum do consequat ad pariatur amet magna aliquip occaecat officia.', type: 'message' }); @@ -90,7 +83,7 @@ } : {}), from: { id: 'u-00001', name: 'Bot', role: 'bot' }, - id: 'a-00002', + id: 'a-00004', type: 'typing' }); @@ -107,7 +100,7 @@ ...(isLivestream ? { channelData: { - streamId: 'a-00002', + streamId: 'a-00004', streamType: 'final' } } @@ -119,7 +112,7 @@ } }), from: { id: 'u-00001', name: 'Bot', role: 'bot' }, - id: 'a-00002', + id: 'a-00005', type: 'typing' }); diff --git a/__tests__/html2/typing/typingIndicator.shouldNotRevive.html b/__tests__/html2/typing/typingIndicator.shouldNotRevive.html index b1c1b396a8..30f690cbfd 100644 --- a/__tests__/html2/typing/typingIndicator.shouldNotRevive.html +++ b/__tests__/html2/typing/typingIndicator.shouldNotRevive.html @@ -63,7 +63,7 @@ // THEN: Should display the message. await pageConditions.numActivitiesShown(1); - // THEN: Should not display typing indicator. + // THEN: Should not display typing indicator because typing started at t=2 ms but now is t = 5002 ms. await pageConditions.typingIndicatorHidden(); }); diff --git a/docs/SORTING.md b/docs/SORTING.md index 7e51f3fb14..fbbba90d64 100644 --- a/docs/SORTING.md +++ b/docs/SORTING.md @@ -1,3 +1,62 @@ +## How are activities grouped? + +Before sorting, activities are grouped as a unit: + +- Activities within the same part grouping are inserted next to each other + - Order is based on the `position` property + - Logical timestamp of the part grouping is the "maximum logical timestamp" of all parts + - Parts can contain livestream session or ungrouped activities +- Activities within the same livestream session will be inserted next to each other + - Order is based on the `streamSequence` property + - Logical timestamp of the livestream session is the logical timestamp of the finalizing activity (if any), or the logical timestamp of the first activity + - In other words, livestream will be reordered when they are inserted for the first time, or when they are finalized + - Interim updates will not reorder the livestream to avoid too much flickering +- All other activities are not grouped, they are individual units + +## How are activities sorted? + +Short answer: activities are sorted by logical timestamp when inserted. + +Long answer: + +- For grouped activities (such as part groupings or livestream sessions), all activities within the group are treated as a unit +- For ungrouped activities, each activity is a unit on its own + +Units are sorted using their logical timestamp on insertion. If the unit is already in the chat history, they will be removed before being re-inserted. + +## What is logical timestamp? + +By logical timestamp, in JavaScript: + +``` +logicalTimestamp = activity.channelData['webchat:sequence-id'] ?? +new Date(activity.timestamp) || +new Date(activity.localTimestamp); +``` + +1. `activity.channelData['webchat:sequence-id']: number` + - This field represents the order of the activity in the chat history and can be a sparse number; + - This field is OPTIONAL, but RECOMMENDED; + - This field MUST be an integer number; + - This field MUST be a unique number assigned by the chat service; + - This field SHOULD be consistent across all members of the conversation. +1. Otherwise, `activity.timestamp: string` + - This field represents the server timestamp of the activity; + - This field is REQUIRED; + - This field MUST be an ISO date-time string, for example, `2000-01-23T12:34:56.000Z`; + - This field MUST be assigned by the chat service, a.k.a. server timestamp; + - This field SHOULD have resolution up to 1 millisecond; + - This field MUST be the same across all members of the conversation. +1. Otherwise, `activity.localTimestamp: string` + - This field represents the local timestamp of an outgoing activity; + - This field is REQUIRED for all outgoing activities; + - This field MUST be an ISO date-time string, for example, `2000-01-23T12:34:56.000Z`; + - This field MUST be assigned by the sender, a.k.a. Web Chat; + - This field SHOULD have resolution up to 1 millisecond; + - This field MUST be the same across all members of the conversation. + +# Archived documentation + +The following documentation is archived. + ## How activities are being sorted? Web Chat sort activities based on a few sort keys stamped by the chat service, with the following fallback order: @@ -61,6 +120,8 @@ Combining the algorithms above, Web Chat has the following assumptions ## Related read +- Pull requests + - [Grouped activities are sorted as a single unit](https://github.com/microsoft/BotFramework-WebChat/pull/5635) - Posting an activity, [`sagas/postActivitySaga.ts`](https://github.com/microsoft/BotFramework-WebChat/blob/main/packages/core/src/sagas/postActivitySaga.ts) - Inserting an activity into the chat history, [`reducers/activities.ts`](https://github.com/microsoft/BotFramework-WebChat/blob/main/packages/core/src/reducers/activities.ts) - Activity type, [`types/WebChatActivity.ts`](https://github.com/microsoft/BotFramework-WebChat/blob/main/packages/core/src/types/WebChatActivity.ts) diff --git a/package-lock.json b/package-lock.json index b081f77d23..cd61a91504 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19669,6 +19669,7 @@ "@msinternal/botframework-webchat-base": "0.0.0-0", "@msinternal/botframework-webchat-core-graph": "0.0.0-0", "@msinternal/botframework-webchat-tsconfig": "0.0.0-0", + "@testduet/given-when-then": "^0.1.1-main.28754e6", "@types/jest": "^30.0.0", "@types/node": "^24.1.0", "babel-plugin-istanbul": "^7.0.0", diff --git a/packages/api/src/providers/ActivitySendStatus/ActivitySendStatusComposer.tsx b/packages/api/src/providers/ActivitySendStatus/ActivitySendStatusComposer.tsx index 5fb8eeb8b2..8bcbad8148 100644 --- a/packages/api/src/providers/ActivitySendStatus/ActivitySendStatusComposer.tsx +++ b/packages/api/src/providers/ActivitySendStatus/ActivitySendStatusComposer.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useMemo, useRef, type ReactNode } from 'react'; +import { querySendStatusFromOutgoingActivity } from 'botframework-webchat-core/activity'; import { useActivities, usePonyfill } from '../../hooks/index'; import useForceRender from '../../hooks/internal/useForceRender'; @@ -43,9 +44,11 @@ const ActivitySendStatusComposer = ({ children }: Readonly<{ children?: ReactNod if (key) { const { - channelData: { state, 'webchat:send-status': sendStatus } + channelData: { state } } = activity; + const sendStatus = querySendStatusFromOutgoingActivity(activity); + // `channelData.state` is being deprecated in favor of `channelData['webchat:send-status']`. // Please refer to #4362 for details. Remove on or after 2024-07-31. const rectifiedSendStatus = sendStatus || (state === 'sent' ? 'sent' : 'sending'); diff --git a/packages/api/src/providers/ActivityTyping/ActivityTypingComposer.tsx b/packages/api/src/providers/ActivityTyping/ActivityTypingComposer.tsx index 709df6a7b1..6dda9f0e2d 100644 --- a/packages/api/src/providers/ActivityTyping/ActivityTypingComposer.tsx +++ b/packages/api/src/providers/ActivityTyping/ActivityTypingComposer.tsx @@ -1,4 +1,5 @@ import { getActivityLivestreamingMetadata, type WebChatActivity } from 'botframework-webchat-core'; +import { queryReceivedAtFromActivity } from 'botframework-webchat-core/activity'; import { iteratorFind } from 'iter-fest'; import React, { memo, useCallback, useMemo, type ReactNode } from 'react'; @@ -50,7 +51,7 @@ const ActivityTypingComposer = ({ children }: Props) => { } // A normal message activity, or final activity (which could be "message" or "typing"), will remove the typing indicator. - const receivedAt = activity.channelData?.webChat?.receivedAt || Date.now(); + const receivedAt = queryReceivedAtFromActivity(activity) ?? Date.now(); const livestreamingMetadata = getActivityLivestreamingMetadata(activity); const typingState = new Map(prevTypingState); @@ -74,7 +75,7 @@ const ActivityTypingComposer = ({ children }: Props) => { mutableEntry.livestreamActivities.set( sessionId, Object.freeze({ - firstReceivedAt: Date.now(), + firstReceivedAt: mutableEntry.livestreamActivities.get(sessionId)?.firstReceivedAt ?? receivedAt, ...mutableEntry.livestreamActivities.get(sessionId), activity, contentful: livestreamingMetadata.type !== 'contentless', @@ -88,7 +89,7 @@ const ActivityTypingComposer = ({ children }: Props) => { mutableEntry.typingIndicator = Object.freeze({ activity, duration: numberWithInfinity(activity.channelData.webChat?.styleOptions?.typingAnimationDuration), - firstReceivedAt: mutableEntry.typingIndicator?.firstReceivedAt || Date.now(), + firstReceivedAt: mutableEntry.typingIndicator?.firstReceivedAt ?? receivedAt, lastReceivedAt: receivedAt }); } diff --git a/packages/api/src/providers/ActivityTyping/private/useReduceActivities.spec.tsx b/packages/api/src/providers/ActivityTyping/private/useReduceActivities.spec.tsx index f77832b992..977822826e 100644 --- a/packages/api/src/providers/ActivityTyping/private/useReduceActivities.spec.tsx +++ b/packages/api/src/providers/ActivityTyping/private/useReduceActivities.spec.tsx @@ -4,6 +4,7 @@ import { render, type RenderResult } from '@testing-library/react'; import type { WebChatActivity } from 'botframework-webchat-core'; +import type { LocalId } from 'botframework-webchat-core/activity'; import React, { type ComponentType } from 'react'; import { type useActivities as UseActivitiesType } from '../../../hooks'; import type UseReduceActivitiesType from './useReduceActivities'; @@ -12,7 +13,7 @@ type UseReduceActivitiesFn = Parameters[0]; const ACTIVITY_TEMPLATE = { channelData: { - 'webchat:internal:id': 'a-00001', + 'webchat:internal:local-id': '_:a-00001' as LocalId, 'webchat:internal:position': 0, 'webchat:sequence-id': 0, 'webchat:send-status': undefined diff --git a/packages/api/src/providers/GroupActivities/private/createDefaultGroupActivitiesMiddleware.ts b/packages/api/src/providers/GroupActivities/private/createDefaultGroupActivitiesMiddleware.ts index 5fdb7503a7..72dcb16f81 100644 --- a/packages/api/src/providers/GroupActivities/private/createDefaultGroupActivitiesMiddleware.ts +++ b/packages/api/src/providers/GroupActivities/private/createDefaultGroupActivitiesMiddleware.ts @@ -1,5 +1,8 @@ import { getOrgSchemaMessage, type GlobalScopePonyfill, type WebChatActivity } from 'botframework-webchat-core'; +import { IdentifierSchema } from 'botframework-webchat-core/graph'; +import { safeParse } from 'valibot'; +import { querySendStatusFromOutgoingActivity } from 'botframework-webchat-core/activity'; import type GroupActivitiesMiddleware from '../../../types/GroupActivitiesMiddleware'; import { type SendStatus } from '../../../types/SendStatus'; @@ -26,13 +29,9 @@ function bin(items: readonly T[], grouping: (last: T, current: T) => boolean) return Object.freeze(bins); } -function sending(activity: WebChatActivity): SendStatus | undefined { +function isSending(activity: WebChatActivity): SendStatus | undefined { if (activity.from.role === 'user') { - const { - channelData: { 'webchat:send-status': sendStatus } - } = activity; - - return sendStatus; + return querySendStatusFromOutgoingActivity(activity); } } @@ -42,7 +41,7 @@ function createShouldGroupTimestamp(groupTimestamp: boolean | number, { Date }: // Hide timestamp for all activities. return true; } else if (activityX && activityY) { - if (sending(activityX) !== sending(activityY)) { + if (isSending(activityX) !== isSending(activityY)) { return false; } @@ -94,13 +93,19 @@ export default function createDefaultGroupActivitiesMiddleware({ ? next => ({ activities }) => { const messages = activities.map(activity => [getOrgSchemaMessage(activity.entities), activity] as const); + return { ...next({ activities }), - part: bin( - messages, - ([last], [current]) => - typeof last?.isPartOf?.['@id'] === 'string' && last.isPartOf['@id'] === current?.isPartOf?.['@id'] - ).map(bin => bin.map(([, activity]) => activity)) + part: bin(messages, ([last], [current]) => { + const lastPartIdResult = safeParse(IdentifierSchema, last?.isPartOf?.['@id']); + const currentPartIdResult = safeParse(IdentifierSchema, current?.isPartOf?.['@id']); + + return ( + lastPartIdResult.success && + currentPartIdResult.success && + lastPartIdResult.output === currentPartIdResult.output + ); + }).map(bin => bin.map(([, activity]) => activity)) }; } : undefined diff --git a/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/PartGrouping.tsx b/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/PartGrouping.tsx index 2eeb95813a..211b992c31 100644 --- a/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/PartGrouping.tsx +++ b/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/PartGrouping.tsx @@ -1,5 +1,6 @@ import { reactNode } from '@msinternal/botframework-webchat-react-valibot'; -import { getOrgSchemaMessage, type WebChatActivity } from 'botframework-webchat-core'; +import { getActivityLivestreamingMetadata, getOrgSchemaMessage, type WebChatActivity } from 'botframework-webchat-core'; +import { IdentifierSchema } from 'botframework-webchat-core/graph'; import React, { Fragment, memo, useMemo } from 'react'; import { array, @@ -42,15 +43,14 @@ function PartGrouping(props: PartGroupingProps) { ( activities .toReversed() - .find( - activity => 'streamType' in activity.channelData && activity.channelData.streamType === 'informative' - ) || lastActivity + .find(activity => getActivityLivestreamingMetadata(activity)?.type === 'informative message') || + lastActivity )?.entities ), [activities, lastActivity] ); - const isGroup = activities.length > 1 || !!lastMessage?.isPartOf?.['@id']; + const isGroup = activities.length > 1 || safeParse(IdentifierSchema, lastMessage?.isPartOf?.['@id']).success; return isGroup ? ( {children} diff --git a/packages/core/activity.js b/packages/core/activity.js new file mode 100644 index 0000000000..5182a42795 --- /dev/null +++ b/packages/core/activity.js @@ -0,0 +1,3 @@ +// This is required for Webpack 4 which does not support named exports. +// eslint-disable-next-line no-undef +module.exports = require('./dist/botframework-webchat-core.activity.js'); diff --git a/packages/core/package.json b/packages/core/package.json index 77214260e0..40713b88ce 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -15,6 +15,16 @@ "default": "./dist/botframework-webchat-core.js" } }, + "./activity": { + "import": { + "types": "./dist/botframework-webchat-core.activity.d.mts", + "default": "./dist/botframework-webchat-core.activity.mjs" + }, + "require": { + "types": "./dist/botframework-webchat-core.activity.d.ts", + "default": "./dist/botframework-webchat-core.activity.js" + } + }, "./graph": { "import": { "types": "./dist/botframework-webchat-core.graph.d.mts", @@ -84,7 +94,7 @@ "precommit:eslint": "../../node_modules/.bin/eslint --report-unused-disable-directives --max-warnings 0", "precommit:typecheck": "tsc --project ./src --emitDeclarationOnly false --esModuleInterop true --noEmit --pretty false", "preversion": "../../scripts/npm/preversion.sh", - "start": "../../scripts/npm/notify-build.sh \"src\" \"../base/package.json\" \"../core-graph/package.json\"", + "start": "../../scripts/npm/notify-build.sh \"src\" \"../core-graph/package.json\"", "test:tsd": "tsd" }, "engines": { @@ -113,6 +123,7 @@ "@msinternal/botframework-webchat-base": "0.0.0-0", "@msinternal/botframework-webchat-core-graph": "0.0.0-0", "@msinternal/botframework-webchat-tsconfig": "0.0.0-0", + "@testduet/given-when-then": "^0.1.1-main.28754e6", "@types/jest": "^30.0.0", "@types/node": "^24.1.0", "babel-plugin-istanbul": "^7.0.0", diff --git a/packages/core/src/activity/index.ts b/packages/core/src/activity/index.ts new file mode 100644 index 0000000000..1435d5c47a --- /dev/null +++ b/packages/core/src/activity/index.ts @@ -0,0 +1,19 @@ +export { getLocalIdFromActivity, LocalIdSchema, type LocalId } from '../reducers/activities/sort/property/LocalId'; +export { + getPositionFromActivity, + PositionSchema, + queryPositionFromActivity, + type Position +} from '../reducers/activities/sort/property/Position'; +export { + getReceivedAtFromActivity, + queryReceivedAtFromActivity, + ReceivedAtSchema, + type ReceivedAt +} from '../reducers/activities/sort/property/ReceivedAt'; +export { + getSendStatusFromOutgoingActivity, + querySendStatusFromOutgoingActivity, + SendStatusSchema, + type SendStatus +} from '../reducers/activities/sort/property/SendStatus'; diff --git a/packages/core/src/createReducer.ts b/packages/core/src/createReducer.ts index 3e9c9894b1..57085213dc 100644 --- a/packages/core/src/createReducer.ts +++ b/packages/core/src/createReducer.ts @@ -1,7 +1,7 @@ import { combineReducers } from 'redux'; +import combineActivitiesReducer from './reducers/activities/combineActivitiesReducer'; import connectivityStatus from './reducers/connectivityStatus'; -import createActivitiesReducer from './reducers/createActivitiesReducer'; import createInternalReducer from './reducers/createInternalReducer'; import createNotificationsReducer from './reducers/createNotificationsReducer'; import createTypingReducer from './reducers/createTypingReducer'; @@ -21,23 +21,25 @@ import suggestedActionsOriginActivity from './reducers/suggestedActionsOriginAct import type { GlobalScopePonyfill } from './types/GlobalScopePonyfill'; export default function createReducer(ponyfill: GlobalScopePonyfill) { - return combineReducers({ - activities: createActivitiesReducer(ponyfill), - connectivityStatus, - dictateInterims, - dictateState, - internal: createInternalReducer(ponyfill), - language, - notifications: createNotificationsReducer(ponyfill), - readyState, - referenceGrammarID, - sendBoxAttachments, - sendBoxValue, - sendTimeout, - sendTypingIndicator, - shouldSpeakIncomingActivity, - suggestedActions, - suggestedActionsOriginActivity, - typing: createTypingReducer(ponyfill) - }); + return combineActivitiesReducer( + ponyfill, + combineReducers({ + connectivityStatus, + dictateInterims, + dictateState, + internal: createInternalReducer(ponyfill), + language, + notifications: createNotificationsReducer(ponyfill), + readyState, + referenceGrammarID, + sendBoxAttachments, + sendBoxValue, + sendTimeout, + sendTypingIndicator, + shouldSpeakIncomingActivity, + suggestedActions, + suggestedActionsOriginActivity, + typing: createTypingReducer(ponyfill) + }) + ); } diff --git a/packages/core/src/graph/createGraphFromStore.ts b/packages/core/src/graph/createGraphFromStore.ts index 2eb1c102be..fa86efd3e2 100644 --- a/packages/core/src/graph/createGraphFromStore.ts +++ b/packages/core/src/graph/createGraphFromStore.ts @@ -1,10 +1,8 @@ import { SlantGraph, SlantNodeSchema } from '@msinternal/botframework-webchat-core-graph'; -import type { IterableElement } from 'type-fest'; import { parse } from 'valibot'; import type createStore from '../createStore'; -import type createActivitiesReducer from '../reducers/createActivitiesReducer'; - -type Activity = IterableElement>>; +import type { Activity } from '../reducers/activities/sort/types'; +import { getLocalIdFromActivity, getPositionFromActivity } from '../activity'; function createGraphFromStore(store: ReturnType): SlantGraph { const graph = new SlantGraph(); @@ -53,8 +51,8 @@ function createGraphFromStore(store: ReturnType): SlantGraph from: { role } } = activity; - const permanentId = activity.channelData['webchat:internal:id']; - const position = activity.channelData['webchat:internal:position']; + const localId = getLocalIdFromActivity(activity); + const position = getPositionFromActivity(activity); // TODO: Should use Person and more specific than just "Others". const sender = @@ -71,7 +69,7 @@ function createGraphFromStore(store: ReturnType): SlantGraph graph.upsert({ '@context': 'https://schema.org', - '@id': `_:${permanentId}`, + '@id': localId, '@type': ['Message', `urn:microsoft:webchat:direct-line-activity`], encodingFormat: @@ -96,7 +94,7 @@ function createGraphFromStore(store: ReturnType): SlantGraph } else if (typeof activity.type === 'string') { graph.upsert({ '@context': 'https://schema.org', - '@id': `_:${permanentId}`, + '@id': localId, '@type': Object.freeze(['urn:microsoft:webchat:direct-line-activity']), identifier: activity.id && `urn:microsoft:webchat:direct-line-activity:id:${activity.id}`, position, diff --git a/packages/core/src/reducers/activities/combineActivitiesReducer.ts b/packages/core/src/reducers/activities/combineActivitiesReducer.ts new file mode 100644 index 0000000000..841686822e --- /dev/null +++ b/packages/core/src/reducers/activities/combineActivitiesReducer.ts @@ -0,0 +1,56 @@ +import type { ActionFromReducersMapObject, combineReducers, Reducer, StateFromReducersMapObject } from 'redux'; +import type { GlobalScopePonyfill } from '../../types/GlobalScopePonyfill'; +import type { WebChatActivity } from '../../types/WebChatActivity'; +import isForbiddenPropertyName from '../../utils/isForbiddenPropertyName'; +import createGroupedActivitiesReducer, { + type GroupedActivitiesAction, + type GroupedActivitiesState +} from './createGroupedActivitiesReducer'; + +type ActivitiesState = { + activities: readonly WebChatActivity[]; + groupedActivities: GroupedActivitiesState; +}; + +/** + * Creates a reducer by combining slice `activities` and `groupedActivities` to an existing sliced reducer. + * + * @param ponyfill + * @param existingSlicedReducer + * @returns + */ +export default function combineActivitiesReducer( + ponyfill: GlobalScopePonyfill, + existingSlicedReducer: ReturnType> +): Reducer & ActivitiesState, ActionFromReducersMapObject & GroupedActivitiesAction> { + type ExistingState = StateFromReducersMapObject; + type ExistingAction = ActionFromReducersMapObject; + + const groupedActivitiesReducer = createGroupedActivitiesReducer(ponyfill); + + return function ( + state: (ExistingState & ActivitiesState) | undefined, + action: ExistingAction & GroupedActivitiesAction + ): ExistingState & ActivitiesState { + const { activities: _activities, groupedActivities, ...existingState } = state ?? {}; + const nextState = existingSlicedReducer(existingState as ExistingState, action); + const nextGroupedActivities = groupedActivitiesReducer(groupedActivities, action); + + const existingStateEntries = Object.entries(existingState); + const nextStateEntries = Object.entries(nextState); + + const hasChanged = + !state || + !Object.is(state.groupedActivities, nextGroupedActivities) || + existingStateEntries.length !== nextStateEntries.length || + existingStateEntries.some( + // Denylisting forbidden property names. + // eslint-disable-next-line security/detect-object-injection + ([key, value]) => !Object.is(value, isForbiddenPropertyName(key) ? undefined : (nextState as any)[key]) + ); + + return hasChanged + ? { ...nextState, activities: nextGroupedActivities.sortedActivities, groupedActivities: nextGroupedActivities } + : state; + }; +} diff --git a/packages/core/src/reducers/activities/createGroupedActivitiesReducer.ts b/packages/core/src/reducers/activities/createGroupedActivitiesReducer.ts new file mode 100644 index 0000000000..4edc7c7360 --- /dev/null +++ b/packages/core/src/reducers/activities/createGroupedActivitiesReducer.ts @@ -0,0 +1,292 @@ +/* eslint-disable complexity */ +/* eslint no-magic-numbers: ["error", { "ignore": [0, 1, -1] }] */ + +// @ts-ignore No @types/simple-update-in +import updateIn from 'simple-update-in'; +import { v4 } from 'uuid'; + +import { DELETE_ACTIVITY } from '../../actions/deleteActivity'; +import { INCOMING_ACTIVITY } from '../../actions/incomingActivity'; +import { MARK_ACTIVITY } from '../../actions/markActivity'; +import { + POST_ACTIVITY_FULFILLED, + POST_ACTIVITY_IMPEDED, + POST_ACTIVITY_PENDING, + POST_ACTIVITY_REJECTED +} from '../../actions/postActivity'; +import { SENDING, SEND_FAILED, SENT } from '../../types/internal/SendStatus'; + +import type { Reducer } from 'redux'; +import type { DeleteActivityAction } from '../../actions/deleteActivity'; +import type { IncomingActivityAction } from '../../actions/incomingActivity'; +import type { MarkActivityAction } from '../../actions/markActivity'; +import type { + PostActivityFulfilledAction, + PostActivityImpededAction, + PostActivityPendingAction, + PostActivityRejectedAction +} from '../../actions/postActivity'; +import type { GlobalScopePonyfill } from '../../types/GlobalScopePonyfill'; +import type { WebChatActivity } from '../../types/WebChatActivity'; +import patchActivity from './patchActivity'; +import deleteActivityByLocalId from './sort/deleteActivityByLocalId'; +import { generateLocalIdInActivity, getLocalIdFromActivity, setLocalIdInActivity } from './sort/property/LocalId'; +import { getPositionFromActivity, setPositionInActivity } from './sort/property/Position'; +import { setReceivedAtInActivity } from './sort/property/ReceivedAt'; +import { querySendStatusFromOutgoingActivity, setSendStatusInOutgoingActivity } from './sort/property/SendStatus'; +import queryLocalIdAByActivityId from './sort/queryLocalIdByActivityId'; +import queryLocalIdAByClientActivityId from './sort/queryLocalIdByClientActivityId'; +import type { State } from './sort/types'; +import updateActivityChannelData, { + updateActivityChannelDataInternalSkipNameCheck +} from './sort/updateActivityChannelData'; +import upsert, { INITIAL_STATE } from './sort/upsert'; + +type GroupedActivitiesAction = + | DeleteActivityAction + | IncomingActivityAction + | MarkActivityAction + | PostActivityFulfilledAction + | PostActivityImpededAction + | PostActivityPendingAction + | PostActivityRejectedAction; + +type GroupedActivitiesState = State; + +const DEFAULT_STATE: GroupedActivitiesState = INITIAL_STATE; + +function getClientActivityID(activity: WebChatActivity): string | undefined { + return activity.channelData?.clientActivityID; +} + +function createGroupedActivitiesReducer( + ponyfill: GlobalScopePonyfill +): Reducer { + return function activities( + state: GroupedActivitiesState = DEFAULT_STATE, + action: GroupedActivitiesAction + ): GroupedActivitiesState { + switch (action.type) { + case DELETE_ACTIVITY: { + console.warn( + 'botframework-webchat: Delete activity is being deprecated, please build your own chat adapter instead.' + ); + + const localId = queryLocalIdAByActivityId(state, action.payload.activityID); + + if (localId) { + state = deleteActivityByLocalId(state, localId); + } + + break; + } + + case MARK_ACTIVITY: { + // We need to deprecate this, however, it is currently using by speech. + + const localId = queryLocalIdAByActivityId(state, action.payload.activityID); + + if (localId) { + state = updateActivityChannelData(state, localId, action.payload.name, action.payload.value); + } + + break; + } + + case POST_ACTIVITY_PENDING: { + let { + payload: { activity } + } = action; + + // Patch activity so the outgoing blob: URL is not re-downloadable. + // Related to /__tests__/html2/accessibility/liveRegion/attachment/file. + + // Why not re-downloadable? + // - When the activity echo back, the URL will be dummy (not downloadable) + // - Outgoing -> echo back, the UI will be "downloadable" and flash to "not downloadable" in a short amount of time + + // TODO: [P0] Consider modify attachment middleware so all outgoing activity is not downloadable. + + activity = patchActivity(activity, ponyfill); + activity = setReceivedAtInActivity(activity, ponyfill.Date.now()); + activity = generateLocalIdInActivity(activity); + // `channelData.state` is being deprecated in favor of `channelData['webchat:send-status']`. + // Please refer to #4362 for details. Remove on or after 2024-07-31. + activity = updateIn(activity, ['channelData', 'state'], () => SENDING); + activity = setSendStatusInOutgoingActivity(activity, SENDING); + + state = upsert(ponyfill, state, activity); + + break; + } + + case POST_ACTIVITY_IMPEDED: { + const localId = queryLocalIdAByClientActivityId(state, action.meta.clientActivityID); + + if (localId) { + state = updateActivityChannelDataInternalSkipNameCheck( + state, + localId, + // `channelData.state` is being deprecated in favor of `channelData['webchat:send-status']`. + // Please refer to #4362 for details. Remove on or after 2024-07-31. + 'state', + SEND_FAILED + ); + } + + break; + } + + case POST_ACTIVITY_REJECTED: { + const localId = queryLocalIdAByClientActivityId(state, action.meta.clientActivityID); + + if (localId) { + state = updateActivityChannelDataInternalSkipNameCheck(state, localId, 'state', SEND_FAILED); + state = updateActivityChannelDataInternalSkipNameCheck(state, localId, 'webchat:send-status', SEND_FAILED); + } + + break; + } + + case POST_ACTIVITY_FULFILLED: { + const localId = queryLocalIdAByClientActivityId(state, action.meta.clientActivityID); + + const existingActivity = localId && state.activityMap.get(localId)?.activity; + + if (!existingActivity) { + throw new Error( + 'botframework-webchat-internal: On POST_ACTIVITY_FULFILLED, there is no activities with same client activity ID' + ); + } + + // We will replace the outgoing activity with the version from the server + let activity = patchActivity(action.payload.activity, ponyfill); + + activity = updateIn( + activity, + // `channelData.state` is being deprecated in favor of `channelData['webchat:send-status']`. + // Please refer to #4362 for details. Remove on or after 2024-07-31. + ['channelData', 'state'], + () => SENT + ); + + activity = setSendStatusInOutgoingActivity(activity, SENT); + activity = setLocalIdInActivity(activity, localId); + + // Keep existing position. + activity = setPositionInActivity(activity, getPositionFromActivity(existingActivity)); + + // Compare the INCOMING_ACTIVITY below: + // - POST_ACTIVITY_FULFILLED will mark send status as SENT + // - INCOMING_ACTIVITY will not change send status and leave it as-is + state = upsert(ponyfill, state, activity); + + break; + } + + case INCOMING_ACTIVITY: { + let { + payload: { activity } + } = action; + + activity = patchActivity(activity, ponyfill); + + // Clean internal properties if they were passed from chat adapter. + // These properties should not be passed from external systems. + activity = setLocalIdInActivity(activity, undefined); + activity = setPositionInActivity(activity, undefined); + activity = setSendStatusInOutgoingActivity(activity, undefined); + + // If the incoming activity is an echo back, we should keep the existing `channelData['webchat:send-status']` field. + // + // Otherwise, it will fail following scenario: + // + // 1. Send an activity to the service + // 2. Service echoed back the activity + // 3. Service did NOT return `postActivity` call + // - EXPECT: `channelData['webchat:send-status']` should be "sending". + // - ACTUAL: `channelData['webchat:send-status']` is `undefined` because the activity get overwritten by the echo back activity. + // The echo back activity contains no `channelData['webchat:send-status']`. + // + // While we are looking out for the scenario above, we should also look at the following scenarios: + // + // 1. Service restore chat history, including activities sent from the user. These activities has the following characteristics: + // - They do not have `channelData['webchat:send-status']`; + // - They do not have an ongoing `postActivitySaga`; + // - They should not previously appear in the chat history. + // 2. We need to mark these activities as "sent". + // + // In the future, when we revamp our object model, we could use a different signal so we don't need the code below, for example: + // + // - If `activity.id` is set, it is "sent", because the chat service assigned an ID to the activity; + // - If `activity.id` is not set, it is either "sending" or "send failed"; + // - If `activity.channelData['webchat:send-failed-reason']` is set, it is "send failed" with the reason, otherwise; + // - It is sending. + if (activity.from.role === 'user') { + const { id } = activity; + const clientActivityID = getClientActivityID(activity); + + const existingLocalId = clientActivityID + ? queryLocalIdAByClientActivityId(state, clientActivityID) + : id + ? queryLocalIdAByActivityId(state, id) + : undefined; + const existingActivity = existingLocalId && state.activityMap.get(existingLocalId)?.activity; + + if (existingActivity) { + activity = setLocalIdInActivity(activity, getLocalIdFromActivity(existingActivity)); + + const existingSendStatus = querySendStatusFromOutgoingActivity(existingActivity); + + if (typeof existingSendStatus !== 'undefined') { + activity = setSendStatusInOutgoingActivity(activity, existingSendStatus); + } + } else { + activity = generateLocalIdInActivity(activity); + + // If there are no existing activity, probably this activity is restored from chat history. + // All outgoing activities restored from service means they arrived at the service successfully. + // Thus, we are marking them as "sent". + activity = setSendStatusInOutgoingActivity(activity, SENT); + } + } else { + let { id } = activity; + + if (!id) { + id = v4(); + + console.warn( + 'botframework-webchat: Incoming activity must have "id" field set, assigning a random value as ID', + { + activity, + newActivityId: id + } + ); + + activity = updateIn(activity, ['id'], () => id); + } + + const existingLocalId = queryLocalIdAByActivityId(state, id); + + if (existingLocalId) { + activity = setLocalIdInActivity(activity, existingLocalId); + } else { + activity = generateLocalIdInActivity(activity); + } + } + + state = upsert(ponyfill, state, activity); + + break; + } + + default: + break; + } + + return state; + }; +} + +export default createGroupedActivitiesReducer; +export type { GroupedActivitiesAction, GroupedActivitiesState }; diff --git a/packages/core/src/reducers/activities/patchActivity.ts b/packages/core/src/reducers/activities/patchActivity.ts new file mode 100644 index 0000000000..e1669535cd --- /dev/null +++ b/packages/core/src/reducers/activities/patchActivity.ts @@ -0,0 +1,37 @@ +// @ts-ignore No @types/simple-update-in +import updateIn from 'simple-update-in'; +import type { GlobalScopePonyfill } from '../../types/GlobalScopePonyfill'; +import type { WebChatActivity } from '../../types/WebChatActivity'; +import { setReceivedAtInActivity } from './sort/property/ReceivedAt'; + +const DIRECT_LINE_PLACEHOLDER_URL = + 'https://docs.botframework.com/static/devportal/client/images/bot-framework-default-placeholder.png'; + +/** + * Patches incoming activity. + * + * @returns A patched activity. + */ +export default function patchActivity(activity: WebChatActivity, { Date }: GlobalScopePonyfill): WebChatActivity { + // Direct Line channel will return a placeholder image for the user-uploaded image. + // As observed, the URL for the placeholder image is https://docs.botframework.com/static/devportal/client/images/bot-framework-default-placeholder.png. + // To make our code simpler, we are removing the value if "contentUrl" is pointing to a placeholder image. + + // TODO: [P2] #2869 This "contentURL" removal code should be moved to DirectLineJS adapter. + + // Also, if the "contentURL" starts with "blob:", this means the user is uploading a file (the URL is constructed by URL.createObjectURL) + // Although the copy/reference of the file is temporary in-memory, to make the UX consistent across page refresh, we do not allow the user to re-download the file either. + + activity = updateIn(activity, ['attachments', () => true, 'contentUrl'], (contentUrl: string) => { + if (contentUrl !== DIRECT_LINE_PLACEHOLDER_URL && !/^blob:/iu.test(contentUrl)) { + return contentUrl; + } + + return undefined; + }); + + activity = updateIn(activity, ['channelData'], (channelData: any) => ({ ...channelData })); + activity = setReceivedAtInActivity(activity, Date.now()); + + return activity; +} diff --git a/packages/core/src/reducers/activities/sort/deleteActivityByLocalId.activity.spec.ts b/packages/core/src/reducers/activities/sort/deleteActivityByLocalId.activity.spec.ts new file mode 100644 index 0000000000..6135f77e71 --- /dev/null +++ b/packages/core/src/reducers/activities/sort/deleteActivityByLocalId.activity.spec.ts @@ -0,0 +1,144 @@ +/* eslint-disable no-restricted-globals */ +import { expect } from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import deleteActivityByLocalId from './deleteActivityByLocalId'; +import { getLocalIdFromActivity, type LocalId } from './property/LocalId'; +import { type Activity, type ActivityMapEntry, type SortedChatHistoryEntry } from './types'; +import upsert, { INITIAL_STATE } from './upsert'; + +function activityToExpectation(activity: Activity, expectedPosition: number = expect.any(Number) as any): Activity { + return { + ...activity, + channelData: { + ...activity.channelData, + 'webchat:internal:position': expectedPosition + } as any + }; +} + +scenario('deleting activity', bdd => { + const activity1: Activity = { + channelData: { + 'webchat:internal:local-id': '_:a-00001' as LocalId, + 'webchat:internal:position': 0, + 'webchat:send-status': undefined + }, + from: { id: 'bot', role: 'bot' }, + id: 'a-00001', + text: 'Hello, World!', + timestamp: new Date(1_000).toISOString(), + type: 'message' + }; + + const activity2: Activity = { + channelData: { + 'webchat:internal:local-id': '_:a-00002' as LocalId, + 'webchat:internal:position': 0, + 'webchat:send-status': undefined + }, + from: { id: 'bot', role: 'bot' }, + id: 'a-00002', + text: 'Aloha!', + timestamp: new Date(2_000).toISOString(), + type: 'message' + }; + + bdd + .given('an initial state', () => INITIAL_STATE) + .when('2 activities are upserted', state => upsert({ Date }, upsert({ Date }, state, activity1), activity2)) + .then('should have 2 activities', (_, state) => { + expect(state.activityIdToLocalIdMap).toHaveProperty('size', 2); + expect(state.activityMap).toHaveProperty('size', 2); + expect(state.howToGroupingMap).toHaveProperty('size', 0); + expect(state.livestreamSessionMap).toHaveProperty('size', 0); + expect(state.sortedActivities).toHaveLength(2); + expect(state.sortedChatHistoryList).toHaveLength(2); + }) + .when('the first activity is deleted', (_, state) => + deleteActivityByLocalId(state, getLocalIdFromActivity(state.sortedActivities[0]!)) + ) + .then('should have 1 activity', (_, state) => { + expect(state.activityIdToLocalIdMap).toHaveProperty('size', 1); + expect(state.activityMap).toHaveProperty('size', 1); + expect(state.howToGroupingMap).toHaveProperty('size', 0); + expect(state.livestreamSessionMap).toHaveProperty('size', 0); + expect(state.sortedActivities).toHaveLength(1); + expect(state.sortedChatHistoryList).toHaveLength(1); + }) + .and('`activityIdToLocalIdMap` should match', (_, state) => { + expect(state.activityIdToLocalIdMap).toEqual(new Map([['a-00002', '_:a-00002' as LocalId]])); + }) + .and('`activityMap` should match', (_, state) => { + expect(state.activityMap).toEqual( + new Map([ + [ + '_:a-00002' as LocalId, + { + activity: activityToExpectation(activity2, 2_000), + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: 2_000, + type: 'activity' + } satisfies ActivityMapEntry + ] + ]) + ); + }) + .and('`sortedActivities` should match', (_, state) => { + expect(state.sortedActivities).toEqual([activityToExpectation(activity2, 2_000)]); + }) + .and('`sortedChatHistoryList` should match', (_, state) => { + expect(state.sortedChatHistoryList).toEqual([ + { + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: 2_000, + type: 'activity' + } satisfies SortedChatHistoryEntry + ]); + }); +}); + +scenario('deleting an outgoing activity', bdd => { + const activity1: Activity = { + channelData: { + clientActivityID: 'caid-00001', + 'webchat:internal:local-id': '_:a-00001' as LocalId, + 'webchat:internal:position': 0, + 'webchat:send-status': 'sending' + } as any, + from: { id: 'user', role: 'user' }, + id: 'a-00001', + text: 'Hello, World!', + timestamp: new Date(1_000).toISOString(), + type: 'message' + }; + + bdd + .given('an initial state', () => INITIAL_STATE) + .when('1 activity are upserted', state => upsert({ Date }, state, activity1)) + .then('should have 1 activity', (_, state) => { + expect(state.activityIdToLocalIdMap).toHaveProperty('size', 1); + expect(state.activityMap).toHaveProperty('size', 1); + expect(state.clientActivityIdToLocalIdMap).toHaveProperty('size', 1); + expect(state.howToGroupingMap).toHaveProperty('size', 0); + expect(state.livestreamSessionMap).toHaveProperty('size', 0); + expect(state.sortedActivities).toHaveLength(1); + expect(state.sortedChatHistoryList).toHaveLength(1); + }) + .and('`clientActivityIdToLocalMap` should match', (_, state) => { + expect(state.clientActivityIdToLocalIdMap).toEqual( + new Map([['caid-00001', '_:a-00001' as LocalId]]) + ); + }) + .when('the first activity is deleted', (_, state) => + deleteActivityByLocalId(state, getLocalIdFromActivity(state.sortedActivities[0]!)) + ) + .then('should have no activities', (_, state) => { + expect(state.activityIdToLocalIdMap).toHaveProperty('size', 0); + expect(state.activityMap).toHaveProperty('size', 0); + expect(state.clientActivityIdToLocalIdMap).toHaveProperty('size', 0); + expect(state.howToGroupingMap).toHaveProperty('size', 0); + expect(state.livestreamSessionMap).toHaveProperty('size', 0); + expect(state.sortedActivities).toHaveLength(0); + expect(state.sortedChatHistoryList).toHaveLength(0); + }); +}); diff --git a/packages/core/src/reducers/activities/sort/deleteActivityByLocalId.howTo.spec.ts b/packages/core/src/reducers/activities/sort/deleteActivityByLocalId.howTo.spec.ts new file mode 100644 index 0000000000..f9ea25566c --- /dev/null +++ b/packages/core/src/reducers/activities/sort/deleteActivityByLocalId.howTo.spec.ts @@ -0,0 +1,148 @@ +/* eslint-disable no-restricted-globals */ +import { expect } from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import { parse } from 'valibot'; +import type { WebChatActivity } from '../../../types/WebChatActivity'; +import deleteActivityByLocalId from './deleteActivityByLocalId'; +import { getLocalIdFromActivity, LocalIdSchema, type LocalId } from './property/LocalId'; +import type { + Activity, + ActivityMapEntry, + HowToGroupingId, + HowToGroupingMapEntry, + SortedChatHistoryEntry +} from './types'; +import upsert, { INITIAL_STATE } from './upsert'; + +type SingularOrPlural = T | readonly T[]; + +function activityToExpectation(activity: Activity, expectedPosition: number = expect.any(Number) as any): Activity { + return { + ...activity, + channelData: { + ...activity.channelData, + 'webchat:internal:position': expectedPosition + } as any + }; +} + +function buildActivity( + activity: { id: string; text: string; timestamp: string }, + messageEntity: { isPartOf: SingularOrPlural<{ '@id': string; '@type': string }>; position: number } | undefined +): WebChatActivity { + const { id } = activity; + + return { + channelData: { + 'webchat:internal:local-id': parse(LocalIdSchema, `_:${id}`), + 'webchat:internal:position': 0, + 'webchat:send-status': undefined + }, + ...(messageEntity + ? { + entities: [ + { + '@context': 'https://schema.org', + '@id': '', + '@type': 'Message', + type: 'https://schema.org/Message', + ...messageEntity + } as any + ] + } + : {}), + from: { id: 'bot', role: 'bot' }, + type: 'message', + ...activity + }; +} + +scenario('deleting an activity in the same grouping', bdd => { + const activity1 = buildActivity( + { id: 'a-00001', text: 'Hello, World!', timestamp: new Date(1_000).toISOString() }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 1 } + ); + + const activity2 = buildActivity( + { id: 'a-00002', text: 'Aloha!', timestamp: new Date(2_000).toISOString() }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 2 } + ); + + bdd + .given('an initial state', () => INITIAL_STATE) + .when('2 activities are upserted', state => upsert({ Date }, upsert({ Date }, state, activity1), activity2)) + .then('should have 2 activities', (_, state) => { + expect(state.activityMap).toHaveProperty('size', 2); + expect(state.howToGroupingMap).toHaveProperty('size', 1); + expect(state.livestreamSessionMap).toHaveProperty('size', 0); + expect(state.sortedActivities).toHaveLength(2); + expect(state.sortedChatHistoryList).toHaveLength(1); + }) + .when('the second activity is deleted', (_, state) => + deleteActivityByLocalId(state, getLocalIdFromActivity(state.sortedActivities[1])) + ) + .then('should have 1 activity', (_, state) => { + expect(state.activityMap).toHaveProperty('size', 1); + expect(state.howToGroupingMap).toHaveProperty('size', 1); + expect(state.livestreamSessionMap).toHaveProperty('size', 0); + expect(state.sortedActivities).toHaveLength(1); + expect(state.sortedChatHistoryList).toHaveLength(1); + }) + .and('`activityMap` should match', (_, state) => { + expect(state.activityMap).toEqual( + new Map([ + [ + '_:a-00001' as LocalId, + { + activity: activityToExpectation(activity1, 1_000), + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + type: 'activity' + } satisfies ActivityMapEntry + ] + ]) + ); + }) + .and('`howToGroupingMap` should match', (_, state) => { + expect(state.howToGroupingMap).toEqual( + new Map([ + [ + '_:how-to:00001' as HowToGroupingId, + { + logicalTimestamp: 1_000, + partList: [ + { + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + position: 1, + type: 'activity' + } + ] + } + ] + ]) + ); + }) + .and('`sortedActivities` should match', (_, state) => { + expect(state.sortedActivities).toEqual([activityToExpectation(activity1, 1_000)]); + }) + .and('`sortedChatHistoryList` should match', (_, state) => { + expect(state.sortedChatHistoryList).toEqual([ + { + howToGroupingId: '_:how-to:00001' as HowToGroupingId, + logicalTimestamp: 1_000, + type: 'how to grouping' + } satisfies SortedChatHistoryEntry + ]); + }) + .when('the first activity is deleted', (_, state) => + deleteActivityByLocalId(state, getLocalIdFromActivity(state.sortedActivities[0])) + ) + .then('should have no activities', (_, state) => { + expect(state.activityMap).toHaveProperty('size', 0); + expect(state.howToGroupingMap).toHaveProperty('size', 0); + expect(state.livestreamSessionMap).toHaveProperty('size', 0); + expect(state.sortedActivities).toHaveLength(0); + expect(state.sortedChatHistoryList).toHaveLength(0); + }); +}); diff --git a/packages/core/src/reducers/activities/sort/deleteActivityByLocalId.howToWithLivestream.spec.ts b/packages/core/src/reducers/activities/sort/deleteActivityByLocalId.howToWithLivestream.spec.ts new file mode 100644 index 0000000000..153b5c6793 --- /dev/null +++ b/packages/core/src/reducers/activities/sort/deleteActivityByLocalId.howToWithLivestream.spec.ts @@ -0,0 +1,248 @@ +/* eslint-disable no-restricted-globals */ +import { expect } from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import { parse } from 'valibot'; +import type { WebChatActivity } from '../../../types/WebChatActivity'; +import deleteActivityByLocalId from './deleteActivityByLocalId'; +import { getLocalIdFromActivity, LocalIdSchema, type LocalId } from './property/LocalId'; +import { + HowToGroupingId, + type HowToGroupingMapEntry, + type LivestreamSessionId, + type LivestreamSessionMapEntry, + type LivestreamSessionMapEntryActivityEntry +} from './types'; +import upsert, { INITIAL_STATE } from './upsert'; + +type SingularOrPlural = T | readonly T[]; + +function buildActivity( + activity: + | { + channelData: + | { + streamId: string; + streamSequence?: never; + streamType: 'final'; + } + | undefined; + id: string; + text: string; + timestamp: string; + type: 'message'; + } + | { + channelData: + | { + streamId: string; + streamSequence: number; + streamType: 'informative' | 'streaming'; + } + | { + streamId?: never; + streamSequence: 1; + streamType: 'informative' | 'streaming'; + } + | undefined; + id: string; + text: string; + timestamp: string; + type: 'typing'; + }, + messageEntity: { isPartOf: SingularOrPlural<{ '@id': string; '@type': string }>; position: number } | undefined +): WebChatActivity { + const { id } = activity; + + return { + ...(messageEntity + ? { + entities: [ + { + '@context': 'https://schema.org', + '@id': '', + '@type': 'Message', + type: 'https://schema.org/Message', + ...messageEntity + } as any + ] + } + : {}), + from: { id: 'bot', role: 'bot' }, + ...activity, + channelData: { + 'webchat:internal:local-id': parse(LocalIdSchema, `_:${id}`), + 'webchat:internal:position': 0, + 'webchat:send-status': undefined, + ...activity.channelData + } + } as any; +} + +scenario('delete livestream activities in part grouping', bdd => { + const activity1 = buildActivity( + { + channelData: { streamSequence: 1, streamType: 'streaming' }, + id: 'a-00001', + text: 'A quick', + timestamp: new Date(1_000).toISOString(), + type: 'typing' + }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 1 } + ); + + const activity2 = buildActivity( + { + channelData: { streamId: 'a-00001', streamSequence: 2, streamType: 'streaming' }, + id: 'a-00002', + text: 'A quick brown fox', + timestamp: new Date(2_000).toISOString(), + type: 'typing' + }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 1 } + ); + + const activity3 = buildActivity( + { + channelData: { streamId: 'a-00001', streamType: 'final' }, + id: 'a-00003', + text: 'A quick brown fox jumped over the lazy dogs.', + timestamp: new Date(3_000).toISOString(), + type: 'message' + }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 1 } + ); + + const activity4 = buildActivity( + { + channelData: undefined, + id: 'a-00004', + text: 'Hello, World!', + timestamp: new Date(4_000).toISOString(), + type: 'message' + }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 2 } + ); + + bdd + .given('an initial state', () => INITIAL_STATE) + .when('4 activities are upserted', state => + upsert( + { Date }, + upsert({ Date }, upsert({ Date }, upsert({ Date }, state, activity1), activity2), activity3), + activity4 + ) + ) + .then('should have 4 activities', (_, state) => { + expect(state.activityMap).toHaveProperty('size', 4); + expect(state.howToGroupingMap).toHaveProperty('size', 1); + expect(state.livestreamSessionMap).toHaveProperty('size', 1); + expect(state.sortedActivities).toHaveLength(4); + expect(state.sortedChatHistoryList).toHaveLength(1); + }) + .when('the last livestream activity is delete', (_, state) => + deleteActivityByLocalId(state, getLocalIdFromActivity(state.sortedActivities[2])) + ) + .then('should have 3 activities', (_, state) => { + expect(state.activityMap).toHaveProperty('size', 3); + expect(state.howToGroupingMap).toHaveProperty('size', 1); + expect(state.livestreamSessionMap).toHaveProperty('size', 1); + expect(state.sortedActivities).toHaveLength(3); + expect(state.sortedChatHistoryList).toHaveLength(1); + }) + .and('`livestreamSessionMap` should match', (_, state) => { + expect(state.livestreamSessionMap).toEqual( + new Map([ + [ + 'a-00001' as LivestreamSessionId, + { + activities: [ + { + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + sequenceNumber: 1, + type: 'activity' + } satisfies LivestreamSessionMapEntryActivityEntry, + { + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: 2_000, + sequenceNumber: 2, + type: 'activity' + } satisfies LivestreamSessionMapEntryActivityEntry + ], + finalized: false, + logicalTimestamp: 1_000 + } + ] + ]) + ); + }) + .and('`howToGroupingMap` should match', (_, state) => { + expect(state.howToGroupingMap).toEqual( + new Map([ + [ + '_:how-to:00001' as HowToGroupingId, + { + logicalTimestamp: 4_000, + partList: [ + { + livestreamSessionId: 'a-00001' as LivestreamSessionId, + logicalTimestamp: 1_000, + position: 1, + type: 'livestream session' + }, + { + activityLocalId: '_:a-00004' as LocalId, + logicalTimestamp: 4_000, + position: 2, + type: 'activity' + } + ] + } + ] + ]) + ); + }) + .when('all livestream activities are delete', (_, state) => + deleteActivityByLocalId( + deleteActivityByLocalId(state, getLocalIdFromActivity(state.sortedActivities[1])), + getLocalIdFromActivity(state.sortedActivities[0]) + ) + ) + .then('should have 1 activity', (_, state) => { + expect(state.activityMap).toHaveProperty('size', 1); + expect(state.howToGroupingMap).toHaveProperty('size', 1); + expect(state.livestreamSessionMap).toHaveProperty('size', 0); + expect(state.sortedActivities).toHaveLength(1); + expect(state.sortedChatHistoryList).toHaveLength(1); + }) + .and('`howToGroupingMap` should match', (_, state) => { + expect(state.howToGroupingMap).toEqual( + new Map([ + [ + '_:how-to:00001' as HowToGroupingId, + { + logicalTimestamp: 4_000, + partList: [ + { + activityLocalId: '_:a-00004' as LocalId, + logicalTimestamp: 4_000, + position: 2, + type: 'activity' + } + ] + } + ] + ]) + ); + }) + .when('all activities are delete', (_, state) => + deleteActivityByLocalId(state, getLocalIdFromActivity(state.sortedActivities[0])) + ) + .then('should have no activities', (_, state) => { + expect(state.activityMap).toHaveProperty('size', 0); + expect(state.howToGroupingMap).toHaveProperty('size', 0); + expect(state.livestreamSessionMap).toHaveProperty('size', 0); + expect(state.sortedActivities).toHaveLength(0); + expect(state.sortedChatHistoryList).toHaveLength(0); + }); +}); diff --git a/packages/core/src/reducers/activities/sort/deleteActivityByLocalId.livestream.spec.ts b/packages/core/src/reducers/activities/sort/deleteActivityByLocalId.livestream.spec.ts new file mode 100644 index 0000000000..1bbbe0454a --- /dev/null +++ b/packages/core/src/reducers/activities/sort/deleteActivityByLocalId.livestream.spec.ts @@ -0,0 +1,156 @@ +/* eslint-disable no-restricted-globals */ +import { expect } from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import { parse } from 'valibot'; +import type { WebChatActivity } from '../../../types/WebChatActivity'; +import deleteActivityByLocalId from './deleteActivityByLocalId'; +import { getLocalIdFromActivity, LocalIdSchema, type LocalId } from './property/LocalId'; +import { LivestreamSessionId, type LivestreamSessionMapEntry } from './types'; +import upsert, { INITIAL_STATE } from './upsert'; + +function buildActivity( + activity: + | { + channelData: + | { + streamId: string; + streamSequence?: never; + streamType: 'final'; + } + | undefined; + id: string; + text: string; + timestamp: string; + type: 'message'; + } + | { + channelData: + | { + streamId: string; + streamSequence: number; + streamType: 'informative' | 'streaming'; + } + | { + streamId?: never; + streamSequence: 1; + streamType: 'informative' | 'streaming'; + } + | undefined; + id: string; + text: string; + timestamp: string; + type: 'typing'; + } +): WebChatActivity { + const { id } = activity; + + return { + from: { id: 'bot', role: 'bot' }, + ...activity, + channelData: { + 'webchat:internal:local-id': parse(LocalIdSchema, `_:${id}`), + 'webchat:internal:position': 0, + 'webchat:send-status': undefined, + ...activity.channelData + } + } as any; +} + +scenario('deleting an activity', bdd => { + const activity1 = buildActivity({ + channelData: { + streamSequence: 1, + streamType: 'streaming' + }, + id: 'a-00001', + text: 'A quick', + timestamp: new Date(1_000).toISOString(), + type: 'typing' + }); + + const activity2 = buildActivity({ + channelData: { + streamId: 'a-00001', + streamSequence: 2, + streamType: 'streaming' + }, + id: 'a-00002', + text: 'A quick brown fox', + timestamp: new Date(2_000).toISOString(), + type: 'typing' + }); + + const activity3 = buildActivity({ + channelData: { + streamId: 'a-00001', + streamType: 'final' + }, + id: 'a-00003', + text: 'A quick brown fox jumped over the lazy dogs.', + timestamp: new Date(3_000).toISOString(), + type: 'message' + }); + + bdd + .given('an initial state', () => INITIAL_STATE) + .when('3 activities are upserted', state => + upsert({ Date }, upsert({ Date }, upsert({ Date }, state, activity1), activity2), activity3) + ) + .then('should have 3 activities', (_, state) => { + expect(state.activityMap).toHaveProperty('size', 3); + expect(state.howToGroupingMap).toHaveProperty('size', 0); + expect(state.livestreamSessionMap).toHaveProperty('size', 1); + expect(state.sortedActivities).toHaveLength(3); + expect(state.sortedChatHistoryList).toHaveLength(1); + }) + .when('the last activity is delete', (_, state) => + deleteActivityByLocalId(state, getLocalIdFromActivity(state.sortedActivities[2])) + ) + .then('should have 2 activities', (_, state) => { + expect(state.activityMap).toHaveProperty('size', 2); + expect(state.howToGroupingMap).toHaveProperty('size', 0); + expect(state.livestreamSessionMap).toHaveProperty('size', 1); + expect(state.sortedActivities).toHaveLength(2); + expect(state.sortedChatHistoryList).toHaveLength(1); + }) + .and('`livestreamSessionMap` should match', (_, state) => { + expect(state.livestreamSessionMap).toEqual( + new Map([ + [ + 'a-00001' as LivestreamSessionId, + { + activities: [ + { + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + sequenceNumber: 1, + type: 'activity' + }, + { + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: 2_000, + sequenceNumber: 2, + type: 'activity' + } + ], + finalized: false, + logicalTimestamp: 1_000 + } + ] + ]) + ); + }) + .when('all activities are delete', (_, state) => + deleteActivityByLocalId( + deleteActivityByLocalId(state, getLocalIdFromActivity(state.sortedActivities[1])), + getLocalIdFromActivity(state.sortedActivities[0]) + ) + ) + .then('should have no activities', (_, state) => { + expect(state.activityMap).toHaveProperty('size', 0); + expect(state.howToGroupingMap).toHaveProperty('size', 0); + expect(state.livestreamSessionMap).toHaveProperty('size', 0); + expect(state.sortedActivities).toHaveLength(0); + expect(state.sortedChatHistoryList).toHaveLength(0); + }); +}); diff --git a/packages/core/src/reducers/activities/sort/deleteActivityByLocalId.ts b/packages/core/src/reducers/activities/sort/deleteActivityByLocalId.ts new file mode 100644 index 0000000000..87d6e1e02c --- /dev/null +++ b/packages/core/src/reducers/activities/sort/deleteActivityByLocalId.ts @@ -0,0 +1,177 @@ +import computePartListTimestamp from './private/computePartListTimestamp'; +import computeSortedActivities from './private/computeSortedActivities'; +import type { LocalId } from './property/LocalId'; +import type { LivestreamSessionMapEntry, State } from './types'; + +export default function deleteActivityByLocalId(state: State, localId: LocalId): State { + const nextActivityIdToLocalIdMap = new Map(state.activityIdToLocalIdMap); + const nextActivityMap = new Map(state.activityMap); + const nextClientActivityIdToLocalIdMap = new Map(state.clientActivityIdToLocalIdMap); + const nextHowToGroupingMap = new Map(state.howToGroupingMap); + const nextLivestreamSessionMap = new Map(state.livestreamSessionMap); + let nextSortedChatHistoryList = Array.from(state.sortedChatHistoryList); + + if (!nextActivityMap.delete(localId)) { + throw new Error(`botframework-webchat: Cannot find activity with local ID "${localId}" to delete`); + } + + for (const entry of nextActivityIdToLocalIdMap) { + if (entry[1] === localId) { + nextActivityIdToLocalIdMap.delete(entry[0]); + + break; + } + } + + for (const entry of nextClientActivityIdToLocalIdMap) { + if (entry[1] === localId) { + nextClientActivityIdToLocalIdMap.delete(entry[0]); + + break; + } + } + + for (const [howToGroupingId, entry] of nextHowToGroupingMap) { + const partIndex = entry.partList.findIndex(part => part.type === 'activity' && part.activityLocalId === localId); + + if (~partIndex) { + const nextPartList = Array.from(entry.partList); + + nextPartList.splice(partIndex, 1); + + if (nextPartList.length) { + const nextHowToGroupingMapEntry = Object.freeze({ + ...entry, + logicalTimestamp: computePartListTimestamp(nextPartList), + partList: Object.freeze(nextPartList) + }); + + nextHowToGroupingMap.set(howToGroupingId, nextHowToGroupingMapEntry); + + nextSortedChatHistoryList = nextSortedChatHistoryList.map(entry => { + if (entry.type === 'how to grouping' && entry.howToGroupingId === howToGroupingId) { + return { + howToGroupingId, + logicalTimestamp: nextHowToGroupingMapEntry.logicalTimestamp, + type: 'how to grouping' + }; + } + + return entry; + }); + } else { + nextHowToGroupingMap.delete(howToGroupingId); + + const sortedChatHistoryListIndex = nextSortedChatHistoryList.findIndex( + entry => entry.type === 'how to grouping' && entry.howToGroupingId === howToGroupingId + ); + + ~sortedChatHistoryListIndex && nextSortedChatHistoryList.splice(sortedChatHistoryListIndex, 1); + } + } + } + + for (const [livestreamSessionId, livestreamSessionMapEntry] of nextLivestreamSessionMap) { + const activityIndex = livestreamSessionMapEntry.activities.findIndex( + activity => activity.activityLocalId === localId + ); + + if (~activityIndex) { + const nextActivities = Array.from(livestreamSessionMapEntry.activities); + + nextActivities.splice(activityIndex, 1); + + if (nextActivities.length) { + // eslint-disable-next-line no-magic-numbers + const lastActivity = nextActivities.at(-1); + const finalActivity = lastActivity?.sequenceNumber === Infinity ? lastActivity : undefined; + + const logicalTimestamp = finalActivity + ? finalActivity.logicalTimestamp + : nextActivities.at(0)?.logicalTimestamp; + + const nextLivestreamSessionMapEntry: LivestreamSessionMapEntry = { + ...livestreamSessionMapEntry, + activities: nextActivities, + finalized: !!finalActivity, + logicalTimestamp + }; + + nextLivestreamSessionMap.set(livestreamSessionId, nextLivestreamSessionMapEntry); + + for (const [howToGroupingId, entry] of nextHowToGroupingMap) { + let changed = false; + + const nextPartList = entry.partList.map(part => { + if (part.type === 'livestream session' && part.livestreamSessionId === livestreamSessionId) { + changed = true; + + return { ...part, logicalTimestamp }; + } + + return part; + }); + + if (changed) { + nextHowToGroupingMap.set(howToGroupingId, { + ...entry, + logicalTimestamp: computePartListTimestamp(nextPartList), + partList: nextPartList + }); + } + } + } else { + nextLivestreamSessionMap.delete(livestreamSessionId); + + const sortedChatHistoryListIndex = nextSortedChatHistoryList.findIndex( + entry => entry.type === 'livestream session' && entry.livestreamSessionId === livestreamSessionId + ); + + ~sortedChatHistoryListIndex && nextSortedChatHistoryList.splice(sortedChatHistoryListIndex, 1); + + for (const [howToGroupingId, entry] of nextHowToGroupingMap) { + const partIndex = entry.partList.findIndex( + part => part.type === 'livestream session' && part.livestreamSessionId === livestreamSessionId + ); + + if (~partIndex) { + const nextPartList = Array.from(entry.partList); + + nextPartList.splice(partIndex, 1); + + nextHowToGroupingMap.set(howToGroupingId, { + ...entry, + logicalTimestamp: computePartListTimestamp(nextPartList), + partList: nextPartList + }); + } + } + } + } + } + + nextSortedChatHistoryList = nextSortedChatHistoryList.filter(entry => { + if (entry.type === 'activity' && entry.activityLocalId === localId) { + return false; + } + + return true; + }); + + const nextSortedActivities = computeSortedActivities({ + activityMap: nextActivityMap, + howToGroupingMap: nextHowToGroupingMap, + livestreamSessionMap: nextLivestreamSessionMap, + sortedChatHistoryList: nextSortedChatHistoryList + }); + + return Object.freeze({ + activityIdToLocalIdMap: Object.freeze(nextActivityIdToLocalIdMap), + activityMap: Object.freeze(nextActivityMap), + clientActivityIdToLocalIdMap: Object.freeze(nextClientActivityIdToLocalIdMap), + howToGroupingMap: Object.freeze(nextHowToGroupingMap), + livestreamSessionMap: Object.freeze(nextLivestreamSessionMap), + sortedActivities: Object.freeze(nextSortedActivities), + sortedChatHistoryList: Object.freeze(nextSortedChatHistoryList) + } satisfies State); +} diff --git a/packages/core/src/reducers/activities/sort/private/computePartListTimestamp.ts b/packages/core/src/reducers/activities/sort/private/computePartListTimestamp.ts new file mode 100644 index 0000000000..5e7a212a1a --- /dev/null +++ b/packages/core/src/reducers/activities/sort/private/computePartListTimestamp.ts @@ -0,0 +1,9 @@ +import type { HowToGroupingMapPartEntry } from '../types'; + +export default function computePartListTimestamp(partList: readonly HowToGroupingMapPartEntry[]): number | undefined { + return partList.reduce( + (max, { logicalTimestamp }) => + typeof logicalTimestamp === 'undefined' ? max : Math.max(max ?? -Infinity, logicalTimestamp), + undefined + ); +} diff --git a/packages/core/src/reducers/activities/sort/private/computeSortedActivities.ts b/packages/core/src/reducers/activities/sort/private/computeSortedActivities.ts new file mode 100644 index 0000000000..07c3d1f9e7 --- /dev/null +++ b/packages/core/src/reducers/activities/sort/private/computeSortedActivities.ts @@ -0,0 +1,38 @@ +import type { Activity, State } from '../types'; + +export default function computeSortedActivities( + temporalState: Pick +): Activity[] { + const { activityMap, howToGroupingMap, livestreamSessionMap, sortedChatHistoryList } = temporalState; + + return Array.from( + (function* () { + for (const sortedEntry of sortedChatHistoryList) { + if (sortedEntry.type === 'activity') { + // TODO: [P*] Instead of deferencing using internal ID, use pointer instead. + yield activityMap.get(sortedEntry.activityLocalId)!.activity; + } else if (sortedEntry.type === 'how to grouping') { + const howToGrouping = howToGroupingMap.get(sortedEntry.howToGroupingId)!; + + for (const howToPartEntry of howToGrouping.partList) { + if (howToPartEntry.type === 'activity') { + yield activityMap.get(howToPartEntry.activityLocalId)!.activity; + } else { + howToPartEntry.type satisfies 'livestream session'; + + for (const activityEntry of livestreamSessionMap.get(howToPartEntry.livestreamSessionId)!.activities) { + yield activityMap.get(activityEntry.activityLocalId)!.activity; + } + } + } + } else { + sortedEntry.type satisfies 'livestream session'; + + for (const activityEntry of livestreamSessionMap.get(sortedEntry.livestreamSessionId)!.activities) { + yield activityMap.get(activityEntry.activityLocalId)!.activity; + } + } + } + })() + ); +} diff --git a/packages/core/src/reducers/activities/sort/private/getLogicalTimestamp.spec.ts b/packages/core/src/reducers/activities/sort/private/getLogicalTimestamp.spec.ts new file mode 100644 index 0000000000..af57cf2469 --- /dev/null +++ b/packages/core/src/reducers/activities/sort/private/getLogicalTimestamp.spec.ts @@ -0,0 +1,49 @@ +/* eslint-disable no-restricted-globals */ + +import { scenario } from '@testduet/given-when-then'; +import type { WebChatActivity } from '../../../../types/WebChatActivity'; +import type { LocalId } from '../property/LocalId'; +import getLogicalTimestamp from './getLogicalTimestamp'; + +scenario('get logical timestamp', bdd => { + bdd + .given( + 'an activity with timestamp property of new Date(123)', + () => + ({ + channelData: { + 'webchat:internal:local-id': '_:a-00001' as LocalId, + 'webchat:internal:position': 1, + 'webchat:send-status': undefined + }, + from: { id: 'bot', role: 'bot' }, + id: 'a-00001', + text: 'Hello, World!', + timestamp: new Date(123).toISOString(), + type: 'message' + }) satisfies WebChatActivity + ) + .when('getting its logical timestamp', activity => getLogicalTimestamp(activity, { Date })) + .then('should get 123', (_, actual) => expect(actual).toBe(123)); + + bdd + .given( + 'an activity with sequence ID of 123', + () => + ({ + channelData: { + 'webchat:internal:local-id': '_:a-00001' as LocalId, + 'webchat:internal:position': 1, + 'webchat:sequence-id': 123, + 'webchat:send-status': 'sent' + }, + from: { id: 'bot', role: 'bot' }, + id: 'a-00001', + text: 'Hello, World!', + timestamp: undefined as any, + type: 'message' + }) satisfies WebChatActivity + ) + .when('getting its logical timestamp', activity => getLogicalTimestamp(activity, { Date })) + .then('should get 123', (_, actual) => expect(actual).toBe(123)); +}); diff --git a/packages/core/src/reducers/activities/sort/private/getLogicalTimestamp.ts b/packages/core/src/reducers/activities/sort/private/getLogicalTimestamp.ts new file mode 100644 index 0000000000..94ca059051 --- /dev/null +++ b/packages/core/src/reducers/activities/sort/private/getLogicalTimestamp.ts @@ -0,0 +1,33 @@ +import type { GlobalScopePonyfill } from '../../../../types/GlobalScopePonyfill'; +import type { Activity } from '../types'; + +/** + * Get sequence ID from `activity.channelData['webchat:sequence-id']` and fallback to `+new Date(activity.timestamp)`. + * + * Chat adapter may send sequence ID to affect activity reordering. Sequence ID is supposed to be Unix timestamp. + * + * @param activity Activity to get sequence ID from. + * @returns Sequence ID. + */ +export default function getLogicalTimestamp( + activity: Activity, + ponyfill: Pick +): number | undefined { + const sequenceId = activity.channelData?.['webchat:sequence-id']; + + if (typeof sequenceId === 'number') { + return sequenceId; + } + + const { timestamp } = activity; + + if (typeof timestamp === 'string') { + return +new ponyfill.Date(timestamp); + } else if (typeof timestamp !== 'undefined' && (timestamp as any) instanceof ponyfill.Date) { + console.warn('botframework-webchat: "timestamp" must be of type string, instead of Date.'); + + return +timestamp; + } + + return undefined; +} diff --git a/packages/core/src/reducers/activities/sort/private/getPartGroupingMetadataMap.spec.ts b/packages/core/src/reducers/activities/sort/private/getPartGroupingMetadataMap.spec.ts new file mode 100644 index 0000000000..d8202cc344 --- /dev/null +++ b/packages/core/src/reducers/activities/sort/private/getPartGroupingMetadataMap.spec.ts @@ -0,0 +1,92 @@ +/* eslint-disable no-restricted-globals */ + +import { scenario } from '@testduet/given-when-then'; +import type { WebChatActivity } from '../../../../types/WebChatActivity'; +import type { LocalId } from '../property/LocalId'; +import getPartGroupingMetadataMap, { type PartGroupingMetadataMapEntry } from './getPartGroupingMetadataMap'; + +scenario('getPartGroupingMetadataMap', bdd => { + bdd + .given( + 'an activity with isPartOf[@type="HowTo"]', + () => + ({ + channelData: { + 'webchat:internal:local-id': '_:a-00001' as LocalId, + 'webchat:internal:position': 0 + }, + entities: [ + { + '@context': 'https://schema.org', + '@id': '', + '@type': 'Message', + type: 'https://schema.org/Message', + isPartOf: { '@id': '_:c-00001', '@type': 'HowTo' }, + position: 1 + } as any // TODO: [P0] We need to redo the typing of `WebChatActivity`. + ], + from: { + id: 'bot', + role: 'bot' + }, + id: 'a-00001', + text: 'Hello, World!', + timestamp: new Date(0).toISOString(), + type: 'message' + }) satisfies WebChatActivity + ) + .when('getPartGroupingMetadataMap() is called', activity => getPartGroupingMetadataMap(activity)) + .then('should return part grouping metadata', (_, actual) => { + expect(actual).toEqual( + new Map([ + ['HowTo', { groupingId: '_:c-00001', position: 1 }] + ]) satisfies ReturnType + ); + }); +}); + +// TODO: [P0] Enable multiple part grouping once the schema behind `parseThing()` supports multiple part grouping. +scenario('getPartGroupingMetadataMap with multiple part grouping', bdd => { + bdd + .given( + 'an activity with isPartOf[@type="Conversation"][@type="HowTo"]', + () => + ({ + channelData: { + 'webchat:internal:local-id': '_:a-00001' as LocalId, + 'webchat:internal:position': 0 + }, + entities: [ + { + '@context': 'https://schema.org', + '@id': '', + '@type': 'Message', + type: 'https://schema.org/Message', + isPartOf: [ + { '@id': '_:conv:00001', '@type': 'Conversation' }, + { '@id': '_:how-to:00001', '@type': 'HowTo' } + ], + position: 1 + } as any // TODO: [P0] We need to redo the typing of `WebChatActivity`. + ], + from: { + id: 'bot', + role: 'bot' + }, + id: 'a-00001', + text: 'Hello, World!', + timestamp: new Date(0).toISOString(), + type: 'message' + }) satisfies WebChatActivity + ) + .when('getPartGroupingMetadataMap() is called', activity => getPartGroupingMetadataMap(activity)) + .then('should return part grouping metadata', (_, actual) => { + expect(actual).toEqual( + new Map([ + ['Conversation', { groupingId: '_:conv:00001', position: 1 }] + // TODO: [P0] Currently, it only return the first part grouping. + // ['HowTo', { groupingId: '_:how-to:00001', position: 1 }] + ]) satisfies ReturnType + ); + }); +}); diff --git a/packages/core/src/reducers/activities/sort/private/getPartGroupingMetadataMap.ts b/packages/core/src/reducers/activities/sort/private/getPartGroupingMetadataMap.ts new file mode 100644 index 0000000000..cdc387c149 --- /dev/null +++ b/packages/core/src/reducers/activities/sort/private/getPartGroupingMetadataMap.ts @@ -0,0 +1,49 @@ +import { IdentifierSchema } from '@msinternal/botframework-webchat-core-graph'; +import { array, literal, number, object, optional, safeParse, string, union } from 'valibot'; +import type { WebChatActivity } from '../../../../types/WebChatActivity'; +import getOrgSchemaMessage from '../../../../utils/getOrgSchemaMessage'; + +// TODO: [P0] Need to fix `getOrgSchemaMessage` before we can move to `NodeReferenceSchema`. +// It is introducing new properties, should be relaxed. +const IsPartOfNodeReferenceSchema = object({ '@id': IdentifierSchema, '@type': string() }); + +const MessageIsPartOfSchema = object({ + '@type': literal('Message'), + isPartOf: union([IsPartOfNodeReferenceSchema, array(IsPartOfNodeReferenceSchema)]), + position: optional(number()) +}); + +type PartGroupingMetadataMapEntry = { + readonly groupingId: string; + readonly position: number | undefined; +}; + +function getPartGroupingMetadataMap(activity: WebChatActivity): ReadonlyMap { + const metadataMap = new Map(); + + const message = getOrgSchemaMessage(activity.entities || []); + + if (message) { + const messageIsPartOfResult = safeParse(MessageIsPartOfSchema, message); + + if (messageIsPartOfResult.success) { + const { isPartOf, position } = messageIsPartOfResult.output; + + // TODO: [P0] Simplify this code when we use the new graph, where all property values are array. + for (const item of Array.isArray(isPartOf) ? isPartOf : [isPartOf]) { + metadataMap.set( + item['@type'], + Object.freeze({ + groupingId: item['@id'], + position + }) + ); + } + } + } + + return metadataMap; +} + +export default getPartGroupingMetadataMap; +export type { PartGroupingMetadataMapEntry }; diff --git a/packages/core/src/reducers/activities/sort/private/insertSorted.spec.ts b/packages/core/src/reducers/activities/sort/private/insertSorted.spec.ts new file mode 100644 index 0000000000..9b586fd5ab --- /dev/null +++ b/packages/core/src/reducers/activities/sort/private/insertSorted.spec.ts @@ -0,0 +1,106 @@ +import { scenario } from '@testduet/given-when-then'; +import insertSorted from './insertSorted'; + +scenario('insert to sorted array', bdd => { + bdd + .given('a sorted array of [1, 3, 5]', () => [1, 3, 5]) + .when('inserting 2', array => insertSorted(array, 2, (x, y) => x - y)) + .then('should return [1, 2, 3, 5]', (_, actual) => { + expect(actual).toEqual([1, 2, 3, 5]); + }); + + bdd + .given('a sorted array of [1, 3, 5]', () => [1, 3, 5]) + .when('inserting 6', array => insertSorted(array, 6, (x, y) => x - y)) + .then('should return [1, 3, 5, 6]', (_, actual) => { + expect(actual).toEqual([1, 3, 5, 6]); + }); + + bdd + .given('a sorted array of [1, 3, 5]', () => [1, 3, 5]) + .when('inserting 0', array => insertSorted(array, 0, (x, y) => x - y)) + .then('should return [0, 1, 3, 5]', (_, actual) => { + expect(actual).toEqual([0, 1, 3, 5]); + }); + + bdd + .given('an empty array', () => []) + .when('inserting 0', array => insertSorted(array, 0, (x, y) => x - y)) + .then('should return [0]', (_, actual) => { + expect(actual).toEqual([0]); + }); + + bdd + .given("a sorted array of ['1b', '2b', '3b']", () => ['1b', '2b', '3b']) + .when('inserting 2c with a parseInt comparer', array => + insertSorted(array, '2c', (x, y) => parseInt(x, 10) - parseInt(y, 10)) + ) + .then("should return ['1b', '2b', '2c', '3b']", (_, actual) => { + expect(actual).toEqual(['1b', '2b', '2c', '3b']); + }); + + bdd + .given("a sorted array of ['1b', '2b', '3b']", () => ['1b', '2b', '3b']) + .when('inserting 2a with a parseInt comparer', array => + insertSorted(array, '2a', (x, y) => parseInt(x, 10) - parseInt(y, 10)) + ) + .then("should return ['1b', '2b', '2a', '3b']", (_, actual) => { + // For items with same weight, the new item should be appended. + expect(actual).toEqual(['1b', '2b', '2a', '3b']); + }); +}); + +scenario('insert to sorted array ignoring undefined elements with a custom compareFn', bdd => { + function compareFn(x: number | undefined, y: number | undefined): number { + return typeof x === 'undefined' || typeof y === 'undefined' ? -1 : x - y; + } + + bdd + .given('a sorted array of [1, undefined, 3, undefined, 5]', () => [1, undefined, 3, undefined, 5]) + .when('inserting 2', array => insertSorted(array, 2, compareFn)) + .then('should return [1, undefined, 2, 3, undefined, 5]', (_, actual) => { + expect(actual).toEqual([1, undefined, 2, 3, undefined, 5]); + }); + + bdd + .given('a sorted array of [1, undefined, 3, undefined, 5]', () => [1, undefined, 3, undefined, 5]) + .when('inserting 4', array => insertSorted(array, 4, compareFn)) + .then('should return [1, undefined, 3, undefined, 4, 5]', (_, actual) => { + expect(actual).toEqual([1, undefined, 3, undefined, 4, 5]); + }); + + bdd + .given('a sorted array of [undefined, 3, undefined]', () => [undefined, 3, undefined]) + .when('inserting 2', array => insertSorted(array, 2, compareFn)) + .then('should return [undefined, 2, 3, undefined]', (_, actual) => { + expect(actual).toEqual([undefined, 2, 3, undefined]); + }); + + bdd + .given('a sorted array of [undefined, 3, undefined]', () => [undefined, 3, undefined]) + .when('inserting 4', array => insertSorted(array, 4, compareFn)) + .then('should return [undefined, 3, undefined, 4]', (_, actual) => { + expect(actual).toEqual([undefined, 3, undefined, 4]); + }); + + bdd + .given('a sorted array of [1, 3, 5]', () => [1, 3, 5]) + .when('inserting undefined', array => insertSorted(array, undefined, compareFn)) + .then('should return [1, 3, 5, undefined]', (_, actual) => { + expect(actual).toEqual([1, 3, 5, undefined]); + }); + + bdd + .given('a sorted array of [1, undefined, 3, undefined, 5]', () => [1, undefined, 3, undefined, 5]) + .when('inserting undefined', array => insertSorted(array, undefined, compareFn)) + .then('should return [1, undefined, 3, undefined, 5, undefined]', (_, actual) => { + expect(actual).toEqual([1, undefined, 3, undefined, 5, undefined]); + }); + + bdd + .given('an array of [undefined]', () => [undefined]) + .when('inserting undefined', array => insertSorted(array, undefined, compareFn)) + .then('should return [undefined, undefined]', (_, actual) => { + expect(actual).toEqual([undefined, undefined]); + }); +}); diff --git a/packages/core/src/reducers/activities/sort/private/insertSorted.ts b/packages/core/src/reducers/activities/sort/private/insertSorted.ts new file mode 100644 index 0000000000..a2e663c4df --- /dev/null +++ b/packages/core/src/reducers/activities/sort/private/insertSorted.ts @@ -0,0 +1,24 @@ +// @ts-ignore No @types/core-js-pure +import { default as toSpliced_ } from 'core-js-pure/features/array/to-spliced'; + +// The Node.js version we are using for CI/CD does not support Array.prototype.toSpliced yet. +function toSpliced(array: readonly T[], start: number, deleteCount: number, ...items: T[]): T[] { + return toSpliced_(array, start, deleteCount, ...items); +} + +/** + * Inserts a single item into a sorted array. + * + * For multiple items, consider other options for efficiency. + * + * @param sortedArray A sorted array. + * @param item Item to be inserted. + * @param compareFn Function used to determine the order of the elements. It is expected to return a negative value if the first argument is less than the second argument, zero if they're equal, and a positive value otherwise. + * @returns A new sorted array with the new item. + */ +export default function insertSorted(sortedArray: readonly T[], item: T, compareFn: (x: T, y: T) => number): T[] { + // TODO: Implements `binaryFindIndex()` for better performance. + const indexToInsert = sortedArray.findIndex(i => compareFn(i, item) > 0); + + return toSpliced(sortedArray, ~indexToInsert ? indexToInsert : sortedArray.length, 0, item); +} diff --git a/packages/core/src/reducers/activities/sort/property/LocalId.ts b/packages/core/src/reducers/activities/sort/property/LocalId.ts new file mode 100644 index 0000000000..32e5fd2957 --- /dev/null +++ b/packages/core/src/reducers/activities/sort/property/LocalId.ts @@ -0,0 +1,60 @@ +import { IdentifierSchema, type Identifier } from '@msinternal/botframework-webchat-core-graph'; +import type { Tagged } from 'type-fest'; +import { v4 } from 'uuid'; +import { object, parse, pipe, safeParse, transform, type InferOutput } from 'valibot'; +import type { Activity } from '../types'; + +const LocalIdSchema = pipe( + IdentifierSchema, + transform(value => value as Tagged) +); + +type LocalId = InferOutput; + +const ActivityWithLocalIdSchema = object({ + channelData: object({ + 'webchat:internal:local-id': LocalIdSchema + }) +}); + +function getLocalIdFromActivity(activity: Readonly): LocalId { + return parse(ActivityWithLocalIdSchema, activity).channelData['webchat:internal:local-id']; +} + +function queryLocalIdFromActivity(activity: Readonly): LocalId | undefined { + const result = safeParse(ActivityWithLocalIdSchema, activity); + + return result.success ? result.output.channelData['webchat:internal:local-id'] : undefined; +} + +function setLocalIdInActivity(activity: Readonly, value: LocalId | undefined): Activity { + const nextChannelData = { ...activity.channelData }; + + if (typeof value === 'undefined') { + delete (nextChannelData as any)['webchat:internal:local-id']; + } else { + nextChannelData['webchat:internal:local-id'] = parse(LocalIdSchema, value); + } + + return { + ...activity, + channelData: nextChannelData as any + }; +} + +// TODO: [P1] We can use a UUID v6 (reorder). Then, we can drop `receivedAt`. +function generateLocalId(): LocalId { + return parse(LocalIdSchema, `_:${v4()}`); +} + +function generateLocalIdInActivity(activity: Readonly): Activity { + if (queryLocalIdFromActivity(activity)) { + throw new Error( + 'botframework-webchat: Cannot generate a new local ID for activity because the activity already has a local ID' + ); + } + + return setLocalIdInActivity(activity, generateLocalId()); +} + +export { generateLocalIdInActivity, getLocalIdFromActivity, LocalIdSchema, setLocalIdInActivity, type LocalId }; diff --git a/packages/core/src/reducers/activities/sort/property/Position.ts b/packages/core/src/reducers/activities/sort/property/Position.ts new file mode 100644 index 0000000000..b235cfc5fc --- /dev/null +++ b/packages/core/src/reducers/activities/sort/property/Position.ts @@ -0,0 +1,39 @@ +import { number, object, parse, safeParse, type InferOutput } from 'valibot'; +import type { Activity } from '../types'; + +const PositionSchema = number('position must be a number'); + +type Position = InferOutput; + +const ActivityWithPositionSchema = object({ + channelData: object({ + 'webchat:internal:position': PositionSchema + }) +}); + +function getPositionFromActivity(activity: Readonly): Position { + return parse(ActivityWithPositionSchema, activity).channelData['webchat:internal:position']; +} + +function queryPositionFromActivity(activity: Readonly): Position | undefined { + const result = safeParse(ActivityWithPositionSchema, activity); + + return result.success ? result.output.channelData['webchat:internal:position'] : undefined; +} + +function setPositionInActivity(activity: Readonly, value: Position | undefined): Activity { + const nextChannelData = { ...activity.channelData }; + + if (typeof value === 'undefined') { + delete (nextChannelData as any)['webchat:internal:position']; + } else { + nextChannelData['webchat:internal:position'] = parse(PositionSchema, value); + } + + return { + ...activity, + channelData: nextChannelData as any + }; +} + +export { getPositionFromActivity, PositionSchema, queryPositionFromActivity, setPositionInActivity, type Position }; diff --git a/packages/core/src/reducers/activities/sort/property/ReceivedAt.ts b/packages/core/src/reducers/activities/sort/property/ReceivedAt.ts new file mode 100644 index 0000000000..696ae91071 --- /dev/null +++ b/packages/core/src/reducers/activities/sort/property/ReceivedAt.ts @@ -0,0 +1,45 @@ +import { number, object, parse, safeParse, type InferOutput } from 'valibot'; +import type { Activity } from '../types'; + +const ReceivedAtSchema = number(); + +type ReceivedAt = InferOutput; + +const ActivityWithReceivedAtSchema = object({ + channelData: object({ + 'webchat:internal:received-at': ReceivedAtSchema + }) +}); + +function getReceivedAtFromActivity(activity: Readonly): ReceivedAt { + return parse(ActivityWithReceivedAtSchema, activity).channelData['webchat:internal:received-at']; +} + +function queryReceivedAtFromActivity(activity: Readonly): ReceivedAt | undefined { + const result = safeParse(ActivityWithReceivedAtSchema, activity); + + return result.success ? result.output.channelData['webchat:internal:received-at'] : undefined; +} + +function setReceivedAtInActivity(activity: Readonly, value: ReceivedAt | undefined): Activity { + const nextChannelData = { ...activity.channelData }; + + if (typeof value === 'undefined') { + delete (nextChannelData as any)['webchat:internal:received-at']; + } else { + nextChannelData['webchat:internal:received-at'] = parse(ReceivedAtSchema, value); + } + + return { + ...activity, + channelData: nextChannelData as any + }; +} + +export { + getReceivedAtFromActivity, + queryReceivedAtFromActivity, + ReceivedAtSchema, + setReceivedAtInActivity, + type ReceivedAt +}; diff --git a/packages/core/src/reducers/activities/sort/property/SendStatus.ts b/packages/core/src/reducers/activities/sort/property/SendStatus.ts new file mode 100644 index 0000000000..9d6c366dc1 --- /dev/null +++ b/packages/core/src/reducers/activities/sort/property/SendStatus.ts @@ -0,0 +1,51 @@ +import { object, parse, picklist, safeParse, type InferOutput } from 'valibot'; +import type { Activity } from '../types'; + +const SendStatusSchema = picklist( + ['sending', 'send failed', 'sent'], + 'Send status must be either "sending", "send failed" or "sent"' +); + +type SendStatus = InferOutput; + +const OutgoingActivityWithSendStatusSchema = object({ + channelData: object( + { + 'webchat:send-status': SendStatusSchema + }, + '"channelData" must be an object' + ) +}); + +function getSendStatusFromOutgoingActivity(activity: Activity): SendStatus { + return parse(OutgoingActivityWithSendStatusSchema, activity).channelData['webchat:send-status']; +} + +function querySendStatusFromOutgoingActivity(activity: Activity): SendStatus | undefined { + const result = safeParse(OutgoingActivityWithSendStatusSchema, activity); + + return result.success ? result.output.channelData['webchat:send-status'] : undefined; +} + +function setSendStatusInOutgoingActivity(activity: Activity, value: SendStatus | undefined): Activity { + const nextChannelData = { ...activity.channelData }; + + if (typeof value === 'undefined') { + delete (nextChannelData as any)['webchat:send-status']; + } else { + nextChannelData['webchat:send-status'] = value; + } + + return { + ...activity, + channelData: nextChannelData as any + }; +} + +export { + getSendStatusFromOutgoingActivity, + querySendStatusFromOutgoingActivity, + SendStatusSchema, + setSendStatusInOutgoingActivity, + type SendStatus +}; diff --git a/packages/core/src/reducers/activities/sort/queryLocalIdByActivityId.ts b/packages/core/src/reducers/activities/sort/queryLocalIdByActivityId.ts new file mode 100644 index 0000000000..fbe42098a8 --- /dev/null +++ b/packages/core/src/reducers/activities/sort/queryLocalIdByActivityId.ts @@ -0,0 +1,6 @@ +import type { LocalId } from './property/LocalId'; +import type { State } from './types'; + +export default function queryLocalIdAByActivityId(state: State, activityId: string): LocalId | undefined { + return state.activityIdToLocalIdMap.get(activityId); +} diff --git a/packages/core/src/reducers/activities/sort/queryLocalIdByClientActivityId.ts b/packages/core/src/reducers/activities/sort/queryLocalIdByClientActivityId.ts new file mode 100644 index 0000000000..5c594671e3 --- /dev/null +++ b/packages/core/src/reducers/activities/sort/queryLocalIdByClientActivityId.ts @@ -0,0 +1,6 @@ +import type { LocalId } from './property/LocalId'; +import type { State } from './types'; + +export default function queryLocalIdAByClientActivityId(state: State, clientActivityId: string): LocalId | undefined { + return state.clientActivityIdToLocalIdMap.get(clientActivityId); +} diff --git a/packages/core/src/reducers/activities/sort/types.ts b/packages/core/src/reducers/activities/sort/types.ts new file mode 100644 index 0000000000..286711854f --- /dev/null +++ b/packages/core/src/reducers/activities/sort/types.ts @@ -0,0 +1,80 @@ +import type { Tagged } from 'type-fest'; +import type { WebChatActivity } from '../../../types/WebChatActivity'; +import type { LocalId } from './property/LocalId'; + +type Activity = WebChatActivity; + +type HowToGroupingId = Tagged; +type LivestreamSessionId = Tagged; + +type HowToGroupingEntry = { + readonly howToGroupingId: HowToGroupingId; + readonly logicalTimestamp: number | undefined; + readonly type: 'how to grouping'; +}; + +type LivestreamSessionEntry = { + readonly livestreamSessionId: LivestreamSessionId; + readonly logicalTimestamp: number | undefined; + readonly type: 'livestream session'; +}; + +type ActivityEntry = { + readonly activityLocalId: LocalId; + readonly logicalTimestamp: number | undefined; + readonly type: 'activity'; +}; + +type HowToGroupingMapPartEntry = (ActivityEntry | LivestreamSessionEntry) & { + readonly position: number | undefined; +}; +type HowToGroupingMapEntry = { + readonly logicalTimestamp: number | undefined; + readonly partList: readonly HowToGroupingMapPartEntry[]; +}; +type HowToGroupingMap = ReadonlyMap; + +type SortedChatHistoryEntry = ActivityEntry | LivestreamSessionEntry | HowToGroupingEntry; +type SortedChatHistory = readonly SortedChatHistoryEntry[]; + +type LivestreamSessionMapEntryActivityEntry = ActivityEntry & { readonly sequenceNumber: number }; + +type LivestreamSessionMapEntry = { + readonly activities: readonly LivestreamSessionMapEntryActivityEntry[]; + readonly finalized: boolean; + readonly logicalTimestamp: number | undefined; +}; +type LivestreamSessionMap = ReadonlyMap; + +type ActivityMapEntry = ActivityEntry & { readonly activity: Activity }; +type ActivityMap = ReadonlyMap; + +type State = { + readonly activityIdToLocalIdMap: Map; + readonly activityMap: ActivityMap; + readonly clientActivityIdToLocalIdMap: Map; + readonly howToGroupingMap: HowToGroupingMap; + readonly livestreamSessionMap: LivestreamSessionMap; + readonly sortedChatHistoryList: SortedChatHistory; + readonly sortedActivities: readonly Activity[]; +}; + +export { + type Activity, + type ActivityEntry, + type ActivityMap, + type ActivityMapEntry, + type HowToGroupingEntry, + type HowToGroupingId, + type HowToGroupingMap, + type HowToGroupingMapEntry, + type HowToGroupingMapPartEntry, + type LivestreamSessionEntry, + type LivestreamSessionId, + type LivestreamSessionMap, + type LivestreamSessionMapEntry, + type LivestreamSessionMapEntryActivityEntry, + type SortedChatHistory, + type SortedChatHistoryEntry, + type State +}; diff --git a/packages/core/src/reducers/activities/sort/updateActivityChannelData.ts b/packages/core/src/reducers/activities/sort/updateActivityChannelData.ts new file mode 100644 index 0000000000..caa4709ebb --- /dev/null +++ b/packages/core/src/reducers/activities/sort/updateActivityChannelData.ts @@ -0,0 +1,101 @@ +import { check, parse, pipe, string, type GenericSchema } from 'valibot'; +import { getLocalIdFromActivity, type LocalId } from './property/LocalId'; +import type { Activity, ActivityMapEntry, State } from './types'; + +const channelDataNameSchema: GenericSchema< + Exclude +> = pipe( + string(), + check( + value => + value !== 'state' && + value !== 'streamId' && + value !== 'streamSequence' && + value !== 'streamType' && + !value.startsWith('webchat:'), + 'name must not be a reserved' + ) +); + +/** + * Updates activity channel data. + * + * Note: after channel data is updated, it will not update to a new position. + * Do not use this function for updating channel data that would affect position, such as `streamSequence`. + * + * @param state + * @param activityLocalId + * @param name + * @param value + * @returns + */ +function updateActivityChannelDataInternalSkipNameCheck( + state: State, + activityLocalId: LocalId, + name: string, + value: unknown +): State { + const activityEntry = state.activityMap.get(activityLocalId); + + if (!activityEntry) { + throw new Error(`botframework-webchat: no activity found with internal ID ${activityLocalId}`); + } + + // TODO: [P0] We will freeze activity in future. + const nextActivity: Activity = { + ...activityEntry.activity, + channelData: { + ...activityEntry.activity.channelData, + [name]: value + } as any + }; + + const nextActivityMap = new Map(state.activityMap).set( + activityLocalId, + Object.freeze({ ...activityEntry, activity: nextActivity } satisfies ActivityMapEntry) + ); + + const nextSortedActivities = Array.from(state.sortedActivities); + + const existingActivityIndex = nextSortedActivities.findIndex( + activity => getLocalIdFromActivity(activity) === activityLocalId + ); + + if (!~existingActivityIndex) { + throw new Error(`botframework-webchat: no activity found in sortedActivities with internal ID ${activityLocalId}`); + } + + nextSortedActivities[+existingActivityIndex] = nextActivity; + + return Object.freeze({ + ...state, + activityMap: Object.freeze(nextActivityMap), + sortedActivities: Object.freeze(nextSortedActivities) + } satisfies State); +} + +/** + * Update activity channel data. + * + * @deprecated Channel data update is being deprecated, please use a custom state management solution instead. + * @param state + * @param activityLocalId + * @param name + * @param value + * @returns + */ +function updateActivityChannelData(state: State, activityLocalId: LocalId, name: string, value: unknown): State { + if (name.startsWith('webchat:')) { + throw new Error('botframework-webchat: custom channel data name must not use prefix "webchat:"'); + } + + return updateActivityChannelDataInternalSkipNameCheck( + state, + activityLocalId, + parse(channelDataNameSchema, name), + value + ); +} + +export default updateActivityChannelData; +export { updateActivityChannelDataInternalSkipNameCheck }; diff --git a/packages/core/src/reducers/activities/sort/updateSendState.ts b/packages/core/src/reducers/activities/sort/updateSendState.ts new file mode 100644 index 0000000000..9820221fd3 --- /dev/null +++ b/packages/core/src/reducers/activities/sort/updateSendState.ts @@ -0,0 +1,22 @@ +import type { LocalId } from './property/LocalId'; +import type { State } from './types'; +import { updateActivityChannelDataInternalSkipNameCheck } from './updateActivityChannelData'; + +/** + * @deprecated Channel data update is obsoleted, re-upsert instead. + * @param state + * @param activityLocalId + * @param sendState + * @returns + */ +export default function updateSendState( + state: State, + activityLocalId: LocalId, + sendState: 'sending' | 'send failed' | 'sent' +): State { + state = updateActivityChannelDataInternalSkipNameCheck(state, activityLocalId, 'state', sendState); + + state = updateActivityChannelDataInternalSkipNameCheck(state, activityLocalId, 'webchat:send-status', sendState); + + return state; +} diff --git a/packages/core/src/reducers/activities/sort/upsert.activity.spec.ts b/packages/core/src/reducers/activities/sort/upsert.activity.spec.ts new file mode 100644 index 0000000000..b8eed6be7d --- /dev/null +++ b/packages/core/src/reducers/activities/sort/upsert.activity.spec.ts @@ -0,0 +1,500 @@ +/* eslint-disable no-restricted-globals */ +import { expect } from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import type { WebChatActivity } from '../../../types/WebChatActivity'; +import type { LocalId } from './property/LocalId'; +import { type Activity, type ActivityMapEntry, type SortedChatHistory } from './types'; +import upsert, { INITIAL_STATE } from './upsert'; + +function activityToExpectation(activity: Activity, expectedPosition: number = expect.any(Number) as any): Activity { + return { + ...activity, + channelData: { + ...activity.channelData, + 'webchat:internal:position': expectedPosition + } as any + }; +} + +scenario('upserting 2 activities with timestamps', bdd => { + const activity1: Activity = { + channelData: { + 'webchat:internal:local-id': '_:a-00001' as LocalId, + 'webchat:internal:position': 0, + 'webchat:send-status': undefined + }, + from: { id: 'bot', role: 'bot' }, + id: 'a-00001', + text: 'Hello, World!', + timestamp: new Date(1000).toISOString(), + type: 'message' + }; + + const activity2: Activity = { + channelData: { + 'webchat:internal:local-id': '_:a-00002' as LocalId, + 'webchat:internal:position': 0, + 'webchat:send-status': undefined + }, + from: { id: 'bot', role: 'bot' }, + id: 'a-00002', + text: 'Aloha!', + timestamp: new Date(500).toISOString(), + type: 'message' + }; + + bdd + .given('an initial state', () => INITIAL_STATE) + .when('upserted', state => upsert({ Date }, state, activity1)) + .then('`activityIdToLocalIdMap` should match', (_, state) => { + expect(state.activityIdToLocalIdMap).toEqual(new Map([['a-00001', '_:a-00001' as LocalId]])); + }) + .and('should have added activity to `activityMap`', (_, state) => { + expect(state).toHaveProperty( + 'activityMap', + new Map([ + [ + '_:a-00001' as LocalId, + { + activity: activityToExpectation(activity1), + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + type: 'activity' + } + ] + ]) + ); + }) + .and('should have added activity to `sortedChatHistoryList`', (_, state) => { + expect(state).toHaveProperty('sortedChatHistoryList', [ + { + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + type: 'activity' + } + ]); + }) + .and('should match `sortedActivities` snapshot', (_, state) => { + expect(state).toHaveProperty('sortedActivities', [activityToExpectation(activity1, 1_000)]); + }) + .when('another activity is upserted', (_, state) => upsert({ Date }, state, activity2)) + .then('`activityIdToLocalIdMap` should match', (_, state) => { + expect(state.activityIdToLocalIdMap).toEqual( + new Map([ + ['a-00001', '_:a-00001' as LocalId], + ['a-00002', '_:a-00002' as LocalId] + ]) + ); + }) + .and('should have added activity to `activityMap`', (_, state) => { + expect(state).toHaveProperty( + 'activityMap', + new Map([ + [ + '_:a-00001' as LocalId, + { + activity: { + channelData: { + 'webchat:internal:local-id': '_:a-00001' as LocalId, + 'webchat:internal:position': expect.any(Number), + 'webchat:send-status': undefined + } as any, + from: { id: 'bot', role: 'bot' }, + id: 'a-00001', + text: 'Hello, World!', + timestamp: new Date(1_000).toISOString(), + type: 'message' + }, + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + type: 'activity' + } + ], + [ + '_:a-00002' as LocalId, + { + activity: { + channelData: { + 'webchat:internal:local-id': '_:a-00002' as LocalId, + 'webchat:internal:position': expect.any(Number), + 'webchat:send-status': undefined + } as any, + from: { id: 'bot', role: 'bot' }, + id: 'a-00002', + text: 'Aloha!', + timestamp: new Date(500).toISOString(), + type: 'message' + }, + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: 500, + type: 'activity' + } + ] + ]) + ); + }) + .and('should have added activity to `sortedChatHistoryList`', (_, state) => { + expect(state).toHaveProperty('sortedChatHistoryList', [ + { + activityLocalId: '_:a-00002', + logicalTimestamp: 500, + type: 'activity' + }, + { + activityLocalId: '_:a-00001', + logicalTimestamp: 1_000, + type: 'activity' + } + ]); + }) + .and('should match `sortedActivities` snapshot', (_, state) => { + expect(state).toHaveProperty('sortedActivities', [ + activityToExpectation(activity2, 1), + activityToExpectation(activity1, 1_000) + ]); + }); +}); + +scenario('upserting activities which some with timestamp and some without', bdd => { + const activity1: WebChatActivity = { + channelData: { + 'webchat:internal:local-id': '_:a-00001' as LocalId, + 'webchat:internal:position': 0, + 'webchat:send-status': undefined + }, + from: { id: 'bot', role: 'bot' }, + id: 'a-00001', + text: 't=1000ms', + timestamp: new Date(1_000).toISOString(), + type: 'message' + }; + + const activity2: WebChatActivity = { + channelData: { + 'webchat:internal:local-id': '_:a-00002' as LocalId, + 'webchat:internal:position': 0, + 'webchat:send-status': undefined + }, + from: { id: 'bot', role: 'bot' }, + id: 'a-00002', + text: 't=undefined', + timestamp: undefined as any, + type: 'message' + }; + + const activity3: WebChatActivity = { + channelData: { + 'webchat:internal:local-id': '_:a-00003' as LocalId, + 'webchat:internal:position': 0, + 'webchat:send-status': undefined + }, + from: { id: 'bot', role: 'bot' }, + id: 'a-00003', + text: 't=2_000ms', + timestamp: new Date(2_000).toISOString(), + type: 'message' + }; + + const activity4: WebChatActivity = { + channelData: { + 'webchat:internal:local-id': '_:a-00004' as LocalId, + 'webchat:internal:position': 0, + 'webchat:send-status': undefined + }, + from: { id: 'bot', role: 'bot' }, + id: 'a-00004', + text: 't=1500ms', + timestamp: new Date(1_500).toISOString(), + type: 'message' + }; + + const activity2b = { ...activity2, timestamp: new Date(1_750).toISOString() }; + + bdd + .given('an initial state', () => INITIAL_STATE) + .when('upserting an activity with t=1000ms', state => upsert({ Date }, state, activity1)) + .then('should have added to `activityMap`', (_, state) => { + expect(state.activityMap).toEqual( + new Map([ + [ + '_:a-00001' as LocalId, + { + activity: activityToExpectation(activity1), + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + type: 'activity' + } + ] + ]) + ); + }) + .and('should have added to `sortedChatHistoryList`', (_, state) => { + expect(state.sortedChatHistoryList).toEqual([ + { + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + type: 'activity' + } + ] satisfies SortedChatHistory); + }) + .and('`sortedActivities` should match', (_, state) => { + expect(state.sortedActivities).toEqual([activityToExpectation(activity1, 1_000)]); + }) + .when('upserting an activity with t=undefined', (_, state) => upsert({ Date }, state, activity2)) + .then('should have added to `activityMap`', (_, state) => { + expect(state.activityMap).toEqual( + new Map([ + [ + '_:a-00001' as LocalId, + { + activity: activityToExpectation(activity1), + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + type: 'activity' + } + ], + [ + '_:a-00002' as LocalId, + { + activity: activityToExpectation(activity2), + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: undefined, + type: 'activity' + } + ] + ]) + ); + }) + .and('should have added to `sortedChatHistoryList`', (_, state) => { + expect(state.sortedChatHistoryList).toEqual([ + { + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + type: 'activity' + }, + { + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: undefined, + type: 'activity' + } + ] satisfies SortedChatHistory); + }) + .and('`sortedActivities` should match', (_, state) => { + expect(state.sortedActivities).toEqual([ + activityToExpectation(activity1, 1_000), + activityToExpectation(activity2, 2_000) + ]); + }) + .when('upserting an activity with t=2_000ms', (_, state) => upsert({ Date }, state, activity3)) + .then('should have added to `activityMap`', (_, state) => { + expect(state.activityMap).toEqual( + new Map([ + [ + '_:a-00001' as LocalId, + { + activity: activityToExpectation(activity1), + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + type: 'activity' + } + ], + [ + '_:a-00002' as LocalId, + { + activity: activityToExpectation(activity2), + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: undefined, + type: 'activity' + } + ], + [ + '_:a-00003' as LocalId, + { + activity: activityToExpectation(activity3), + activityLocalId: '_:a-00003' as LocalId, + logicalTimestamp: 2_000, + type: 'activity' + } + ] + ]) + ); + }) + .and('should have added to `sortedChatHistoryList`', (_, state) => { + expect(state.sortedChatHistoryList).toEqual([ + { + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + type: 'activity' + }, + { + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: undefined, + type: 'activity' + }, + { + activityLocalId: '_:a-00003' as LocalId, + logicalTimestamp: 2_000, + type: 'activity' + } + ] satisfies SortedChatHistory); + }) + .and('`sortedActivities` should match', (_, state) => { + expect(state.sortedActivities).toEqual([ + activityToExpectation(activity1, 1_000), + activityToExpectation(activity2, 2_000), + activityToExpectation(activity3, 3_000) + ]); + }) + .when('upserting an activity with t=1500ms', (_, state) => upsert({ Date }, state, activity4)) + .then('should have added to `activityMap`', (_, state) => { + expect(state.activityMap).toEqual( + new Map([ + [ + '_:a-00001' as LocalId, + { + activity: activityToExpectation(activity1), + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + type: 'activity' + } + ], + [ + '_:a-00002' as LocalId, + { + activity: activityToExpectation(activity2), + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: undefined, + type: 'activity' + } + ], + [ + '_:a-00003' as LocalId, + { + activity: activityToExpectation(activity3), + activityLocalId: '_:a-00003' as LocalId, + logicalTimestamp: 2_000, + type: 'activity' + } + ], + [ + '_:a-00004' as LocalId, + { + activity: activityToExpectation(activity4), + activityLocalId: '_:a-00004' as LocalId, + logicalTimestamp: 1_500, + type: 'activity' + } + ] + ]) + ); + }) + .and('should have added to `sortedChatHistoryList`', (_, state) => { + expect(state.sortedChatHistoryList).toEqual([ + { + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + type: 'activity' + }, + { + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: undefined, + type: 'activity' + }, + { + activityLocalId: '_:a-00004' as LocalId, + logicalTimestamp: 1_500, + type: 'activity' + }, + { + activityLocalId: '_:a-00003' as LocalId, + logicalTimestamp: 2_000, + type: 'activity' + } + ] satisfies SortedChatHistory); + }) + .and('`sortedActivities` should match', (_, state) => { + expect(state.sortedActivities).toEqual([ + activityToExpectation(activity1, 1_000), + activityToExpectation(activity2, 2_000), + activityToExpectation(activity4, 2_001), + activityToExpectation(activity3, 3_000) + ]); + }) + .when('upserting the t=undefined activity is updated with t=1750ms', (_, state) => + upsert({ Date }, state, activity2b) + ) + .then('should have added to `activityMap`', (_, state) => { + expect(state.activityMap).toEqual( + new Map([ + [ + '_:a-00001' as LocalId, + { + activity: activityToExpectation(activity1), + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + type: 'activity' + } + ], + [ + '_:a-00002' as LocalId, + { + activity: activityToExpectation(activity2b), + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: 1_750, + type: 'activity' + } + ], + [ + '_:a-00003' as LocalId, + { + activity: activityToExpectation(activity3), + activityLocalId: '_:a-00003' as LocalId, + logicalTimestamp: 2_000, + type: 'activity' + } + ], + [ + '_:a-00004' as LocalId, + { + activity: activityToExpectation(activity4), + activityLocalId: '_:a-00004' as LocalId, + logicalTimestamp: 1_500, + type: 'activity' + } + ] + ]) + ); + }) + .and('should have added to `sortedChatHistoryList`', (_, state) => { + expect(state.sortedChatHistoryList).toEqual([ + { + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + type: 'activity' + }, + { + activityLocalId: '_:a-00004' as LocalId, + logicalTimestamp: 1_500, + type: 'activity' + }, + { + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: 1_750, // Update activity is moved here. + type: 'activity' + }, + { + activityLocalId: '_:a-00003' as LocalId, + logicalTimestamp: 2_000, + type: 'activity' + } + ] satisfies SortedChatHistory); + }) + .and('`sortedActivities` should match', (_, state) => { + expect(state.sortedActivities).toEqual([ + activityToExpectation(activity1, 1_000), + activityToExpectation(activity4, 2_001), + activityToExpectation(activity2b, 2_002), + activityToExpectation(activity3, 3_000) + ]); + }); +}); diff --git a/packages/core/src/reducers/activities/sort/upsert.howTo.spec.ts b/packages/core/src/reducers/activities/sort/upsert.howTo.spec.ts new file mode 100644 index 0000000000..85f69cf3df --- /dev/null +++ b/packages/core/src/reducers/activities/sort/upsert.howTo.spec.ts @@ -0,0 +1,368 @@ +/* eslint-disable no-restricted-globals */ +import { expect } from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import type { WebChatActivity } from '../../../types/WebChatActivity'; +import type { Activity, ActivityMapEntry, HowToGroupingId, HowToGroupingMapEntry, SortedChatHistory } from './types'; +import upsert, { INITIAL_STATE } from './upsert'; +import { LocalIdSchema, type LocalId } from './property/LocalId'; +import { parse } from 'valibot'; + +type SingularOrPlural = T | readonly T[]; + +function activityToExpectation(activity: Activity, expectedPosition: number = expect.any(Number) as any): Activity { + return { + ...activity, + channelData: { + ...activity.channelData, + 'webchat:internal:position': expectedPosition + } as any + }; +} + +function buildActivity( + activity: { id: string; text: string; timestamp: string }, + messageEntity: { isPartOf: SingularOrPlural<{ '@id': string; '@type': string }>; position: number } | undefined +): WebChatActivity { + const { id } = activity; + + return { + channelData: { + 'webchat:internal:local-id': parse(LocalIdSchema, `_:${id}`), + 'webchat:internal:position': 0, + 'webchat:send-status': undefined + }, + ...(messageEntity + ? { + entities: [ + { + '@context': 'https://schema.org', + '@id': '', + '@type': 'Message', + type: 'https://schema.org/Message', + ...messageEntity + } as any + ] + } + : {}), + from: { id: 'bot', role: 'bot' }, + type: 'message', + ...activity + }; +} + +scenario('upserting plain activity in the same grouping', bdd => { + const activity1 = buildActivity( + { id: 'a-00001', text: 'Hello, World!', timestamp: new Date(1_000).toISOString() }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 1 } + ); + + const activity2 = buildActivity( + { id: 'a-00002', text: 'Aloha!', timestamp: new Date(2_000).toISOString() }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 3 } + ); + + const activity3 = buildActivity( + { id: 'a-00003', text: 'Aloha!', timestamp: new Date(3_000).toISOString() }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 2 } + ); + + bdd + .given('an initial state', () => INITIAL_STATE) + .when('upserted', state => upsert({ Date }, state, activity1)) + .then('should have added to `activityMap`', (_, state) => { + expect(state.activityMap).toEqual( + new Map([ + [ + '_:a-00001' as LocalId, + { + activity: activityToExpectation(activity1), + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + type: 'activity' + } + ] + ]) + ); + }) + .and('should have added a new part grouping', (_, state) => { + expect(state.howToGroupingMap).toEqual( + new Map([ + [ + '_:how-to:00001' as HowToGroupingId, + { + logicalTimestamp: 1_000, + partList: [ + { + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + position: 1, + type: 'activity' + } + ] + } + ] + ]) + ); + }) + .and('should appear in `sortedChatHistoryList`', (_, state) => { + expect(state.sortedChatHistoryList).toEqual([ + { + howToGroupingId: '_:how-to:00001' as HowToGroupingId, + logicalTimestamp: 1_000, + type: 'how to grouping' + } + ] satisfies SortedChatHistory); + }) + .and('`sortedActivities` should match snapshot', (_, state) => { + expect(state).toHaveProperty('sortedActivities', [activityToExpectation(activity1)]); + }) + .when('upsert another activity into same group', (_, state) => upsert({ Date }, state, activity2)) + .then('should add to `activityMap`', (_, state) => { + expect(state.activityMap).toEqual( + new Map([ + [ + '_:a-00001' as LocalId, + { + activity: activityToExpectation(activity1), + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + type: 'activity' + } + ], + [ + '_:a-00002' as LocalId, + { + activity: activityToExpectation(activity2), + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: 2_000, + type: 'activity' + } + ] + ]) + ); + }) + .and('should update existing part grouping', (_, state) => { + expect(state.howToGroupingMap).toEqual( + new Map([ + [ + '_:how-to:00001' as HowToGroupingId, + { + logicalTimestamp: 2_000, + partList: [ + { + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + position: 1, + type: 'activity' + }, + { + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: 2_000, + position: 3, + type: 'activity' + } + ] + } + ] + ]) + ); + }) + .and('`sortedChatHistoryList` should match', (_, state) => { + expect(state.sortedChatHistoryList).toEqual([ + { + howToGroupingId: '_:how-to:00001' as HowToGroupingId, + logicalTimestamp: 2_000, // Should update to 2_000. + type: 'how to grouping' + } + ] satisfies SortedChatHistory); + }) + .and('`sortedActivities` should match', (_, state) => { + expect(state).toHaveProperty('sortedActivities', [ + activityToExpectation(activity1), + activityToExpectation(activity2) + ]); + }) + .when('the third activity is upserted', (_, state) => upsert({ Date }, state, activity3)) + .then('should add to `activityMap`', (_, state) => { + expect(state.activityMap).toEqual( + new Map([ + [ + '_:a-00001' as LocalId, + { + activity: activityToExpectation(activity1), + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + type: 'activity' + } + ], + [ + '_:a-00002' as LocalId, + { + activity: activityToExpectation(activity2), + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: 2_000, + type: 'activity' + } + ], + [ + '_:a-00003' as LocalId, + { + activity: activityToExpectation(activity3), + activityLocalId: '_:a-00003' as LocalId, + logicalTimestamp: 3_000, + type: 'activity' + } + ] + ]) + ); + }) + .and('should update existing part grouping', (_, state) => { + expect(state.howToGroupingMap).toEqual( + new Map([ + [ + '_:how-to:00001' as HowToGroupingId, + { + logicalTimestamp: 3_000, + partList: [ + { + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + position: 1, + type: 'activity' + }, + { + activityLocalId: '_:a-00003' as LocalId, + logicalTimestamp: 3_000, + position: 2, + type: 'activity' + }, + { + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: 2_000, + position: 3, + type: 'activity' + } + ] + } + ] + ]) + ); + }) + .and('should appear in `sortedChatHistoryList`', (_, state) => { + expect(state.sortedChatHistoryList).toEqual([ + { + howToGroupingId: '_:how-to:00001' as HowToGroupingId, + logicalTimestamp: 3_000, // Should not update to 3_000. + type: 'how to grouping' + } + ] satisfies SortedChatHistory); + }) + .and('`sortedActivities` should match snapshot', (_, state) => { + expect(state).toHaveProperty('sortedActivities', [ + activityToExpectation(activity1), + activityToExpectation(activity3), + activityToExpectation(activity2) + ]); + }); +}); + +scenario('upserting plain activity in two different grouping', bdd => { + const activity1 = buildActivity( + { id: 'a-00001', text: 'Hello, World!', timestamp: new Date(1000).toISOString() }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 1 } + ); + + const activity2 = buildActivity( + { id: 'a-00002', text: 'Aloha!', timestamp: new Date(500).toISOString() }, + { isPartOf: [{ '@id': '_:how-to:00002', '@type': 'HowTo' }], position: 1 } + ); + + bdd + .given('an initial state', () => INITIAL_STATE) + .when('upserted', state => upsert({ Date }, state, activity1)) + .then('should have added a new part grouping', (_, state) => { + expect(state.howToGroupingMap).toEqual( + new Map([ + [ + '_:how-to:00001' as HowToGroupingId, + { + logicalTimestamp: 1_000, + partList: [ + { + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + position: 1, + type: 'activity' + } + ] + } + ] + ]) + ); + }) + .and('should have inserted into `sortedChatHistoryList`', (_, state) => { + expect(state.sortedChatHistoryList).toEqual([ + { logicalTimestamp: 1_000, howToGroupingId: '_:how-to:00001', type: 'how to grouping' } + ]); + }) + .and('`sortedActivities` should match snapshot', (_, state) => { + expect(state.sortedActivities).toEqual([activityToExpectation(activity1, 1_000)]); + }) + .when('upsert another activity into same group with a former timestamp', (_, state) => + upsert({ Date }, state, activity2) + ) + .then('should update existing part grouping', (_, state) => { + expect(state.howToGroupingMap).toEqual( + new Map([ + [ + '_:how-to:00001' as HowToGroupingId, + { + logicalTimestamp: 1_000, + partList: [ + { + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + position: 1, + type: 'activity' + } + ] + } + ], + [ + '_:how-to:00002' as HowToGroupingId, + { + logicalTimestamp: 500, + partList: [ + { + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: 500, + position: 1, + type: 'activity' + } + ] + } + ] + ]) + ); + }) + .and('should appear in `sortedChatHistoryList` as a separate entry', (_, state) => { + expect(state.sortedChatHistoryList).toEqual([ + { + howToGroupingId: '_:how-to:00002' as HowToGroupingId, + logicalTimestamp: 500, + type: 'how to grouping' + }, + { + howToGroupingId: '_:how-to:00001' as HowToGroupingId, + logicalTimestamp: 1_000, + type: 'how to grouping' + } + ] satisfies SortedChatHistory); + }) + .and('`sortedActivities` should match snapshot', (_, state) => { + expect(state.sortedActivities).toEqual([ + activityToExpectation(activity2, 1), + activityToExpectation(activity1, 1_000) + ]); + }); +}); diff --git a/packages/core/src/reducers/activities/sort/upsert.howToWithLivestream.spec.ts b/packages/core/src/reducers/activities/sort/upsert.howToWithLivestream.spec.ts new file mode 100644 index 0000000000..e77c6724e9 --- /dev/null +++ b/packages/core/src/reducers/activities/sort/upsert.howToWithLivestream.spec.ts @@ -0,0 +1,388 @@ +/* eslint-disable no-restricted-globals */ +import { expect } from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import type { WebChatActivity } from '../../../types/WebChatActivity'; +import type { + Activity, + ActivityMapEntry, + HowToGroupingId, + HowToGroupingMapEntry, + LivestreamSessionId, + LivestreamSessionMapEntry, + LivestreamSessionMapEntryActivityEntry, + SortedChatHistory +} from './types'; +import upsert, { INITIAL_STATE } from './upsert'; +import { LocalIdSchema, type LocalId } from './property/LocalId'; +import { parse } from 'valibot'; + +type SingularOrPlural = T | readonly T[]; + +function activityToExpectation(activity: Activity, expectedPosition: number = expect.any(Number) as any): Activity { + return { + ...activity, + channelData: { + ...activity.channelData, + 'webchat:internal:position': expectedPosition + } as any + }; +} + +function buildActivity( + activity: + | { + channelData: + | { + streamId: string; + streamSequence?: never; + streamType: 'final'; + } + | undefined; + id: string; + text: string; + timestamp: string; + type: 'message'; + } + | { + channelData: + | { + streamId: string; + streamSequence: number; + streamType: 'informative' | 'streaming'; + } + | { + streamId?: never; + streamSequence: 1; + streamType: 'informative' | 'streaming'; + } + | undefined; + id: string; + text: string; + timestamp: string; + type: 'typing'; + }, + messageEntity: { isPartOf: SingularOrPlural<{ '@id': string; '@type': string }>; position: number } | undefined +): WebChatActivity { + const { id } = activity; + + return { + ...(messageEntity + ? { + entities: [ + { + '@context': 'https://schema.org', + '@id': '', + '@type': 'Message', + type: 'https://schema.org/Message', + ...messageEntity + } as any + ] + } + : {}), + from: { id: 'bot', role: 'bot' }, + ...activity, + channelData: { + 'webchat:internal:local-id': parse(LocalIdSchema, `_:${id}`), + 'webchat:internal:position': 0, + 'webchat:send-status': undefined, + ...activity.channelData + } + } as any; +} + +scenario('upserting plain activity in the same grouping', bdd => { + const activity1 = buildActivity( + { + channelData: { streamSequence: 1, streamType: 'streaming' }, + id: 'a-00001', + text: 'A quick', + timestamp: new Date(1_000).toISOString(), + type: 'typing' + }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 1 } + ); + + const activity2 = buildActivity( + { + channelData: { streamId: 'a-00001', streamSequence: 2, streamType: 'streaming' }, + id: 'a-00002', + text: 'A quick brown fox', + timestamp: new Date(2_000).toISOString(), + type: 'typing' + }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 1 } + ); + + const activity3 = buildActivity( + { + channelData: { streamId: 'a-00001', streamType: 'final' }, + id: 'a-00003', + text: 'A quick brown fox jumped over the lazy dogs.', + timestamp: new Date(3_000).toISOString(), + type: 'message' + }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 1 } + ); + + bdd + .given('an initial state', () => INITIAL_STATE) + .when('the first activity is upserted', state => upsert({ Date }, state, activity1)) + .then('should have added to `activityMap`', (_, state) => { + expect(state.activityMap).toEqual( + new Map([ + [ + '_:a-00001' as LocalId, + { + activity: activityToExpectation(activity1), + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + type: 'activity' + } + ] + ]) + ); + }) + .and('should have added a new part grouping', (_, state) => { + expect(state.howToGroupingMap).toEqual( + new Map([ + [ + '_:how-to:00001' as HowToGroupingId, + { + logicalTimestamp: 1_000, + partList: [ + { + livestreamSessionId: 'a-00001' as LivestreamSessionId, + logicalTimestamp: 1_000, + position: 1, + type: 'livestream session' + } + ] + } + ] + ]) + ); + }) + .and('should have added to `livestreamSessions`', (_, state) => { + expect(state.livestreamSessionMap).toEqual( + new Map([ + [ + 'a-00001' as LivestreamSessionId, + { + activities: [ + { + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + sequenceNumber: 1, + type: 'activity' + } satisfies LivestreamSessionMapEntryActivityEntry + ], + finalized: false, + logicalTimestamp: 1_000 + } + ] + ]) + ); + }) + .and('should appear in `sortedChatHistoryList`', (_, state) => { + expect(state.sortedChatHistoryList).toEqual([ + { + howToGroupingId: '_:how-to:00001' as HowToGroupingId, + logicalTimestamp: 1_000, + type: 'how to grouping' + } + ] satisfies SortedChatHistory); + }) + .and('`sortedActivities` should match snapshot', (_, state) => { + expect(state.sortedActivities).toEqual([activityToExpectation(activity1, 1_000)]); + }) + .when('the second activity is upserted', (_, state) => upsert({ Date }, state, activity2)) + .then('should have added to `activityMap`', (_, state) => { + expect(state.activityMap).toEqual( + new Map([ + [ + '_:a-00001' as LocalId, + { + activity: activityToExpectation(activity1), + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + type: 'activity' + } + ], + [ + '_:a-00002' as LocalId, + { + activity: activityToExpectation(activity2), + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: 2_000, + type: 'activity' + } + ] + ]) + ); + }) + .and('should not modify new part grouping', (_, state) => { + expect(state.howToGroupingMap).toEqual( + new Map([ + [ + '_:how-to:00001' as HowToGroupingId, + { + logicalTimestamp: 1_000, + partList: [ + { + livestreamSessionId: 'a-00001' as LivestreamSessionId, + logicalTimestamp: 1_000, + position: 1, + type: 'livestream session' + } + ] + } + ] + ]) + ); + }) + .and('should have added to `livestreamSessions`', (_, state) => { + expect(state.livestreamSessionMap).toEqual( + new Map([ + [ + 'a-00001' as LivestreamSessionId, + { + activities: [ + { + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + sequenceNumber: 1, + type: 'activity' + } satisfies LivestreamSessionMapEntryActivityEntry, + { + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: 2_000, + sequenceNumber: 2, + type: 'activity' + } satisfies LivestreamSessionMapEntryActivityEntry + ], + finalized: false, + logicalTimestamp: 1_000 + } + ] + ]) + ); + }) + .and('should not modify `sortedChatHistoryList`', (_, state) => { + expect(state.sortedChatHistoryList).toEqual([ + { + howToGroupingId: '_:how-to:00001' as HowToGroupingId, + logicalTimestamp: 1_000, + type: 'how to grouping' + } + ] satisfies SortedChatHistory); + }) + .and('`sortedActivities` should match snapshot', (_, state) => { + expect(state.sortedActivities).toEqual([ + activityToExpectation(activity1, 1_000), + activityToExpectation(activity2, 2_000) + ]); + }) + .when('the third activity is upserted', (_, state) => upsert({ Date }, state, activity3)) + .then('should have added to `activityMap`', (_, state) => { + expect(state.activityMap).toEqual( + new Map([ + [ + '_:a-00001' as LocalId, + { + activity: activityToExpectation(activity1), + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + type: 'activity' + } + ], + [ + '_:a-00002' as LocalId, + { + activity: activityToExpectation(activity2), + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: 2_000, + type: 'activity' + } + ], + [ + '_:a-00003' as LocalId, + { + activity: activityToExpectation(activity3), + activityLocalId: '_:a-00003' as LocalId, + logicalTimestamp: 3_000, + type: 'activity' + } + ] + ]) + ); + }) + .and('should not modify new part grouping', (_, state) => { + expect(state.howToGroupingMap).toEqual( + new Map([ + [ + '_:how-to:00001' as HowToGroupingId, + { + logicalTimestamp: 3_000, // Should follow livestream session and update to 3_000. + partList: [ + { + livestreamSessionId: 'a-00001' as LivestreamSessionId, + logicalTimestamp: 3_000, // Livestream updated to 3_000. + position: 1, + type: 'livestream session' + } + ] + } + ] + ]) + ); + }) + .and('should have added to `livestreamSessions`', (_, state) => { + expect(state.livestreamSessionMap).toEqual( + new Map([ + [ + 'a-00001' as LivestreamSessionId, + { + activities: [ + { + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + sequenceNumber: 1, + type: 'activity' + } satisfies LivestreamSessionMapEntryActivityEntry, + { + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: 2_000, + sequenceNumber: 2, + type: 'activity' + } satisfies LivestreamSessionMapEntryActivityEntry, + { + activityLocalId: '_:a-00003' as LocalId, + logicalTimestamp: 3_000, + sequenceNumber: Infinity, + type: 'activity' + } satisfies LivestreamSessionMapEntryActivityEntry + ], + finalized: true, + logicalTimestamp: 3_000 + } + ] + ]) + ); + }) + .and('`sortedChatHistoryList` should match', (_, state) => { + expect(state.sortedChatHistoryList).toEqual([ + { + howToGroupingId: '_:how-to:00001' as HowToGroupingId, + logicalTimestamp: 3_000, // Update to 3_000 on finalize. + type: 'how to grouping' + } + ] satisfies SortedChatHistory); + }) + .and('`sortedActivities` should match', (_, state) => { + expect(state.sortedActivities).toEqual([ + activityToExpectation(activity1, 1_000), + activityToExpectation(activity2, 2_000), + activityToExpectation(activity3, 3_000) + ]); + }); +}); diff --git a/packages/core/src/reducers/activities/sort/upsert.livestream.spec.ts b/packages/core/src/reducers/activities/sort/upsert.livestream.spec.ts new file mode 100644 index 0000000000..a0a864713e --- /dev/null +++ b/packages/core/src/reducers/activities/sort/upsert.livestream.spec.ts @@ -0,0 +1,423 @@ +/* eslint-disable no-restricted-globals */ +import { expect } from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import { parse } from 'valibot'; +import type { WebChatActivity } from '../../../types/WebChatActivity'; +import { LocalIdSchema, type LocalId } from './property/LocalId'; +import { + type Activity, + type ActivityMapEntry, + type LivestreamSessionId, + type LivestreamSessionMapEntry, + type LivestreamSessionMapEntryActivityEntry, + type SortedChatHistory, + type SortedChatHistoryEntry +} from './types'; +import upsert, { INITIAL_STATE } from './upsert'; + +function activityToExpectation(activity: Activity, expectedPosition: number = expect.any(Number) as any): Activity { + return { + ...activity, + channelData: { + ...activity.channelData, + 'webchat:internal:position': expectedPosition + } as any + }; +} + +function buildActivity( + activity: + | { + channelData: + | { + streamId: string; + streamSequence?: never; + streamType: 'final'; + } + | undefined; + id: string; + text: string; + timestamp: string; + type: 'message'; + } + | { + channelData: + | { + streamId: string; + streamSequence: number; + streamType: 'informative' | 'streaming'; + } + | { + streamId?: never; + streamSequence: 1; + streamType: 'informative' | 'streaming'; + } + | undefined; + id: string; + text: string; + timestamp: string; + type: 'typing'; + } +): WebChatActivity { + const { id } = activity; + + return { + from: { id: 'bot', role: 'bot' }, + ...activity, + channelData: { + 'webchat:internal:local-id': parse(LocalIdSchema, `_:${id}`), + 'webchat:internal:position': 0, + 'webchat:send-status': undefined, + ...activity.channelData + } + } as any; +} + +scenario('upserting a livestream session', bdd => { + const activity1 = buildActivity({ + channelData: { + streamSequence: 1, + streamType: 'streaming' + }, + id: 'a-00001', + text: 'A quick', + timestamp: new Date(1_000).toISOString(), + type: 'typing' + }); + + const activity2 = buildActivity({ + channelData: { + streamId: 'a-00001', + streamSequence: 3, // In reversed order. + streamType: 'streaming' + }, + id: 'a-00002', + text: 'A quick brown fox jumped over', + timestamp: new Date(3_000).toISOString(), + type: 'typing' + }); + + const activity3 = buildActivity({ + channelData: { + streamId: 'a-00001', + streamSequence: 2, + streamType: 'streaming' + }, + id: 'a-00003', + text: 'A quick brown fox', + timestamp: new Date(2_000).toISOString(), + type: 'typing' + }); + + const activity4 = buildActivity({ + channelData: { + streamId: 'a-00001', + streamType: 'final' + }, + id: 'a-00004', + text: 'A quick brown fox jumped over the lazy dogs.', + timestamp: new Date(4_000).toISOString(), + type: 'message' + }); + + bdd + .given('an initial state', () => INITIAL_STATE) + .when('upserted', state => upsert({ Date }, state, activity1)) + .then('should have added to `activityMap`', (_, state) => { + expect(state.activityMap).toEqual( + new Map([ + [ + '_:a-00001' as LocalId, + { + activity: activityToExpectation(activity1), + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + type: 'activity' + } + ] + ]) + ); + }) + .and('should have added to `livestreamSessions`', (_, state) => { + expect(state.livestreamSessionMap).toEqual( + new Map([ + [ + 'a-00001' as LivestreamSessionId, + { + activities: [ + { + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + sequenceNumber: 1, + type: 'activity' + } satisfies LivestreamSessionMapEntryActivityEntry + ], + finalized: false, + logicalTimestamp: 1_000 + } + ] + ]) + ); + }) + .and('should have added to `sortedChatHistoryList`', (_, state) => { + expect(state.sortedChatHistoryList).toEqual([ + { + livestreamSessionId: 'a-00001' as LivestreamSessionId, + logicalTimestamp: 1_000, + type: 'livestream session' + } + ] satisfies SortedChatHistory); + }) + .and('`sortedActivities` should match snapshot', (_, state) => { + expect(state.sortedActivities).toEqual([activityToExpectation(activity1, 1_000)]); + }) + .when('the second activity is upserted', (_, state) => upsert({ Date }, state, activity2)) + .then('should have added to `activityMap`', (_, state) => { + expect(state.activityMap).toEqual( + new Map([ + [ + '_:a-00001' as LocalId, + { + activity: activityToExpectation(activity1), + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + type: 'activity' + } satisfies ActivityMapEntry + ], + [ + '_:a-00002' as LocalId, + { + activity: activityToExpectation(activity2), + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: 3_000, + type: 'activity' + } satisfies ActivityMapEntry + ] + ]) + ); + }) + .and('should have added to `livestreamSessions`', (_, state) => { + expect(state.livestreamSessionMap).toEqual( + new Map([ + [ + 'a-00001' as LivestreamSessionId, + { + activities: [ + { + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + sequenceNumber: 1, + type: 'activity' + } satisfies LivestreamSessionMapEntryActivityEntry, + { + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: 3_000, + sequenceNumber: 3, + type: 'activity' + } satisfies LivestreamSessionMapEntryActivityEntry + ], + finalized: false, + logicalTimestamp: 1_000 + } + ] + ]) + ); + }) + .and('should not modify `sortedChatHistoryList`', (_, state) => { + expect(state.sortedChatHistoryList).toEqual([ + { + livestreamSessionId: 'a-00001' as LivestreamSessionId, + logicalTimestamp: 1_000, + type: 'livestream session' + } satisfies SortedChatHistoryEntry + ]); + }) + .and('`sortedActivities` should match snapshot', (_, state) => { + expect(state.sortedActivities).toEqual([ + activityToExpectation(activity1, 1_000), + activityToExpectation(activity2, 2_000) + ]); + }) + .when('the third activity is upserted', (_, state) => upsert({ Date }, state, activity3)) + .then('should have added to `activityMap`', (_, state) => { + expect(state.activityMap).toEqual( + new Map([ + [ + '_:a-00001' as LocalId, + { + activity: activityToExpectation(activity1), + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + type: 'activity' + } + ], + [ + '_:a-00003' as LocalId, + { + activity: activityToExpectation(activity3), + activityLocalId: '_:a-00003' as LocalId, + logicalTimestamp: 2_000, + type: 'activity' + } + ], + [ + '_:a-00002' as LocalId, + { + activity: activityToExpectation(activity2), + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: 3_000, + type: 'activity' + } + ] + ]) + ); + }) + .and('should have added to `livestreamSessions`', (_, state) => { + expect(state.livestreamSessionMap).toEqual( + new Map([ + [ + 'a-00001' as LivestreamSessionId, + { + activities: [ + { + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + sequenceNumber: 1, + type: 'activity' + } satisfies LivestreamSessionMapEntryActivityEntry, + { + activityLocalId: '_:a-00003' as LocalId, + logicalTimestamp: 2_000, + sequenceNumber: 2, + type: 'activity' + } satisfies LivestreamSessionMapEntryActivityEntry, + { + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: 3_000, + sequenceNumber: 3, + type: 'activity' + } satisfies LivestreamSessionMapEntryActivityEntry + ], + finalized: false, + logicalTimestamp: 1_000 + } + ] + ]) + ); + }) + .and('should update `sortedChatHistoryList` with updated timestamp', (_, state) => { + expect(state.sortedChatHistoryList).toEqual([ + { + livestreamSessionId: 'a-00001' as LivestreamSessionId, + logicalTimestamp: 1_000, + type: 'livestream session' + } satisfies SortedChatHistoryEntry + ]); + }) + .and('`sortedActivities` should match snapshot', (_, state) => { + expect(state.sortedActivities).toEqual([ + activityToExpectation(activity1, 1_000), + activityToExpectation(activity3, 1_001), + activityToExpectation(activity2, 2_000) + ]); + }) + .when('the fourth and final activity is upserted', (_, state) => upsert({ Date }, state, activity4)) + .then('should have added to `activityMap`', (_, state) => { + expect(state.activityMap).toEqual( + new Map([ + [ + '_:a-00001' as LocalId, + { + activity: activityToExpectation(activity1), + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + type: 'activity' + } + ], + [ + '_:a-00003' as LocalId, + { + activity: activityToExpectation(activity3), + activityLocalId: '_:a-00003' as LocalId, + logicalTimestamp: 2_000, + type: 'activity' + } + ], + [ + '_:a-00002' as LocalId, + { + activity: activityToExpectation(activity2), + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: 3_000, + type: 'activity' + } + ], + [ + '_:a-00004' as LocalId, + { + activity: activityToExpectation(activity4), + activityLocalId: '_:a-00004' as LocalId, + logicalTimestamp: 4_000, + type: 'activity' + } + ] + ]) + ); + }) + .and('should have added to `livestreamSessions`', (_, state) => { + expect(state.livestreamSessionMap).toEqual( + new Map([ + [ + 'a-00001' as LivestreamSessionId, + { + activities: [ + { + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + sequenceNumber: 1, + type: 'activity' + } satisfies LivestreamSessionMapEntryActivityEntry, + { + activityLocalId: '_:a-00003' as LocalId, + logicalTimestamp: 2_000, + sequenceNumber: 2, + type: 'activity' + } satisfies LivestreamSessionMapEntryActivityEntry, + { + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: 3_000, + sequenceNumber: 3, + type: 'activity' + } satisfies LivestreamSessionMapEntryActivityEntry, + { + activityLocalId: '_:a-00004' as LocalId, + logicalTimestamp: 4_000, + sequenceNumber: Infinity, + type: 'activity' + } satisfies LivestreamSessionMapEntryActivityEntry + ], + finalized: true, + logicalTimestamp: 4_000 + } + ] + ]) + ); + }) + .and('should update `sortedChatHistoryList` with updated timestamp', (_, state) => { + expect(state.sortedChatHistoryList).toEqual([ + { + livestreamSessionId: 'a-00001' as LivestreamSessionId, + logicalTimestamp: 4_000, + type: 'livestream session' + } satisfies SortedChatHistoryEntry + ]); + }) + .and('`sortedActivities` should match snapshot', (_, state) => { + expect(state.sortedActivities).toEqual([ + activityToExpectation(activity1, 1_000), + activityToExpectation(activity3, 1_001), + activityToExpectation(activity2, 2_000), + activityToExpectation(activity4, 3_000) + ]); + }); +}); diff --git a/packages/core/src/reducers/activities/sort/upsert.ts b/packages/core/src/reducers/activities/sort/upsert.ts new file mode 100644 index 0000000000..c917d77568 --- /dev/null +++ b/packages/core/src/reducers/activities/sort/upsert.ts @@ -0,0 +1,344 @@ +/* eslint-disable complexity */ +import type { GlobalScopePonyfill } from '../../../types/GlobalScopePonyfill'; +import getActivityLivestreamingMetadata from '../../../utils/getActivityLivestreamingMetadata'; +import computePartListTimestamp from './private/computePartListTimestamp'; +import computeSortedActivities from './private/computeSortedActivities'; +import getLogicalTimestamp from './private/getLogicalTimestamp'; +import getPartGroupingMetadataMap from './private/getPartGroupingMetadataMap'; +import insertSorted from './private/insertSorted'; +import { getLocalIdFromActivity } from './property/LocalId'; +import { queryPositionFromActivity, setPositionInActivity } from './property/Position'; +import { + type Activity, + type ActivityMapEntry, + type HowToGroupingId, + type LivestreamSessionId, + type LivestreamSessionMapEntry, + type LivestreamSessionMapEntryActivityEntry, + type SortedChatHistoryEntry, + type State +} from './types'; + +// Honoring timestamp or not: +// +// - Update activity +// - (Should honor) every changes +// - Echo back activity +// - (Should honor) timestamp of echo back of outgoing message +// - Livestream activity +// - (Should not honor) timestamp of revisions of livestream as it could "flash" them to the bottom +// - Should not update session timestamp +// - How: +// - If it's 1 or Nth revision, copy the timestamp from upserting activity into session +// - Otherwise, it's 2...N-1, don't copy the timestamp into session +// - HowTo part grouping +// - (Should not honor) timestamp change via livestream as it could "flash" them to the bottom +// - Not honoring by copying the timestamp from livestream session +// - How: copy the timestamp from the upserting part (livestream or update) into part grouping +// +// Simplifying/concluding all rules: +// +// - Always copy timestamp, except when it's a livestream of 2...N-1 revision +// - Part grouping timestamp is copied from upserting entry (either livestream session or activity) + +const INITIAL_STATE = Object.freeze({ + activityIdToLocalIdMap: Object.freeze(new Map()), + activityMap: Object.freeze(new Map()), + clientActivityIdToLocalIdMap: Object.freeze(new Map()), + livestreamSessionMap: Object.freeze(new Map()), + howToGroupingMap: Object.freeze(new Map()), + sortedActivities: Object.freeze([]), + sortedChatHistoryList: Object.freeze([]) +} satisfies State); + +// Question: Why insertion sort works but not quick sort? +// Short answer: Arrival order matters. +// Long answer: +// - Update activity: when replacing an activity, data from their previous revision matters +// - Duplicate timestamps: activities without timestamp can't be sort deterministically with quick sort + +function upsert(ponyfill: Pick, state: State, activity: Activity): State { + const nextActivityIdToLocalIdMap = new Map(state.activityIdToLocalIdMap); + const nextActivityMap = new Map(state.activityMap); + const nextClientActivityIdToLocalIdMap = new Map(state.clientActivityIdToLocalIdMap); + const nextLivestreamSessionMap = new Map(state.livestreamSessionMap); + const nextHowToGroupingMap = new Map(state.howToGroupingMap); + let nextSortedChatHistoryList = Array.from(state.sortedChatHistoryList); + + const activityLocalId = getLocalIdFromActivity(activity); + const logicalTimestamp = getLogicalTimestamp(activity, ponyfill); + + if (typeof activity.id !== 'undefined') { + nextActivityIdToLocalIdMap.set(activity.id, activityLocalId); + } + + const { clientActivityID } = activity.channelData; + + if (typeof clientActivityID !== 'undefined') { + nextClientActivityIdToLocalIdMap.set(clientActivityID, activityLocalId); + } + + nextActivityMap.set( + activityLocalId, + Object.freeze({ + activity, + activityLocalId, + logicalTimestamp, + type: 'activity' + }) + ); + + let sortedChatHistoryListEntry: SortedChatHistoryEntry = { + activityLocalId, + logicalTimestamp, + type: 'activity' + }; + + // #region Livestreaming + + const activityLivestreamingMetadata = getActivityLivestreamingMetadata(activity); + + if (activityLivestreamingMetadata) { + const sessionId = activityLivestreamingMetadata.sessionId as LivestreamSessionId; + + const livestreamSessionMapEntry = nextLivestreamSessionMap.get(sessionId); + + const wasFinalized = livestreamSessionMapEntry ? livestreamSessionMapEntry.finalized : false; + + if (wasFinalized) { + console.warn( + `botframework-webchat: Cannot update livestreaming session ${sessionId} because it has been concluded` + ); + + // This is a special case. + // TODO: [P1] Revisit this and see how we should process activity after the livestream is finalized. + // 1. Received a previous-and-valid revision while the livestream is finalized (probably should keep to maintain history) + // 2. Received a final activity while the livestream is already finalized (probably drop due to bad packet) + // Related to /__tests__/html2/livestream/concludedLivestream.html. + return state; + } + + const finalized = activityLivestreamingMetadata.type === 'final activity'; + + const nextLivestreamSessionMapEntry = { + activities: Object.freeze( + insertSorted( + livestreamSessionMapEntry ? livestreamSessionMapEntry.activities : [], + Object.freeze({ + activityLocalId, + logicalTimestamp, + sequenceNumber: activityLivestreamingMetadata.sequenceNumber, + type: 'activity' + }), + ({ sequenceNumber: x }, { sequenceNumber: y }) => + typeof x === 'undefined' || typeof y === 'undefined' + ? // eslint-disable-next-line no-magic-numbers + -1 + : x - y + ) + ), + finalized, + // Update timestamp if the upserting activity is the first or last in the livestream session. + // We don't update timestamp for 2...N-1, because it would cause too much flickering. + logicalTimestamp: + finalized || !livestreamSessionMapEntry ? logicalTimestamp : livestreamSessionMapEntry.logicalTimestamp + } satisfies LivestreamSessionMapEntry; + + nextLivestreamSessionMap.set(sessionId, Object.freeze(nextLivestreamSessionMapEntry)); + + sortedChatHistoryListEntry = { + livestreamSessionId: sessionId, + logicalTimestamp: nextLivestreamSessionMapEntry.logicalTimestamp, + type: 'livestream session' + }; + } + + // #endregion + + // #region How-to grouping + + const howToGrouping = getPartGroupingMetadataMap(activity).get('HowTo'); + + if (howToGrouping) { + const howToGroupingId = howToGrouping.groupingId as HowToGroupingId; + const { position: howToGroupingPosition } = howToGrouping; + + const partGroupingMapEntry = nextHowToGroupingMap.get(howToGroupingId); + + let nextPartList = partGroupingMapEntry ? Array.from(partGroupingMapEntry.partList) : []; + + const existingPartEntryIndex = activityLivestreamingMetadata + ? nextPartList.findIndex( + entry => + entry.type === 'livestream session' && entry.livestreamSessionId === activityLivestreamingMetadata.sessionId + ) + : nextPartList.findIndex(entry => entry.type === 'activity' && entry.activityLocalId === activityLocalId); + + const nextPartEntry = Object.freeze({ ...sortedChatHistoryListEntry, position: howToGroupingPosition }); + + // If the upserting activity is position-less and an earlier revision is in the grouping, update the existing entry instead of splice/insert. + if (~existingPartEntryIndex && typeof howToGroupingPosition === 'undefined') { + nextPartList[+existingPartEntryIndex] = nextPartEntry; + } else { + // The upserting activity has position, or it never exist in the grouping. + ~existingPartEntryIndex && nextPartList.splice(existingPartEntryIndex, 1); + + nextPartList = insertSorted( + nextPartList, + nextPartEntry, + // eslint-disable-next-line no-magic-numbers + ({ position: x }, { position: y }) => (typeof x === 'undefined' || typeof y === 'undefined' ? -1 : x - y) + ); + } + + const nextPartGroupingEntry = { + logicalTimestamp: computePartListTimestamp(nextPartList), + partList: Object.freeze(nextPartList) + }; + + nextHowToGroupingMap.set(howToGroupingId, Object.freeze(nextPartGroupingEntry)); + + sortedChatHistoryListEntry = { + howToGroupingId, + logicalTimestamp: nextPartGroupingEntry.logicalTimestamp, + type: 'how to grouping' + }; + } + + // #endregion + + // #region Sorted chat history + + const existingSortedChatHistoryListEntryIndex = + sortedChatHistoryListEntry.type === 'how to grouping' + ? nextSortedChatHistoryList.findIndex( + entry => + entry.type === 'how to grouping' && entry.howToGroupingId === sortedChatHistoryListEntry.howToGroupingId + ) + : sortedChatHistoryListEntry.type === 'livestream session' + ? nextSortedChatHistoryList.findIndex( + entry => + entry.type === 'livestream session' && + entry.livestreamSessionId === sortedChatHistoryListEntry.livestreamSessionId + ) + : sortedChatHistoryListEntry.type === 'activity' + ? nextSortedChatHistoryList.findIndex( + entry => entry.type === 'activity' && entry.activityLocalId === activityLocalId + ) + : // eslint-disable-next-line no-magic-numbers + -1; + + ~existingSortedChatHistoryListEntryIndex && + nextSortedChatHistoryList.splice(existingSortedChatHistoryListEntryIndex, 1); + + nextSortedChatHistoryList = insertSorted( + nextSortedChatHistoryList, + Object.freeze(sortedChatHistoryListEntry), + (x, y) => { + // Compare logical timestamp if both have it. + // Otherwise, compare local timestamp if both have it. + // Otherwise, -1. + const xLogicalTimestamp = x.logicalTimestamp; + const yLogicalTimestamp = y.logicalTimestamp; + + if (typeof xLogicalTimestamp !== 'undefined' && typeof yLogicalTimestamp !== 'undefined') { + return xLogicalTimestamp - yLogicalTimestamp; + } + + if (x.type === 'activity' && y.type === 'activity') { + const xActivity = nextActivityMap.get(x.activityLocalId); + const yActivity = nextActivityMap.get(y.activityLocalId); + + const xLocalTimestamp = xActivity?.activity.localTimestamp; + const yLocalTimestamp = yActivity?.activity.localTimestamp; + + if (typeof xLocalTimestamp !== 'undefined' && typeof yLocalTimestamp !== 'undefined') { + return +new ponyfill.Date(xLocalTimestamp) - +new ponyfill.Date(yLocalTimestamp); + } + } + + // eslint-disable-next-line no-magic-numbers + return -1; + } + ); + // } + + // #endregion + + // #region Sorted activities + + const nextSortedActivities = computeSortedActivities({ + activityMap: nextActivityMap, + howToGroupingMap: nextHowToGroupingMap, + livestreamSessionMap: nextLivestreamSessionMap, + sortedChatHistoryList: nextSortedChatHistoryList + }); + + // #endregion + + // #region Sequence sorted activities + + let lastPosition = 0; + const POSITION_INCREMENT = 1_000; + + for ( + let index = 0, { length: nextSortedActivitiesLength } = nextSortedActivities; + index < nextSortedActivitiesLength; + index++ + ) { + const currentActivity = nextSortedActivities[+index]!; + const currentActivityId = getLocalIdFromActivity(currentActivity); + const hasNextSibling = index + 1 < nextSortedActivitiesLength; + const position = queryPositionFromActivity(currentActivity); + + let nextPosition: number; + + if (typeof position === 'undefined' || position <= lastPosition) { + if (hasNextSibling) { + const nextSiblingPosition = queryPositionFromActivity(nextSortedActivities[+index + 1]!); + + nextPosition = lastPosition + 1; + + if (typeof nextSiblingPosition === 'undefined' || nextPosition > nextSiblingPosition) { + nextPosition = lastPosition + POSITION_INCREMENT; + } + } else { + nextPosition = lastPosition + POSITION_INCREMENT; + } + } else { + nextPosition = position; + } + + if (nextPosition !== position) { + const activityMapEntry = nextActivityMap.get(currentActivityId)!; + + const nextActivityEntry: ActivityMapEntry = Object.freeze({ + ...activityMapEntry, + // TODO: [P0] We should freeze the activity. + // For backcompat, we should consider have a props that temporarily disable this behavior. + activity: setPositionInActivity(activityMapEntry.activity, nextPosition) + }); + + nextActivityMap.set(currentActivityId, nextActivityEntry); + + nextSortedActivities[+index] = nextActivityEntry.activity; + } + + lastPosition = nextPosition; + } + + // #endregion + + return Object.freeze({ + activityIdToLocalIdMap: Object.freeze(nextActivityIdToLocalIdMap), + activityMap: Object.freeze(nextActivityMap), + clientActivityIdToLocalIdMap: Object.freeze(nextClientActivityIdToLocalIdMap), + howToGroupingMap: Object.freeze(nextHowToGroupingMap), + livestreamSessionMap: Object.freeze(nextLivestreamSessionMap), + sortedActivities: Object.freeze(nextSortedActivities), + sortedChatHistoryList: Object.freeze(nextSortedChatHistoryList) + } satisfies State); +} + +export default upsert; +export { INITIAL_STATE }; diff --git a/packages/core/src/reducers/activities/tsconfig.json b/packages/core/src/reducers/activities/tsconfig.json new file mode 100644 index 0000000000..742aff7f4d --- /dev/null +++ b/packages/core/src/reducers/activities/tsconfig.json @@ -0,0 +1,4 @@ +{ + "exclude": ["**/*.spec.*", "**/*.test.*"], + "extends": "@msinternal/botframework-webchat-tsconfig/current" +} diff --git a/packages/core/src/reducers/createActivitiesReducer.ts b/packages/core/src/reducers/createActivitiesReducer.ts deleted file mode 100644 index c7feb3e09b..0000000000 --- a/packages/core/src/reducers/createActivitiesReducer.ts +++ /dev/null @@ -1,484 +0,0 @@ -/* eslint no-magic-numbers: ["error", { "ignore": [0, 1, -1] }] */ - -import updateIn from 'simple-update-in'; -import { v4 } from 'uuid'; - -import { DELETE_ACTIVITY } from '../actions/deleteActivity'; -import { INCOMING_ACTIVITY } from '../actions/incomingActivity'; -import { MARK_ACTIVITY } from '../actions/markActivity'; -import { - POST_ACTIVITY_FULFILLED, - POST_ACTIVITY_IMPEDED, - POST_ACTIVITY_PENDING, - POST_ACTIVITY_REJECTED -} from '../actions/postActivity'; -import { SENDING, SEND_FAILED, SENT } from '../types/internal/SendStatus'; -import getActivityLivestreamingMetadata from '../utils/getActivityLivestreamingMetadata'; -import getOrgSchemaMessage from '../utils/getOrgSchemaMessage'; - -import type { Reducer } from 'redux'; -import type { DeleteActivityAction } from '../actions/deleteActivity'; -import type { IncomingActivityAction } from '../actions/incomingActivity'; -import type { MarkActivityAction } from '../actions/markActivity'; -import type { - PostActivityFulfilledAction, - PostActivityImpededAction, - PostActivityPendingAction, - PostActivityRejectedAction -} from '../actions/postActivity'; -import type { GlobalScopePonyfill } from '../types/GlobalScopePonyfill'; -import type { WebChatActivity } from '../types/WebChatActivity'; - -type ActivitiesAction = - | DeleteActivityAction - | IncomingActivityAction - | MarkActivityAction - | PostActivityFulfilledAction - | PostActivityImpededAction - | PostActivityPendingAction - | PostActivityRejectedAction; - -type ActivitiesState = WebChatActivity[]; - -const DEFAULT_STATE: ActivitiesState = []; -const DIRECT_LINE_PLACEHOLDER_URL = - 'https://docs.botframework.com/static/devportal/client/images/bot-framework-default-placeholder.png'; - -function getClientActivityID(activity: WebChatActivity): string | undefined { - return activity.channelData?.clientActivityID; -} - -function findByClientActivityID(clientActivityID: string): (activity: WebChatActivity) => boolean { - return (activity: WebChatActivity) => getClientActivityID(activity) === clientActivityID; -} - -/** - * Get sequence ID from `activity.channelData['webchat:sequence-id']` and fallback to `+new Date(activity.timestamp)`. - * - * Chat adapter may send sequence ID to affect activity reordering. Sequence ID is supposed to be Unix timestamp. - * - * @param activity Activity to get sequence ID from. - * @returns Sequence ID. - */ -function getSequenceIdOrDeriveFromTimestamp( - activity: WebChatActivity, - ponyfill: Pick -): number | undefined { - const sequenceId = activity.channelData?.['webchat:sequence-id']; - - if (typeof sequenceId === 'number') { - return sequenceId; - } - - const { timestamp } = activity; - - if (typeof timestamp === 'string') { - return +new ponyfill.Date(timestamp); - } else if ((timestamp as any) instanceof ponyfill.Date) { - console.warn('botframework-webchat: "timestamp" must be of type string, instead of Date.'); - - return +timestamp; - } - - return undefined; -} - -function patchActivity(activity: WebChatActivity, { Date }: GlobalScopePonyfill): WebChatActivity { - // Direct Line channel will return a placeholder image for the user-uploaded image. - // As observed, the URL for the placeholder image is https://docs.botframework.com/static/devportal/client/images/bot-framework-default-placeholder.png. - // To make our code simpler, we are removing the value if "contentUrl" is pointing to a placeholder image. - - // TODO: [P2] #2869 This "contentURL" removal code should be moved to DirectLineJS adapter. - - // Also, if the "contentURL" starts with "blob:", this means the user is uploading a file (the URL is constructed by URL.createObjectURL) - // Although the copy/reference of the file is temporary in-memory, to make the UX consistent across page refresh, we do not allow the user to re-download the file either. - - activity = updateIn(activity, ['attachments', () => true, 'contentUrl'], (contentUrl: string) => { - if (contentUrl !== DIRECT_LINE_PLACEHOLDER_URL && !/^blob:/iu.test(contentUrl)) { - return contentUrl; - } - }); - - activity = updateIn(activity, ['channelData'], channelData => ({ ...channelData })); - activity = updateIn(activity, ['channelData', 'webChat', 'receivedAt'], () => Date.now()); - - const messageEntity = getOrgSchemaMessage(activity.entities); - const entityPosition = messageEntity?.position; - const entityPartOf = messageEntity?.isPartOf?.['@id']; - - if (typeof entityPosition === 'number') { - activity = updateIn(activity, ['channelData', 'webchat:entity-position'], () => entityPosition); - } - - if (typeof entityPartOf === 'string') { - activity = updateIn(activity, ['channelData', 'webchat:entity-part-of'], () => entityPartOf); - } - - return activity; -} - -function upsertActivityWithSort( - activities: WebChatActivity[], - upsertingActivity: WebChatActivity, - ponyfill: GlobalScopePonyfill -): WebChatActivity[] { - const upsertingLivestreamingMetadata = getActivityLivestreamingMetadata(upsertingActivity); - - // TODO: [P1] To support time-travelling, we should not drop obsoleted livestreaming activities. - if (upsertingLivestreamingMetadata) { - const { sessionId } = upsertingLivestreamingMetadata; - - // If the upserting activity is going upsert into a concluded livestream, skip the activity. - const isLivestreamConcluded = activities.find(targetActivity => { - const targetMetadata = getActivityLivestreamingMetadata(targetActivity); - - return targetMetadata?.sessionId === sessionId && targetMetadata.type === 'final activity'; - }); - - if (isLivestreamConcluded) { - return activities; - } - } - - upsertingActivity = patchActivity(upsertingActivity, ponyfill); - - const { channelData: { clientActivityID: upsertingClientActivityID } = {} } = upsertingActivity; - - const nextActivities = activities.filter( - ({ channelData: { clientActivityID } = {}, id }) => - // We will remove all "sending messages" activities and activities with same ID - // "clientActivityID" is unique and used to track if the message has been sent and echoed back from the server - !(upsertingClientActivityID && clientActivityID === upsertingClientActivityID) && - !(id && id === upsertingActivity.id) - ); - - const upsertingEntityPosition = upsertingActivity.channelData?.['webchat:entity-position']; - const upsertingPartOf = upsertingActivity.channelData?.['webchat:entity-part-of']; - const upsertingSequenceId = getSequenceIdOrDeriveFromTimestamp(upsertingActivity, ponyfill); - - // TODO: [P1] #3953 We should move this patching logic to a DLJS wrapper for simplicity. - // If the message does not have sequence ID, use these fallback values: - // 1. `entities.position` where `entities.isPartOf[@type === 'HowTo']` - // - If they are not of same set, ignore `entities.position` - // 2. `channelData.streamSequence` field for same session IDk - // 3. `channelData['webchat:sequence-id']` - // - If not available, it will fallback to `+new Date(timestamp)` - // - Outgoing activity will not have `timestamp` field - - let indexToInsert = -1; - - if (typeof upsertingEntityPosition === 'number' && typeof upsertingPartOf === 'string') { - const activitiesOfSamePartGrouping = nextActivities.filter( - activity => activity.channelData['webchat:entity-part-of'] === upsertingPartOf - ); - - if (activitiesOfSamePartGrouping.length) { - const activityImmediateBeforeInsertion = activitiesOfSamePartGrouping.find( - activity => activity.channelData['webchat:entity-position'] > upsertingEntityPosition - ); - - indexToInsert = activityImmediateBeforeInsertion - ? nextActivities.indexOf(activityImmediateBeforeInsertion) - : nextActivities.indexOf(activitiesOfSamePartGrouping.at(-1)) + 1; - } - } - - if (!~indexToInsert && upsertingLivestreamingMetadata) { - const upsertingLivestreamingSessionId = upsertingLivestreamingMetadata.sessionId; - const upsertingLivestreamingSequenceNumber = upsertingLivestreamingMetadata.sequenceNumber; - const activitiesOfSameLivestreamSession = nextActivities.filter( - activity => getActivityLivestreamingMetadata(activity)?.sessionId === upsertingLivestreamingSessionId - ); - - if (activitiesOfSameLivestreamSession.length) { - const activityImmediateBeforeInsertion = activitiesOfSameLivestreamSession.find( - activity => getActivityLivestreamingMetadata(activity).sequenceNumber > upsertingLivestreamingSequenceNumber - ); - - indexToInsert = activityImmediateBeforeInsertion - ? nextActivities.indexOf(activityImmediateBeforeInsertion) - : nextActivities.indexOf(activitiesOfSameLivestreamSession.at(-1)) + 1; - } - } - - // If the upserting activity does not have sequence ID or timestamp, always append it. - if (!~indexToInsert && typeof upsertingSequenceId === 'number') { - indexToInsert = nextActivities.findIndex(activity => { - const currentSequenceId = getSequenceIdOrDeriveFromTimestamp(activity, ponyfill); - - if (typeof currentSequenceId === 'undefined') { - // Treat messages without sequence ID and timestamp as hidden/opaque. Don't use them to influence ordering. - // Related to /__tests__/html2/activityOrdering/mixingMessagesWithAndWithoutTimestamp.html. - return false; - } - - return currentSequenceId > upsertingSequenceId; - }); - } - - if (!~indexToInsert) { - // If no right place can be found, append it. - indexToInsert = nextActivities.length; - } - - const prevSibling: WebChatActivity = indexToInsert === 0 ? undefined : nextActivities.at(indexToInsert - 1); - const nextSibling: WebChatActivity = nextActivities.at(indexToInsert); - let upsertingPosition: number; - - if (prevSibling) { - const prevPosition = prevSibling.channelData['webchat:internal:position']; - - if (nextSibling) { - const nextSequenceId = nextSibling.channelData['webchat:internal:position']; - - // eslint-disable-next-line no-magic-numbers - upsertingPosition = (prevPosition + nextSequenceId) / 2; - } else { - upsertingPosition = prevPosition + 1; - } - } else if (nextSibling) { - const nextSequenceId = nextSibling.channelData['webchat:internal:position']; - - upsertingPosition = nextSequenceId - 1; - } else { - upsertingPosition = 1; - } - - upsertingActivity = updateIn( - upsertingActivity, - ['channelData', 'webchat:internal:position'], - () => upsertingPosition - ); - - nextActivities.splice(indexToInsert, 0, upsertingActivity); - - return nextActivities; -} - -export default function createActivitiesReducer( - ponyfill: GlobalScopePonyfill -): Reducer { - return function activities(state: ActivitiesState = DEFAULT_STATE, action: ActivitiesAction): ActivitiesState { - switch (action.type) { - case DELETE_ACTIVITY: - state = updateIn(state, [({ id }: WebChatActivity) => id === action.payload.activityID]); - break; - - case MARK_ACTIVITY: - { - const { payload } = action; - - state = updateIn( - state, - [({ id }: WebChatActivity) => id === payload.activityID, 'channelData', payload.name], - () => payload.value - ); - } - - break; - - case POST_ACTIVITY_PENDING: - { - let { - payload: { activity } - } = action; - - activity = updateIn(activity, ['channelData', 'webchat:internal:id'], () => v4()); - // `channelData.state` is being deprecated in favor of `channelData['webchat:send-status']`. - // Please refer to #4362 for details. Remove on or after 2024-07-31. - activity = updateIn(activity, ['channelData', 'state'], () => SENDING); - activity = updateIn(activity, ['channelData', 'webchat:send-status'], () => SENDING); - - // Assume the message was sent immediately after the very last message. - // This helps to maintain the order of the outgoing message before the server respond. - activity = updateIn(activity, ['channelData', 'webchat:sequence-id'], () => { - const lastActivity = state.at(-1); - - if (!lastActivity) { - return 1; - } - - const lastSequenceId = lastActivity.channelData['webchat:sequence-id']; - - if (typeof lastSequenceId === 'number') { - return lastSequenceId + 1; - } - - const lastTimestampInNumber = +new ponyfill.Date(lastActivity.timestamp); - - if (!isNaN(lastTimestampInNumber)) { - return lastTimestampInNumber + 1; - } - - return +new ponyfill.Date(); - }); - - state = upsertActivityWithSort(state, activity, ponyfill); - } - - break; - - case POST_ACTIVITY_IMPEDED: - state = updateIn( - state, - // `channelData.state` is being deprecated in favor of `channelData['webchat:send-status']`. - // Please refer to #4362 for details. Remove on or after 2024-07-31. - [findByClientActivityID(action.meta.clientActivityID), 'channelData', 'state'], - () => SEND_FAILED - ); - - break; - - case POST_ACTIVITY_REJECTED: - state = updateIn(state, [findByClientActivityID(action.meta.clientActivityID)], activity => { - activity = updateIn(activity, ['channelData', 'state'], () => SEND_FAILED); - - return updateIn(activity, ['channelData', 'webchat:send-status'], () => SEND_FAILED); - }); - - break; - - case POST_ACTIVITY_FULFILLED: - { - const existingActivity = state.find(findByClientActivityID(action.meta.clientActivityID)); - - if (!existingActivity) { - throw new Error( - 'botframework-webchat-internal: On POST_ACTIVITY_FULFILLED, there is no activities with same client activity ID' - ); - } - - // We will replace the outgoing activity with the version from the server - let activity = patchActivity(action.payload.activity, ponyfill); - - activity = updateIn( - activity, - // `channelData.state` is being deprecated in favor of `channelData['webchat:send-status']`. - // Please refer to #4362 for details. Remove on or after 2024-07-31. - ['channelData', 'state'], - () => SENT - ); - - activity = updateIn(activity, ['channelData', 'webchat:send-status'], () => SENT); - - activity = updateIn( - activity, - ['channelData', 'webchat:internal:id'], - () => existingActivity.channelData['webchat:internal:id'] - ); - - // Keep existing position. - activity = updateIn( - activity, - ['channelData', 'webchat:internal:position'], - () => existingActivity.channelData['webchat:internal:position'] - ); - - state = updateIn(state, [findByClientActivityID(action.meta.clientActivityID)], () => activity); - } - - break; - - case INCOMING_ACTIVITY: - { - let { - payload: { activity } - } = action; - - // Clean internal properties if they were passed from chat adapter. - // These properties should not be passed from external systems. - activity = updateIn(activity, ['channelData', 'webchat:internal:id']); - activity = updateIn(activity, ['channelData', 'webchat:internal:position']); - - // If the incoming activity is an echo back, we should keep the existing `channelData['webchat:send-status']` field. - // - // Otherwise, it will fail following scenario: - // - // 1. Send an activity to the service - // 2. Service echoed back the activity - // 3. Service did NOT return `postActivity` call - // - EXPECT: `channelData['webchat:send-status']` should be "sending". - // - ACTUAL: `channelData['webchat:send-status']` is `undefined` because the activity get overwritten by the echo back activity. - // The echo back activity contains no `channelData['webchat:send-status']`. - // - // While we are looking out for the scenario above, we should also look at the following scenarios: - // - // 1. Service restore chat history, including activities sent from the user. These activities has the following characteristics: - // - They do not have `channelData['webchat:send-status']`; - // - They do not have an ongoing `postActivitySaga`; - // - They should not previously appear in the chat history. - // 2. We need to mark these activities as "sent". - // - // In the future, when we revamp our object model, we could use a different signal so we don't need the code below, for example: - // - // - If `activity.id` is set, it is "sent", because the chat service assigned an ID to the activity; - // - If `activity.id` is not set, it is either "sending" or "send failed"; - // - If `activity.channelData['webchat:send-failed-reason']` is set, it is "send failed" with the reason, otherwise; - // - It is sending. - if (activity.from.role === 'user') { - const { id } = activity; - const clientActivityID = getClientActivityID(activity); - - const existingActivity = state.find( - activity => - (clientActivityID && getClientActivityID(activity) === clientActivityID) || (id && activity.id === id) - ); - - if (existingActivity) { - const { - channelData: { 'webchat:internal:id': permanentId, 'webchat:send-status': sendStatus } - } = existingActivity; - - activity = updateIn(activity, ['channelData', 'webchat:internal:id'], () => permanentId); - - if (sendStatus === SENDING || sendStatus === SEND_FAILED || sendStatus === SENT) { - activity = updateIn(activity, ['channelData', 'webchat:send-status'], () => sendStatus); - } - } else { - activity = updateIn(activity, ['channelData', 'webchat:internal:id'], () => v4()); - - // If there are no existing activity, probably this activity is restored from chat history. - // All outgoing activities restored from service means they arrived at the service successfully. - // Thus, we are marking them as "sent". - activity = updateIn(activity, ['channelData', 'webchat:send-status'], () => SENT); - } - } else { - if (!activity.id) { - const newActivityId = v4(); - - console.warn( - 'botframework-webchat: Incoming activity must have "id" field set, assigning a random value as ID', - { - activity, - newActivityId - } - ); - - activity = updateIn(activity, ['id'], () => newActivityId); - } - - const existingActivity = state.find(({ id }) => id === activity.id); - - if (existingActivity) { - activity = updateIn( - activity, - ['channelData', 'webchat:internal:id'], - () => existingActivity.channelData['webchat:internal:id'] - ); - } else { - activity = updateIn(activity, ['channelData', 'webchat:internal:id'], () => v4()); - } - } - - state = upsertActivityWithSort(state, activity, ponyfill); - } - - break; - - default: - break; - } - - return state; - }; -} diff --git a/packages/core/src/reducers/private/findBeforeAfter.spec.ts b/packages/core/src/reducers/private/findBeforeAfter.spec.ts deleted file mode 100644 index 87362b2b48..0000000000 --- a/packages/core/src/reducers/private/findBeforeAfter.spec.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* eslint no-magic-numbers: "off" */ - -import findBeforeAfter from './findBeforeAfter'; - -let before: number | string | undefined; -let after: number | string | undefined; -let index: number | undefined; -const getPosition = jest.fn<'after' | 'before' | 'unknown', [unknown]>().mockImplementation(() => { - throw new Error('This function should not be called.'); -}); - -describe('when passing an empty array', () => { - beforeEach(() => ([before, after, index] = findBeforeAfter([] as (number | string)[], getPosition))); - - test('before should be undefined', () => expect(before).toBeUndefined()); - test('after should be undefined', () => expect(after).toBeUndefined()); - test('index should be undefined', () => expect(index).toBeUndefined()); -}); - -describe('when passing a value of 2 to [a, 1, 3, b]', () => { - beforeEach(() => { - getPosition.mockImplementation(value => (typeof value === 'number' ? (value < 2 ? 'before' : 'after') : 'unknown')); - - [before, after, index] = findBeforeAfter(['a', 1, 3, 'b'], getPosition); - }); - - test('before should be 1', () => expect(before).toBe(1)); - test('after should be 3', () => expect(after).toBe(3)); - test('index should be 2', () => expect(index).toBe(2)); -}); - -describe('when passing a value of 2 to [3, b]', () => { - beforeEach(() => { - getPosition.mockImplementation(value => (typeof value === 'number' ? (value < 2 ? 'before' : 'after') : 'unknown')); - - [before, after, index] = findBeforeAfter([3, 'b'], getPosition); - }); - - test('before should be undefined', () => expect(before).toBeUndefined()); - test('after should be 3', () => expect(after).toBe(3)); - test('index should be 0', () => expect(index).toBe(0)); -}); - -describe('when passing a value of 2 to [a, 1]', () => { - beforeEach(() => { - getPosition.mockImplementation(value => (typeof value === 'number' ? (value < 2 ? 'before' : 'after') : 'unknown')); - - [before, after, index] = findBeforeAfter(['a', 1], getPosition); - }); - - test('before should be 1', () => expect(before).toBe(1)); - test('after should be undefined', () => expect(after).toBeUndefined()); - test('index should be 2', () => expect(index).toBe(2)); -}); - -describe('when passing a value of 2 to [a, 1, b]', () => { - beforeEach(() => { - getPosition.mockImplementation(value => (typeof value === 'number' ? (value < 2 ? 'before' : 'after') : 'unknown')); - - [before, after, index] = findBeforeAfter(['a', 1, 'b'], getPosition); - }); - - test('before should be 1', () => expect(before).toBe(1)); - test('after should be "b"', () => expect(after).toBe('b')); - test('index should be 2', () => expect(index).toBe(2)); -}); - -describe('when passing a value of 2 to [a, 3, b]', () => { - beforeEach(() => { - getPosition.mockImplementation(value => (typeof value === 'number' ? (value < 2 ? 'before' : 'after') : 'unknown')); - - [before, after, index] = findBeforeAfter(['a', 3, 'b'], getPosition); - }); - - test('before should be "a"', () => expect(before).toBe('a')); - test('after should be 3', () => expect(after).toBe(3)); - test('index should be 1', () => expect(index).toBe(1)); -}); - -describe('when passing a value of 2 to [a, b]', () => { - beforeEach(() => { - getPosition.mockImplementation(value => (typeof value === 'number' ? (value < 2 ? 'before' : 'after') : 'unknown')); - - [before, after, index] = findBeforeAfter(['a', 'b'], getPosition); - }); - - test('before should be undefined', () => expect(before).toBeUndefined()); - test('after should be undefined', () => expect(after).toBeUndefined()); - test('index should be undefined', () => expect(index).toBeUndefined()); -}); diff --git a/packages/core/src/reducers/private/findBeforeAfter.ts b/packages/core/src/reducers/private/findBeforeAfter.ts deleted file mode 100644 index bec4e48452..0000000000 --- a/packages/core/src/reducers/private/findBeforeAfter.ts +++ /dev/null @@ -1,30 +0,0 @@ -type Position = 'after' | 'before' | 'unknown'; - -export default function findBeforeAfter( - array: T[], - getPosition: (value: T) => Position -): [T | undefined, T | undefined, number | undefined] { - let lastValue: T | undefined; - let lastPosition: Position = 'unknown'; - - for (let index = 0; index < array.length; index++) { - const value = array[+index]; - const currentPosition = getPosition(value); - - if ( - ((lastPosition === 'before' || lastPosition === 'unknown') && currentPosition === 'after') || - (lastPosition === 'before' && (currentPosition === 'after' || currentPosition === 'unknown')) - ) { - return [lastValue, value, index]; - } - - lastValue = value; - lastPosition = currentPosition; - } - - if (lastPosition === 'before') { - return [lastValue, undefined, array.length]; - } - - return [undefined, undefined, undefined]; -} diff --git a/packages/core/src/sagas/postActivitySaga.ts b/packages/core/src/sagas/postActivitySaga.ts index 7bf8ab2e9b..6b021ceef6 100644 --- a/packages/core/src/sagas/postActivitySaga.ts +++ b/packages/core/src/sagas/postActivitySaga.ts @@ -8,18 +8,15 @@ import { POST_ACTIVITY_PENDING, POST_ACTIVITY_REJECTED } from '../actions/postActivity'; -import dateToLocaleISOString from '../utils/dateToLocaleISOString'; -import deleteKey from '../utils/deleteKey'; import languageSelector from '../selectors/language'; -import observeOnce from './effects/observeOnce'; import sendTimeoutSelector from '../selectors/sendTimeout'; +import dateToLocaleISOString from '../utils/dateToLocaleISOString'; +import deleteKey from '../utils/deleteKey'; import sleep from '../utils/sleep'; import uniqueID from '../utils/uniqueID'; +import observeOnce from './effects/observeOnce'; import whileConnected from './effects/whileConnected'; -import type { DirectLineActivity } from '../types/external/DirectLineActivity'; -import type { DirectLineJSBotConnection } from '../types/external/DirectLineJSBotConnection'; -import type { GlobalScopePonyfill } from '../types/GlobalScopePonyfill'; import type { IncomingActivityAction } from '../actions/incomingActivity'; import type { PostActivityAction, @@ -28,8 +25,12 @@ import type { PostActivityPendingAction, PostActivityRejectedAction } from '../actions/postActivity'; -import type { WebChatActivity } from '../types/WebChatActivity'; +import { setSendStatusInOutgoingActivity } from '../reducers/activities/sort/property/SendStatus'; +import type { DirectLineActivity } from '../types/external/DirectLineActivity'; +import type { DirectLineJSBotConnection } from '../types/external/DirectLineJSBotConnection'; +import type { GlobalScopePonyfill } from '../types/GlobalScopePonyfill'; import type { WebChatOutgoingActivity } from '../types/internal/WebChatOutgoingActivity'; +import type { WebChatActivity } from '../types/WebChatActivity'; // After 5 minutes, the saga will stop from listening for echo backs and consider the outgoing message as permanently undeliverable. // This value must be equals to or larger than the user-defined `styleOptions.sendTimeout`. @@ -52,13 +53,12 @@ function* postActivity( // Currently, we allow untyped outgoing activity as long as the chat adapter can deliver. // In the future, we should warn if the outgoing activity is not matching the type. - const outgoingActivity: WebChatOutgoingActivity = { + let outgoingActivity: WebChatOutgoingActivity = { ...deleteKey(activity, 'id'), channelData: { - // Remove local fields that should not be send to the service. // `channelData.state` is being deprecated in favor of `channelData['webchat:send-status']`. // Please refer to #4362 for details. Remove on or after 2024-07-31. - ...deleteKey(activity.channelData, 'state', 'webchat:send-status'), + ...deleteKey(activity.channelData, 'state'), clientActivityID }, channelId: 'webchat', @@ -90,6 +90,13 @@ function* postActivity( : {}) }; + // Remove local fields that should not be send to the service. + outgoingActivity = setSendStatusInOutgoingActivity( + // TODO: [P1] Need to rework WebChatActivity typing. + outgoingActivity as WebChatActivity, + undefined + ) as WebChatOutgoingActivity; + if (!numActivitiesPosted) { outgoingActivity.entities = [ ...(outgoingActivity.entities || []), diff --git a/packages/core/src/types/WebChatActivity.ts b/packages/core/src/types/WebChatActivity.ts index 464c056bcd..ec9efa2984 100644 --- a/packages/core/src/types/WebChatActivity.ts +++ b/packages/core/src/types/WebChatActivity.ts @@ -7,6 +7,7 @@ // - However, we do not expect the server to return "localTimestamp" as they may not have capability to store this information // - "conversationUpdate" activity is never sent to Web Chat, thus, it is not defined +import type { LocalId } from '../activity/index'; import type { AnyAnd } from './AnyAnd'; // import type { AsEntity, Thing } from './external/OrgSchema/Thing'; import type { DirectLineAttachment } from './external/DirectLineAttachment'; @@ -23,23 +24,23 @@ type ChannelData; diff --git a/packages/core/test-d/direct-line-activity-from-user-send-failed.test-d.ts b/packages/core/test-d/direct-line-activity-from-user-send-failed.test-d.ts index aaf16636ca..2332a4c54b 100644 --- a/packages/core/test-d/direct-line-activity-from-user-send-failed.test-d.ts +++ b/packages/core/test-d/direct-line-activity-from-user-send-failed.test-d.ts @@ -1,11 +1,12 @@ import { expectAssignable } from 'tsd'; +import type { LocalId } from '../src/activity'; import { type WebChatActivity } from '../src/index'; // All activities that failed to send, are activities that never reach the server (a.k.a. activity-in-transit). expectAssignable({ channelData: { - 'webchat:internal:id': 'a-00001', + 'webchat:internal:local-id': '_:a-00001' as LocalId, 'webchat:internal:position': 0, 'webchat:send-status': 'send failed', 'webchat:sequence-id': 0 diff --git a/packages/core/test-d/direct-line-activity-from-user-sending.test-d.ts b/packages/core/test-d/direct-line-activity-from-user-sending.test-d.ts index 10ae4113fa..d169ac9045 100644 --- a/packages/core/test-d/direct-line-activity-from-user-sending.test-d.ts +++ b/packages/core/test-d/direct-line-activity-from-user-sending.test-d.ts @@ -1,11 +1,12 @@ import { expectAssignable } from 'tsd'; +import type { LocalId } from '../src/activity'; import { type WebChatActivity } from '../src/index'; // All activities that are sending, are activities that did not reach the server yet (a.k.a. activity-in-transit). expectAssignable({ channelData: { - 'webchat:internal:id': 'a-00001', + 'webchat:internal:local-id': '_:a-00001' as LocalId, 'webchat:internal:position': 0, 'webchat:send-status': 'sending', 'webchat:sequence-id': 0 diff --git a/packages/core/test-d/direct-line-activity-from-user-sent.test-d.ts b/packages/core/test-d/direct-line-activity-from-user-sent.test-d.ts index 439d378453..411e6ae67e 100644 --- a/packages/core/test-d/direct-line-activity-from-user-sent.test-d.ts +++ b/packages/core/test-d/direct-line-activity-from-user-sent.test-d.ts @@ -1,11 +1,12 @@ import { expectAssignable } from 'tsd'; +import type { LocalId } from '../src/activity'; import { type WebChatActivity } from '../src/index'; // All activities which are "sent", must be from server. expectAssignable({ channelData: { - 'webchat:internal:id': 'a-00001', + 'webchat:internal:local-id': '_:a-00001' as LocalId, 'webchat:internal:position': 0, 'webchat:send-status': 'sent', 'webchat:sequence-id': 0 diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 24fc63546f..bde073ebde 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -6,6 +6,7 @@ const commonConfig = applyConfig(config => ({ ...config, entry: { 'botframework-webchat-core': './src/index.ts', + 'botframework-webchat-core.activity': './src/activity/index.ts', 'botframework-webchat-core.graph': './src/graph/index.ts', 'botframework-webchat-core.internal': './src/internal/index.ts' } diff --git a/scripts/printCommitStats.mjs b/scripts/printCommitStats.mjs index e0ad6db069..6c39664690 100644 --- a/scripts/printCommitStats.mjs +++ b/scripts/printCommitStats.mjs @@ -14,15 +14,19 @@ function getCategory( ) { return minimatch(path, '*/packages/test/**') ? 'test' - : minimatch(path, '*/packages/**/src/**/*') - ? 'production' - : minimatch(path, '**/*.md') - ? 'doc' - : minimatch(path, '*/__tests__/**/*') - ? 'test' - : minimatch(path, '**/package-lock.json') - ? 'generated' - : 'others'; + : minimatch(path, '**/*.spec.*') + ? 'test' + : minimatch(path, '**/*.test.*') + ? 'test' + : minimatch(path, '*/packages/**/src/**/*') + ? 'production' + : minimatch(path, '**/*.md') + ? 'doc' + : minimatch(path, '*/__tests__/**/*') + ? 'test' + : minimatch(path, '**/package-lock.json') + ? 'generated' + : 'others'; } function toIntegerOrFixed(