diff --git a/__tests__/__image_snapshots__/html/render-activity-performance-js-activity-render-performance-does-not-produce-unnecessary-rerenders-1-snap.png b/__tests__/__image_snapshots__/html/render-activity-performance-js-activity-render-performance-does-not-produce-unnecessary-rerenders-1-snap.png index 5b230b71fd..20423539de 100644 Binary files a/__tests__/__image_snapshots__/html/render-activity-performance-js-activity-render-performance-does-not-produce-unnecessary-rerenders-1-snap.png and b/__tests__/__image_snapshots__/html/render-activity-performance-js-activity-render-performance-does-not-produce-unnecessary-rerenders-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/render-activity-profiling-js-activity-render-performance-render-activity-profiling-1-snap.png b/__tests__/__image_snapshots__/html/render-activity-profiling-js-activity-render-performance-render-activity-profiling-1-snap.png index c7ba750812..e0ff5e19a7 100644 Binary files a/__tests__/__image_snapshots__/html/render-activity-profiling-js-activity-render-performance-render-activity-profiling-1-snap.png and b/__tests__/__image_snapshots__/html/render-activity-profiling-js-activity-render-performance-render-activity-profiling-1-snap.png differ diff --git a/__tests__/html/focusManagement.disableHeroCard.obsolete.html b/__tests__/html/focusManagement.disableHeroCard.obsolete.html index 9f8ec98ede..cd667bd124 100644 --- a/__tests__/html/focusManagement.disableHeroCard.obsolete.html +++ b/__tests__/html/focusManagement.disableHeroCard.obsolete.html @@ -1,4 +1,4 @@ - + @@ -15,7 +15,8 @@ diff --git a/__tests__/html/renderActivity.profiling.html b/__tests__/html/renderActivity.profiling.html index edb4b12a2e..7163153dc6 100644 --- a/__tests__/html/renderActivity.profiling.html +++ b/__tests__/html/renderActivity.profiling.html @@ -1,11 +1,14 @@ - + - + @@ -46,21 +49,19 @@ ReactDOM: { render }, React: { Profiler }, WebChat: { - Components: { - BasicTranscript, - Composer - }, + Components: { BasicTranscript, Composer }, createDirectLine } } = window; const BATCH_SIZE = 20; - const timesActivityRendered = new Map(); + const timesEnhancerCalled = new Map(); + const timesHandlerCalled = new Map(); let activitiesCount = 0; - const commits = new Map() + const commits = new Map(); function handleRender(id, renderPhase, actualDuration, baseDuration, startTime, commitTime) { const commit = commits.get(commitTime) ?? {}; commit.activitiesCount = document.querySelectorAll('.webchat__bubble__content').length; @@ -69,29 +70,45 @@ commit[`${id} ${renderPhase} actual`] += actualDuration; commit[`${id} ${renderPhase} count`] ??= 0; commit[`${id} ${renderPhase} count`] += 1; - commit[`${id} ${renderPhase} avg`] = commit[`${id} ${renderPhase} actual`] / commit[`${id} ${renderPhase} count`] ; + commit[`${id} ${renderPhase} avg`] = + commit[`${id} ${renderPhase} actual`] / commit[`${id} ${renderPhase} count`]; commits.set(commitTime, commit); } function activityRendered() { - return next => (...args) => { - const [{ activity }] = args; - const renderActivity = next(...args) - timesActivityRendered.set(activity.id, (timesActivityRendered.get(activity.id) ?? 0) + 1); - return (...args) => ( - <> - - {renderActivity.call ? renderActivity(...args) : renderActivity} - - Rendered {timesActivityRendered.get(activity.id)} times - - ) - } + return next => + (...args) => { + const [{ activity }] = args; + const renderActivity = next(...args); + + timesEnhancerCalled.set(activity.id, (timesEnhancerCalled.get(activity.id) ?? 0) + 1); + + return (...args) => { + timesHandlerCalled.set(activity.id, (timesHandlerCalled.get(activity.id) ?? 0) + 1); + + return ( + <> + + {renderActivity.call ? renderActivity(...args) : renderActivity} + + + {' '} + Enhancer called {timesEnhancerCalled.get(activity.id)} times and handler called{' '} + {timesHandlerCalled.get(activity.id)} times + + + ); + }; + }; } const createActivity = (timestamp = new Date().toISOString(), nextIndex = activitiesCount++) => ({ - id: `activity-${nextIndex}`, text: `Message ${nextIndex}.`, textFormat: 'plain', type: 'message', timestamp - }) + id: `activity-${nextIndex}`, + text: `Message ${nextIndex}.`, + textFormat: 'plain', + type: 'message', + timestamp + }); async function postMessagesBatch(directLine) { const promises = []; @@ -99,10 +116,7 @@ for (let index = 0; index < BATCH_SIZE; index++) { promises.push( // Plain text message isolate dependencies on Markdown. - directLine.emulateIncomingActivity( - createActivity(timestamp), - { skipWait: true } - ) + directLine.emulateIncomingActivity(createActivity(timestamp), { skipWait: true }) ); } @@ -112,72 +126,92 @@ const testComplete = Promise.withResolvers(); - run(async function () { - const { directLine, store } = testHelpers.createDirectLineEmulator(); - - render( - -

-            
-              
-            
-          
, - document.getElementById('webchat') - ); + run( + async function () { + const { directLine, store } = testHelpers.createDirectLineEmulator(); + + render( + +

+              
+                
+              
+            
, + document.getElementById('webchat') + ); - await pageConditions.uiConnected(); - - // WHEN: Adding 100 activities. - await postMessagesBatch(directLine); - await postMessagesBatch(directLine); - await postMessagesBatch(directLine); - await postMessagesBatch(directLine); - await postMessagesBatch(directLine); - - const data = []; - for (const entry of commits.values()) { - const { - commit, - 'Transcript nested-update actual': transcriptNestedUpdate = 0, - 'Transcript update actual': transcriptUpdate = 0, - 'Activity update actual': activityUpdate = 0, - 'Activity mount actual': acivityMount = 0, - activitiesCount - } = entry; - if (acivityMount || transcriptNestedUpdate || transcriptUpdate || activityUpdate) { - data.push({ index: data.length, commit, activityUpdate, acivityMount, transcriptNestedUpdate, transcriptUpdate, activitiesCount }) + await pageConditions.uiConnected(); + + // WHEN: Adding 100 activities. + await postMessagesBatch(directLine); + await postMessagesBatch(directLine); + await postMessagesBatch(directLine); + await postMessagesBatch(directLine); + await postMessagesBatch(directLine); + + const data = []; + for (const entry of commits.values()) { + const { + commit, + 'Transcript nested-update actual': transcriptNestedUpdate = 0, + 'Transcript update actual': transcriptUpdate = 0, + 'Activity update actual': activityUpdate = 0, + 'Activity mount actual': acivityMount = 0, + activitiesCount + } = entry; + if (acivityMount || transcriptNestedUpdate || transcriptUpdate || activityUpdate) { + data.push({ + index: data.length, + commit, + activityUpdate, + acivityMount, + transcriptNestedUpdate, + transcriptUpdate, + activitiesCount + }); + } } - } - const activityTimes = data - .map(({ acivityMount, activityUpdate, activitiesCount }) => ({time: acivityMount + activityUpdate, count: activitiesCount })) - .filter(({ time }) => time); - const activityTimesSorted = activityTimes.sort(({ time: a }, { time: b }) => a > b ? 1 : a < b ? -1 : 0); - const excludedTimes = [].concat(activityTimesSorted.slice(0, 5), activityTimesSorted.slice(activityTimesSorted.length - 5)); - const activityTimesNorm = activityTimes.filter(v => !excludedTimes.includes(v)); + const activityTimes = data + .map(({ acivityMount, activityUpdate, activitiesCount }) => ({ + time: acivityMount + activityUpdate, + count: activitiesCount + })) + .filter(({ time }) => time); + const activityTimesSorted = activityTimes.sort(({ time: a }, { time: b }) => (a > b ? 1 : a < b ? -1 : 0)); + const excludedTimes = [].concat( + activityTimesSorted.slice(0, 5), + activityTimesSorted.slice(activityTimesSorted.length - 5) + ); + const activityTimesNorm = activityTimes.filter(v => !excludedTimes.includes(v)); - displayResults(activityTimesNorm); + displayResults(activityTimesNorm); - setTimeout(() => testComplete.resolve({ data, activityTimesNorm })); + setTimeout(() => testComplete.resolve({ data, activityTimesNorm })); - await host.snapshot(); - }, { ignoreErrors: true }); + await host.snapshot(); + }, + { ignoreErrors: true } + ); function displayResults(activityTimesNorm, pretty = true) { const prettyBool = condition => { - if (pretty) return condition()[0] ? '✅' : '❌' + if (pretty) return condition()[0] ? '✅' : '❌'; let m = condition.toString().match(/\[(.+),\s*(.*)\]/u); let c = condition(); - return `${c[0]}\n ${m[1]}\n ${m[2]} = ${c[1]}` - } - const covariance = ss.sampleCovariance(activityTimesNorm.map(({ time }) => time), activityTimesNorm.map(({ count }) => count)); + return `${c[0]}\n ${m[1]}\n ${m[2]} = ${c[1]}`; + }; + const covariance = ss.sampleCovariance( + activityTimesNorm.map(({ time }) => time), + activityTimesNorm.map(({ count }) => count) + ); const linearRegression = ss.linearRegression(activityTimesNorm.map(({ time }, i) => [i, time])); const results = document.querySelector('pre'); results.innerText = `Render results: Activities rendered:\t\t\t ${activitiesCount} Render data samples:\t\t\t ${activityTimesNorm.length} -Render time grows slow:\t\t\t ${prettyBool(() => [linearRegression.m < 0.5, linearRegression.m ])} +Render time grows slow:\t\t\t ${prettyBool(() => [linearRegression.m < 0.5, linearRegression.m])} Render time slightly moves with count:\t ${prettyBool(() => [covariance < 50, covariance])} `; expect(linearRegression.m).toBeLessThan(0.5); @@ -198,206 +232,234 @@ const marginBottom = 30; const marginLeft = 30; - const x = d3.scaleLinear() + const x = d3 + .scaleLinear() .domain(d3.extent(data, d => d.index)) .range([marginLeft, width - marginRight]); - const y = d3.scaleLinear() - .domain([0, d3.max(data, d => Math.max(d.activityUpdate, d.transcriptNestedUpdate, d.transcriptUpdate, activitiesCount))]).nice() + const y = d3 + .scaleLinear() + .domain([ + 0, + d3.max(data, d => Math.max(d.activityUpdate, d.transcriptNestedUpdate, d.transcriptUpdate, activitiesCount)) + ]) + .nice() .range([height - marginBottom, marginTop]); // Create the horizontal axis generator, called at startup and when zooming. - const xAxis = (g, x) => g - .call(d3.axisBottom(x).ticks(width / 80).tickSizeOuter(0)) + const xAxis = (g, x) => + g.call( + d3 + .axisBottom(x) + .ticks(width / 80) + .tickSizeOuter(0) + ); // The area generators, called at startup and when zooming. - const areaTNU = (data, x) => d3.area() + const areaTNU = (data, x) => + d3 + .area() + .curve(d3.curveStepAfter) + .x(d => x(d.index)) + .y0(y(0)) + .y1(d => y(d.transcriptNestedUpdate))(data); + + const areaTU = (data, x) => + d3 + .area() .curve(d3.curveStepAfter) .x(d => x(d.index)) .y0(y(0)) - .y1(d => y(d.transcriptNestedUpdate)) - (data); + .y1(d => y(d.transcriptUpdate))(data); - const areaTU = (data, x) => d3.area() + const areaAU = (data, x) => + d3 + .area() .curve(d3.curveStepAfter) .x(d => x(d.index)) .y0(y(0)) - .y1(d => y(d.transcriptUpdate)) - (data); - - const areaAU = (data, x) => d3.area() - .curve(d3.curveStepAfter) - .x(d => x(d.index)) - .y0(y(0)) - .y1(d => y(d.activityUpdate)) - (data); - - const areaAM = (data, x) => d3.area() - .curve(d3.curveStepAfter) - .x(d => x(d.index)) - .y0(y(0)) - .y1(d => y(d.acivityMount + d.activityUpdate)) - (data); - - const areaAC = (data, x) => d3.area() + .y1(d => y(d.activityUpdate))(data); + + const areaAM = (data, x) => + d3 + .area() .curve(d3.curveStepAfter) .x(d => x(d.index)) .y0(y(0)) - .y1(d => y(d.activitiesCount)) - (data); + .y1(d => y(d.acivityMount + d.activityUpdate))(data); + + const areaAC = (data, x) => + d3 + .area() + .curve(d3.curveStepAfter) + .x(d => x(d.index)) + .y0(y(0)) + .y1(d => y(d.activitiesCount))(data); // Create the zoom behavior. - const zoom = d3.zoom() - .scaleExtent([1, 32]) - .extent([[marginLeft, 0], [width - marginRight, height]]) - .translateExtent([[marginLeft, -Infinity], [width - marginRight, Infinity]]) - .on("zoom", zoomed); + const zoom = d3 + .zoom() + .scaleExtent([1, 32]) + .extent([ + [marginLeft, 0], + [width - marginRight, height] + ]) + .translateExtent([ + [marginLeft, -Infinity], + [width - marginRight, Infinity] + ]) + .on('zoom', zoomed); // Create the SVG container. - const svg = d3.create("svg") - .attr("viewBox", [0, 0, width, height]) - .attr("width", width) - .attr("height", height) - .attr("style", "max-width: 100%; height: auto;"); + const svg = d3 + .create('svg') + .attr('viewBox', [0, 0, width, height]) + .attr('width', width) + .attr('height', height) + .attr('style', 'max-width: 100%; height: auto;'); const clip = { id: 'clip-path-1', - toString () { - return `url(#${this.id})` + toString() { + return `url(#${this.id})`; } }; - svg.append("clipPath") - .attr("id", clip.id) - .append("rect") - .attr("x", marginLeft) - .attr("y", marginTop) - .attr("width", width - marginLeft - marginRight) - .attr("height", height - marginTop - marginBottom); + svg + .append('clipPath') + .attr('id', clip.id) + .append('rect') + .attr('x', marginLeft) + .attr('y', marginTop) + .attr('width', width - marginLeft - marginRight) + .attr('height', height - marginTop - marginBottom); // Create area paths. - const pathTNU = svg.append("path") - .attr("clip-path", clip) - .attr("fill", "SteelBlue") - .attr("d", areaTNU(data, x)); - - const pathTU = svg.append("path") - .attr("clip-path", clip) - .attr("fill", "LightSteelBlue") - .attr("d", areaTU(data, x)); - - const pathAM = svg.append("path") - .attr("clip-path", clip) - .attr("fill", "LavenderBlush") - .attr("stroke", "LightPink") - .attr("stroke-width", "1") - .attr("d", areaAM(data, x)); - - const pathAU = svg.append("path") - .attr("clip-path", clip) - .attr("fill", "LightPink") - .attr("d", areaAU(data, x)); - - const pathAC = svg.append("path") - .attr("clip-path", clip) - .attr("stroke", "Coral") - .attr("stroke-width", "2") - .attr("fill", "none") - .attr("d", areaAC(data, x)); + const pathTNU = svg + .append('path') + .attr('clip-path', clip) + .attr('fill', 'SteelBlue') + .attr('d', areaTNU(data, x)); + + const pathTU = svg + .append('path') + .attr('clip-path', clip) + .attr('fill', 'LightSteelBlue') + .attr('d', areaTU(data, x)); + + const pathAM = svg + .append('path') + .attr('clip-path', clip) + .attr('fill', 'LavenderBlush') + .attr('stroke', 'LightPink') + .attr('stroke-width', '1') + .attr('d', areaAM(data, x)); + + const pathAU = svg.append('path').attr('clip-path', clip).attr('fill', 'LightPink').attr('d', areaAU(data, x)); + + const pathAC = svg + .append('path') + .attr('clip-path', clip) + .attr('stroke', 'Coral') + .attr('stroke-width', '2') + .attr('fill', 'none') + .attr('d', areaAC(data, x)); // Append the horizontal axis. - const gx = svg.append("g") - .attr("transform", `translate(0,${height - marginBottom})`) - .call(xAxis, x); + const gx = svg + .append('g') + .attr('transform', `translate(0,${height - marginBottom})`) + .call(xAxis, x); // Append the vertical axis. - svg.append("g") - .attr("transform", `translate(${marginLeft},0)`) - .call(d3.axisLeft(y).ticks(null, "s")) - .call(g => g.select(".domain").remove()) - .call(g => g.select(".tick:last-of-type text").clone() - .attr("x", 3) - .attr("text-anchor", "start") - .attr("font-weight", "bold") - .text("ms / activities")); + svg + .append('g') + .attr('transform', `translate(${marginLeft},0)`) + .call(d3.axisLeft(y).ticks(null, 's')) + .call(g => g.select('.domain').remove()) + .call(g => + g + .select('.tick:last-of-type text') + .clone() + .attr('x', 3) + .attr('text-anchor', 'start') + .attr('font-weight', 'bold') + .text('ms / activities') + ); // When zooming, redraw all areas and the x axis. function zoomed(event) { const xz = event.transform.rescaleX(x); - pathTNU.attr("d", areaTNU(data, xz)); - pathTU.attr("d", areaTU(data, xz)); - pathAU.attr("d", areaAU(data, xz)); - pathAM.attr("d", areaAM(data, xz)); - pathAC.attr("d", areaAC(data, xz)); + pathTNU.attr('d', areaTNU(data, xz)); + pathTU.attr('d', areaTU(data, xz)); + pathAU.attr('d', areaAU(data, xz)); + pathAM.attr('d', areaAM(data, xz)); + pathAC.attr('d', areaAC(data, xz)); gx.call(xAxis, xz); } // Initial zoom. - svg.call(zoom) + svg + .call(zoom) .transition() - .duration(750) - .call(zoom.scaleTo, 4, [x(200), 0]); + .duration(750) + .call(zoom.scaleTo, 4, [x(200), 0]); // Legend - svg.append("circle") - .attr("cx",80) - .attr("cy",50) - .attr("r", 6) - .style("fill", "SteelBlue"); - svg.append("text") - .attr("x", 95) - .attr("y", 52) - .text("Transcript nested-update ms") - .style("font-size", "15px") - .attr("alignment-baseline","middle"); - svg.append("circle") - .attr("cx",80) - .attr("cy",70) - .attr("r", 6) - .style("fill", "LightSteelBlue"); - svg.append("text") - .attr("x", 95) - .attr("y", 72) - .text("Transcript update ms") - .style("font-size", "15px") - .attr("alignment-baseline","middle"); - svg.append("circle") - .attr("cx",80) - .attr("cy",90) - .attr("r", 6) - .style("fill", "LightPink"); - svg.append("text") - .attr("x", 95) - .attr("y", 92) - .text("Activity update ms") - .style("font-size", "15px") - .attr("alignment-baseline","middle"); - svg.append("circle") - .attr("cx",80) - .attr("cy",110) - .attr("r", 6) - .attr("fill", "LavenderBlush") + svg.append('circle').attr('cx', 80).attr('cy', 50).attr('r', 6).style('fill', 'SteelBlue'); + svg + .append('text') + .attr('x', 95) + .attr('y', 52) + .text('Transcript nested-update ms') + .style('font-size', '15px') + .attr('alignment-baseline', 'middle'); + svg.append('circle').attr('cx', 80).attr('cy', 70).attr('r', 6).style('fill', 'LightSteelBlue'); + svg + .append('text') + .attr('x', 95) + .attr('y', 72) + .text('Transcript update ms') + .style('font-size', '15px') + .attr('alignment-baseline', 'middle'); + svg.append('circle').attr('cx', 80).attr('cy', 90).attr('r', 6).style('fill', 'LightPink'); + svg + .append('text') + .attr('x', 95) + .attr('y', 92) + .text('Activity update ms') + .style('font-size', '15px') + .attr('alignment-baseline', 'middle'); + svg + .append('circle') + .attr('cx', 80) + .attr('cy', 110) + .attr('r', 6) + .attr('fill', 'LavenderBlush') .attr('strokeWidth', '1') .style('stroke', 'LightPink'); - svg.append("text") - .attr("x", 95) - .attr("y", 112) - .text("Activity mount ms") - .style("font-size", "15px") - .attr("alignment-baseline","middle"); - svg.append("circle") - .attr("cx",80) - .attr("cy",130) - .attr("r", 6) - .attr("fill", "none") + svg + .append('text') + .attr('x', 95) + .attr('y', 112) + .text('Activity mount ms') + .style('font-size', '15px') + .attr('alignment-baseline', 'middle'); + svg + .append('circle') + .attr('cx', 80) + .attr('cy', 130) + .attr('r', 6) + .attr('fill', 'none') .attr('strokeWidth', '2') .style('stroke', 'Coral'); - svg.append("text") - .attr("x", 95) - .attr("y", 132) - .text("Activities shown count") - .style("font-size", "15px") - .attr("alignment-baseline","middle"); + svg + .append('text') + .attr('x', 95) + .attr('y', 132) + .text('Activities shown count') + .style('font-size', '15px') + .attr('alignment-baseline', 'middle'); document.body.appendChild(svg.node()); } diff --git a/__tests__/html/timestamp.changeGrouping.html b/__tests__/html/timestamp.changeGrouping.html index 50884db3ef..b3bf54f2cd 100644 --- a/__tests__/html/timestamp.changeGrouping.html +++ b/__tests__/html/timestamp.changeGrouping.html @@ -1,4 +1,4 @@ - + @@ -35,18 +35,30 @@ await pageConditions.minNumActivitiesShown(3); // THEN: There should be 3 activities in total: 1 from user, followed by 2 from the bot. + expect( + pageElements.activityStatuses().map(element => element.querySelector('[aria-hidden="true"]')?.textContent) + ).toEqual(['Just now', undefined, 'Just now']); + await host.snapshot(); // WHEN: `styleOptions.groupTimestamp` is set to 0, which means, every activity is on its own group. renderWebChat({ styleOptions: { groupTimestamp: 0 } }); // THEN: Every messages should have timestamp on its own. + expect( + pageElements.activityStatuses().map(element => element.querySelector('[aria-hidden="true"]')?.textContent) + ).toEqual(['Just now', 'Just now', 'Just now']); + await host.snapshot(); // WHEN: `styleOptions.groupTimestamp` is set to false, which means, timestamp is disabled. renderWebChat({ styleOptions: { groupTimestamp: false } }); // THEN: No messages should have timestamp on it. + expect( + pageElements.activityStatuses().map(element => element.querySelector('[aria-hidden="true"]')?.textContent) + ).toEqual([undefined, undefined, undefined]); + await host.snapshot(); }); diff --git a/__tests__/html2/middleware/activity/legacy.html b/__tests__/html2/middleware/activity/legacy.html new file mode 100644 index 0000000000..ee53158935 --- /dev/null +++ b/__tests__/html2/middleware/activity/legacy.html @@ -0,0 +1,89 @@ + + + + + + + + + + + + +
+ + + diff --git a/package-lock.json b/package-lock.json index 340db42b43..aa6c55aa49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "./packages/test/web-server", "./packages/core", "./packages/react-valibot", + "./packages/middleware", "./packages/redux-store", "./packages/styles", "./packages/support/cldr-data-downloader", @@ -3548,6 +3549,10 @@ "resolved": "packages/support/cldr-data-downloader", "link": true }, + "node_modules/@msinternal/botframework-webchat-middleware": { + "resolved": "packages/middleware", + "link": true + }, "node_modules/@msinternal/botframework-webchat-react-valibot": { "resolved": "packages/react-valibot", "link": true @@ -10340,6 +10345,14 @@ "dev": true, "license": "MIT" }, + "node_modules/handler-chain": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/handler-chain/-/handler-chain-0.1.0.tgz", + "integrity": "sha512-Bp0Imbm0pmt3FUNDK1y4Fj31K/tOGfVHKNZGprcuedHrcoRNwC7EYtJwJfdl1x9q8Lcs21sK43hbB8V01f+grA==", + "dependencies": { + "handler-chain": "^0.1.0" + } + }, "node_modules/happy-dom": { "version": "18.0.1", "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-18.0.1.tgz", @@ -19716,6 +19729,19 @@ "react": ">=16.8.0" } }, + "node_modules/use-reduce-memo": { + "version": "0.1.0-main.04cde62", + "resolved": "https://registry.npmjs.org/use-reduce-memo/-/use-reduce-memo-0.1.0-main.04cde62.tgz", + "integrity": "sha512-5oMAOROpBSAxWMFPmc0DRCTg8DtzfmYbBHRUcKpbgtdLznn5wfA9zt0jkPpf+S1JiSB2DFS8whxZaLsIsh6ywg==", + "dependencies": { + "handler-chain": "^0.1.0", + "use-reduce-memo": "^0.1.0-main.04cde62", + "valibot": "^1.1.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/use-ref-from": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/use-ref-from/-/use-ref-from-0.1.0.tgz", @@ -21138,7 +21164,7 @@ "merge-refs": "2.0.0", "prop-types": "15.8.1", "punycode": "2.3.1", - "react-chain-of-responsibility": "0.3.0", + "react-chain-of-responsibility": "^0.4.0-main.5f5cc2f", "react-dictate-button": "4.0.0", "react-film": "4.0.0", "react-redux": "7.2.9", @@ -21148,6 +21174,7 @@ "redux": "5.0.1", "simple-update-in": "2.2.0", "use-propagate": "0.2.1", + "use-reduce-memo": "^0.1.0-main.1384637", "use-ref-from": "0.1.0", "use-state-with-ref": "0.1.0", "valibot": "1.1.0" @@ -21387,6 +21414,19 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "packages/component/node_modules/react-chain-of-responsibility": { + "version": "0.4.0-main.5f5cc2f", + "resolved": "https://registry.npmjs.org/react-chain-of-responsibility/-/react-chain-of-responsibility-0.4.0-main.5f5cc2f.tgz", + "integrity": "sha512-dlzrVfYvvnU9vRulu8KhAd3WBmAbQxWW4w8DiE6IFdu9mCCeeVIvyunDK6dndMqukNOmc3Jgqndb4rPQiBfkGw==", + "dependencies": { + "handler-chain": "^0.1.0", + "react-chain-of-responsibility": "^0.4.0-main.5f5cc2f", + "valibot": "^1.1.0" + }, + "peerDependencies": { + "react": ">=16.8.6" + } + }, "packages/component/node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -21839,6 +21879,87 @@ "webpack-cli": "^6.0.1" } }, + "packages/middleware": { + "name": "@msinternal/botframework-webchat-middleware", + "version": "0.0.0-0", + "license": "MIT", + "dependencies": { + "handler-chain": "^0.1.0", + "react-chain-of-responsibility": "0.4.0-main.a361688", + "valibot": "1.1.0" + }, + "devDependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@msinternal/botframework-webchat-base": "0.0.0-0", + "@msinternal/botframework-webchat-react-valibot": "^0.0.0-0", + "@tsconfig/strictest": "^2.0.5", + "@types/node": "^22.13.4", + "cross-env": "^7.0.3", + "type-fest": "^4.34.1", + "typescript": "^5.7.3" + }, + "peerDependencies": { + "react": ">= 16.8.6" + } + }, + "packages/middleware/node_modules/@types/node": { + "version": "22.17.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.1.tgz", + "integrity": "sha512-y3tBaz+rjspDTylNjAX37jEC3TETEFGNJL6uQDxwF9/8GLLIjW1rvVHlynyuUKMnMr1Roq8jOv3vkopBjC4/VA==", + "dev": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "packages/middleware/node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "packages/middleware/node_modules/react-chain-of-responsibility": { + "version": "0.4.0-main.a361688", + "resolved": "https://registry.npmjs.org/react-chain-of-responsibility/-/react-chain-of-responsibility-0.4.0-main.a361688.tgz", + "integrity": "sha512-QeswyJRmYJnTPTBNgG5MORm2y5gVx4vhK0jdh88GvGP0XMwTx2V2MyzYKNL9tzahHBHo4DVHRYwt4vGf4Tohfw==", + "dependencies": { + "handler-chain": "^0.1.0", + "react-chain-of-responsibility": "^0.4.0-main.a361688", + "valibot": "^1.1.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "packages/middleware/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/middleware/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + }, "packages/react-types": { "name": "@msinternal/botframework-webchat-react-types", "version": "0.0.0-0", diff --git a/package.json b/package.json index 63507046d7..962b36629a 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "./packages/test/web-server", "./packages/core", "./packages/react-valibot", + "./packages/middleware", "./packages/redux-store", "./packages/styles", "./packages/support/cldr-data-downloader", @@ -86,6 +87,7 @@ "start:core": "cd packages && cd core && npm start", "start:directlinespeech": "cd packages && cd directlinespeech && npm start", "start:fluent-theme": "cd packages && cd fluent-theme && npm start", + "start:middleware": "cd packages && cd middleware && npm start", "start:react-valibot": "cd packages && cd react-valibot && npm start", "start:redux-store": "cd packages && cd redux-store && npm start", "start:server": "serve -p 5000", diff --git a/packages/api/src/hooks/Composer.tsx b/packages/api/src/hooks/Composer.tsx index 39d235f6d1..e92081ed3a 100644 --- a/packages/api/src/hooks/Composer.tsx +++ b/packages/api/src/hooks/Composer.tsx @@ -1,3 +1,4 @@ +import { type ActivityMiddleware, type AttachmentMiddleware } from '@msinternal/botframework-webchat-middleware/legacy'; import { ReduxStoreComposer } from '@msinternal/botframework-webchat-redux-store'; import { clearSuggestedActions, @@ -54,10 +55,8 @@ import ActivitySendStatusComposer from '../providers/ActivitySendStatus/Activity import ActivitySendStatusTelemetryComposer from '../providers/ActivitySendStatusTelemetry/ActivitySendStatusTelemetryComposer'; import ActivityTypingComposer from '../providers/ActivityTyping/ActivityTypingComposer'; import PonyfillComposer from '../providers/Ponyfill/PonyfillComposer'; -import ActivityMiddleware from '../types/ActivityMiddleware'; import { type ActivityStatusMiddleware, type RenderActivityStatus } from '../types/ActivityStatusMiddleware'; import AttachmentForScreenReaderMiddleware from '../types/AttachmentForScreenReaderMiddleware'; -import AttachmentMiddleware from '../types/AttachmentMiddleware'; import AvatarMiddleware from '../types/AvatarMiddleware'; import CardActionMiddleware from '../types/CardActionMiddleware'; import { type ContextOf } from '../types/ContextOf'; diff --git a/packages/api/src/hooks/internal/WebChatAPIContext.ts b/packages/api/src/hooks/internal/WebChatAPIContext.ts index 0396a90010..d412b5568e 100644 --- a/packages/api/src/hooks/internal/WebChatAPIContext.ts +++ b/packages/api/src/hooks/internal/WebChatAPIContext.ts @@ -1,3 +1,4 @@ +import { type LegacyActivityRenderer, type RenderAttachment } from '@msinternal/botframework-webchat-middleware/legacy'; import { type DirectLineJSBotConnection, type Observable, @@ -9,10 +10,8 @@ import { import { createContext, type ComponentType } from 'react'; import { StrictStyleOptions } from '../../StyleOptions'; -import { LegacyActivityRenderer } from '../../types/ActivityMiddleware'; import { RenderActivityStatus } from '../../types/ActivityStatusMiddleware'; import { AttachmentForScreenReaderComponentFactory } from '../../types/AttachmentForScreenReaderMiddleware'; -import { RenderAttachment } from '../../types/AttachmentMiddleware'; import { AvatarComponentFactory } from '../../types/AvatarMiddleware'; import { PerformCardAction } from '../../types/CardActionMiddleware'; import { GroupActivities } from '../../types/GroupActivitiesMiddleware'; diff --git a/packages/api/src/hooks/internal/useCreateActivityRendererInternal.ts b/packages/api/src/hooks/internal/useCreateActivityRendererInternal.ts index ec03d9c6d5..d38b5f364c 100644 --- a/packages/api/src/hooks/internal/useCreateActivityRendererInternal.ts +++ b/packages/api/src/hooks/internal/useCreateActivityRendererInternal.ts @@ -1,7 +1,9 @@ +import { + type ActivityComponentFactory, + type RenderAttachment +} from '@msinternal/botframework-webchat-middleware/legacy'; import { isValidElement, useMemo } from 'react'; -import { ActivityComponentFactory } from '../../types/ActivityMiddleware'; -import { RenderAttachment } from '../../types/AttachmentMiddleware'; import useRenderAttachment from '../useRenderAttachment'; import useWebChatAPIContext from './useWebChatAPIContext'; diff --git a/packages/api/src/hooks/useCreateActivityRenderer.ts b/packages/api/src/hooks/useCreateActivityRenderer.ts index 55fb552223..a1506c4a72 100644 --- a/packages/api/src/hooks/useCreateActivityRenderer.ts +++ b/packages/api/src/hooks/useCreateActivityRenderer.ts @@ -1,4 +1,4 @@ -import { ActivityComponentFactory } from '../types/ActivityMiddleware'; +import { type ActivityComponentFactory } from '@msinternal/botframework-webchat-middleware/legacy'; import useCreateActivityRendererInternal from './internal/useCreateActivityRendererInternal'; // The newer useCreateActivityRenderer() hook does not support override renderAttachment(). diff --git a/packages/api/src/hooks/useRenderAttachment.ts b/packages/api/src/hooks/useRenderAttachment.ts index 5add4176fd..7dce6464d0 100644 --- a/packages/api/src/hooks/useRenderAttachment.ts +++ b/packages/api/src/hooks/useRenderAttachment.ts @@ -1,4 +1,4 @@ -import { type RenderAttachment } from '../types/AttachmentMiddleware'; +import { type RenderAttachment } from '@msinternal/botframework-webchat-middleware/legacy'; import useWebChatAPIContext from './internal/useWebChatAPIContext'; export default function useRenderAttachment(): RenderAttachment | undefined { diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 0f34bae547..01668ead74 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,4 +1,10 @@ // TODO: Move the pattern to re-export. +import { + type ActivityComponentFactory, + type ActivityMiddleware, + type AttachmentMiddleware, + type RenderAttachment +} from '@msinternal/botframework-webchat-middleware/legacy'; import StyleOptions, { StrictStyleOptions } from './StyleOptions'; import defaultStyleOptions from './defaultStyleOptions'; import Composer, { ComposerProps } from './hooks/Composer'; @@ -9,12 +15,10 @@ import { type DebouncedNotification, type DebouncedNotifications } from './hooks import { type PostActivityFile } from './hooks/useSendFiles'; import { localize } from './localization/Localize'; import normalizeStyleOptions from './normalizeStyleOptions'; -import ActivityMiddleware, { type ActivityComponentFactory } from './types/ActivityMiddleware'; import { type ActivityStatusMiddleware, type RenderActivityStatus } from './types/ActivityStatusMiddleware'; import AttachmentForScreenReaderMiddleware, { AttachmentForScreenReaderComponentFactory } from './types/AttachmentForScreenReaderMiddleware'; -import AttachmentMiddleware, { type RenderAttachment } from './types/AttachmentMiddleware'; import AvatarMiddleware, { type AvatarComponentFactory } from './types/AvatarMiddleware'; import CardActionMiddleware, { type PerformCardAction } from './types/CardActionMiddleware'; import { type ContextOf } from './types/ContextOf'; diff --git a/packages/api/src/middleware/private/templateMiddleware.ts b/packages/api/src/middleware/private/templateMiddleware.tsx similarity index 83% rename from packages/api/src/middleware/private/templateMiddleware.ts rename to packages/api/src/middleware/private/templateMiddleware.tsx index 1bc97235ea..426e4be5ed 100644 --- a/packages/api/src/middleware/private/templateMiddleware.ts +++ b/packages/api/src/middleware/private/templateMiddleware.tsx @@ -1,4 +1,5 @@ import { warnOnce } from 'botframework-webchat-core'; +import React, { memo, type ReactNode } from 'react'; import { createChainOfResponsibility, type ComponentMiddleware } from 'react-chain-of-responsibility'; import { array, function_, safeParse, type InferOutput } from 'valibot'; @@ -58,15 +59,30 @@ function templateMiddleware(name: string) { return EMPTY_ARRAY; }; - const { Provider, Proxy } = createChainOfResponsibility(); + const { Provider, Proxy } = createChainOfResponsibility(); + + type TemplatedProviderProps = { + readonly children?: ReactNode | undefined; + readonly middleware: readonly Middleware[]; + }; + + // eslint-disable-next-line prefer-arrow-callback + const TemplatedProvider = memo(function TemplatedProvider({ children, middleware }: TemplatedProviderProps) { + return ( + + {children} + + ); + }); + + TemplatedProvider.displayName = `${name}Provider`; - Provider.displayName = `${name}Provider`; Proxy.displayName = `${name}Proxy`; return { createMiddleware, extractMiddleware, - Provider, + Provider: TemplatedProvider, Proxy, '~types': undefined as { middleware: Middleware; diff --git a/packages/api/src/providers/GroupActivities/GroupActivitiesComposer.tsx b/packages/api/src/providers/GroupActivities/GroupActivitiesComposer.tsx index 16d843eb46..e90bed6b71 100644 --- a/packages/api/src/providers/GroupActivities/GroupActivitiesComposer.tsx +++ b/packages/api/src/providers/GroupActivities/GroupActivitiesComposer.tsx @@ -64,14 +64,15 @@ function GroupActivitiesComposer({ children, groupActivitiesMiddleware }: GroupA [groupActivitiesBy, runMiddleware] ); - const groupActivitiesByGroupRef = useRefFrom(groupActivitiesByGroup); const groupActivitiesByRef = useRefFrom(groupActivitiesBy); + // When `groupActivitiesMiddleware` or `styleOptions.groupActivities` changed, the callback should be invalidated. + // The invalidation should cause downstreamers to re-render. const groupActivitiesByName = useCallback< (activities: readonly WebChatActivity[], groupingName: string) => readonly (readonly WebChatActivity[])[] >( (activities, groupingName) => { - const group = groupActivitiesByGroupRef.current.get(groupingName); + const group = groupActivitiesByGroup.get(groupingName); if (group) { const result: ReadonlyMap = new Map( @@ -97,7 +98,7 @@ function GroupActivitiesComposer({ children, groupActivitiesMiddleware }: GroupA return Object.freeze(activities.map(activity => Object.freeze([activity]))); }, - [groupActivitiesByGroupRef, groupActivitiesByRef] + [groupActivitiesByGroup, groupActivitiesByRef] ); const context = useMemo( diff --git a/packages/component/package.json b/packages/component/package.json index 223b6b9219..e5cb19c96b 100644 --- a/packages/component/package.json +++ b/packages/component/package.json @@ -135,7 +135,7 @@ "merge-refs": "2.0.0", "prop-types": "15.8.1", "punycode": "2.3.1", - "react-chain-of-responsibility": "0.3.0", + "react-chain-of-responsibility": "^0.4.0-main.5f5cc2f", "react-dictate-button": "4.0.0", "react-film": "4.0.0", "react-redux": "7.2.9", @@ -145,6 +145,7 @@ "redux": "5.0.1", "simple-update-in": "2.2.0", "use-propagate": "0.2.1", + "use-reduce-memo": "^0.1.0-main.1384637", "use-ref-from": "0.1.0", "use-state-with-ref": "0.1.0", "valibot": "1.1.0" diff --git a/packages/component/src/Composer.tsx b/packages/component/src/Composer.tsx index 67ef2973e6..7f18a9d9a4 100644 --- a/packages/component/src/Composer.tsx +++ b/packages/component/src/Composer.tsx @@ -1,3 +1,8 @@ +import { + createActivityPolyMiddlewareFromLegacy, + PolyMiddlewareComposer, + type PolyMiddleware +} from '@msinternal/botframework-webchat-middleware'; import type { ComposerProps as APIComposerProps, SendBoxMiddleware, @@ -33,6 +38,7 @@ import WebChatUIContext from './hooks/internal/WebChatUIContext'; import { FocusSendBoxScope } from './hooks/sendBoxFocus'; import { ScrollRelativeTranscriptScope } from './hooks/transcriptScrollRelative'; import createDefaultActivityMiddleware from './Middleware/Activity/createCoreMiddleware'; +import LegacyActivityBridge from './Middleware/Activity/private/LegacyActivityBridge'; import createDefaultActivityStatusMiddleware from './Middleware/ActivityStatus/createCoreMiddleware'; import createDefaultAttachmentForScreenReaderMiddleware from './Middleware/AttachmentForScreenReader/createCoreMiddleware'; import createDefaultAvatarMiddleware from './Middleware/Avatar/createCoreMiddleware'; @@ -441,6 +447,15 @@ const InternalComposer = ({ [sendBoxToolbarMiddlewareFromProps, theme.sendBoxToolbarMiddleware] ); + const polyMiddlewareArray = useMemo( + () => + Object.freeze([ + // TODO: Add . + createActivityPolyMiddlewareFromLegacy(LegacyActivityBridge, () => null, ...patchedActivityMiddleware) + ]), + [patchedActivityMiddleware] + ); + return ( - - - - {children} - {onTelemetry && } - - - + + + + + {children} + {onTelemetry && } + + + + diff --git a/packages/component/src/Transcript/TranscriptActivity.tsx b/packages/component/src/Middleware/Activity/private/LegacyActivityBridge.tsx similarity index 69% rename from packages/component/src/Transcript/TranscriptActivity.tsx rename to packages/component/src/Middleware/Activity/private/LegacyActivityBridge.tsx index fec94f1286..c154791036 100644 --- a/packages/component/src/Transcript/TranscriptActivity.tsx +++ b/packages/component/src/Middleware/Activity/private/LegacyActivityBridge.tsx @@ -1,23 +1,27 @@ -import { hooks, type ActivityComponentFactory } from 'botframework-webchat-api'; -import { type WebChatActivity } from 'botframework-webchat-core'; +import { bridgeComponentPropsSchema, type BridgeComponentProps } from '@msinternal/botframework-webchat-middleware'; +import { validateProps } from '@msinternal/botframework-webchat-react-valibot'; +import { hooks } from 'botframework-webchat-api'; import React, { memo, useCallback, useMemo } from 'react'; -import useFirstActivityInSenderGroup from '../Middleware/ActivityGrouping/ui/SenderGrouping/useFirstActivity'; -import useLastActivityInSenderGroup from '../Middleware/ActivityGrouping/ui/SenderGrouping/useLastActivity'; -import useFirstActivityInStatusGroup from '../Middleware/ActivityGrouping/ui/StatusGrouping/useFirstActivity'; -import useLastActivityInStatusGroup from '../Middleware/ActivityGrouping/ui/StatusGrouping/useLastActivity'; -import useActivityElementMapRef from '../providers/ChatHistoryDOM/useActivityElementRef'; -import isZeroOrPositive from '../Utils/isZeroOrPositive'; -import ActivityRow from './ActivityRow'; +import useActivityElementMapRef from '../../../providers/ChatHistoryDOM/useActivityElementRef'; +import ActivityRow from '../../../Transcript/ActivityRow'; +import isZeroOrPositive from '../../../Utils/isZeroOrPositive'; +import useFirstActivityInSenderGroup from '../../ActivityGrouping/ui/SenderGrouping/useFirstActivity'; +import useLastActivityInSenderGroup from '../../ActivityGrouping/ui/SenderGrouping/useLastActivity'; +import useFirstActivityInStatusGroup from '../../ActivityGrouping/ui/StatusGrouping/useFirstActivity'; +import useLastActivityInStatusGroup from '../../ActivityGrouping/ui/StatusGrouping/useLastActivity'; -const { useCreateActivityStatusRenderer, useCreateAvatarRenderer, useGetKeyByActivity, useStyleOptions } = hooks; +const { + useCreateActivityStatusRenderer, + useCreateAvatarRenderer, + useGetKeyByActivity, + useRenderAttachment, + useStyleOptions +} = hooks; -type TranscriptActivityProps = Readonly<{ - activity: WebChatActivity; - renderActivity: Exclude, false>; -}>; +function LegacyActivityBridge(props: BridgeComponentProps) { + const { activity, render } = validateProps(bridgeComponentPropsSchema, props); -const TranscriptActivity = ({ activity, renderActivity }: TranscriptActivityProps) => { const [{ bubbleFromUserNubOffset, bubbleNubOffset, groupTimestamp, showAvatarInGroup }] = useStyleOptions(); const [firstActivityInSenderGroup] = useFirstActivityInSenderGroup(); const [firstActivityInStatusGroup] = useFirstActivityInStatusGroup(); @@ -26,9 +30,9 @@ const TranscriptActivity = ({ activity, renderActivity }: TranscriptActivityProp const activityElementMapRef = useActivityElementMapRef(); const createActivityStatusRenderer = useCreateActivityStatusRenderer(); const getKeyByActivity = useGetKeyByActivity(); + const renderAttachment = useRenderAttachment(); const renderAvatar = useCreateAvatarRenderer(); - const activityKey: string = useMemo(() => getKeyByActivity(activity), [activity, getKeyByActivity]); const hideAllTimestamps = groupTimestamp === false; const isFirstInSenderGroup = firstActivityInSenderGroup === activity || typeof firstActivityInSenderGroup === 'undefined'; @@ -53,6 +57,7 @@ const TranscriptActivity = ({ activity, renderActivity }: TranscriptActivityProp [activity, createActivityStatusRenderer] ); + const activityKey: string = useMemo(() => getKeyByActivity(activity), [activity, getKeyByActivity]); const activityCallbackRef = useCallback( (activityElement: HTMLElement) => { activityElement @@ -85,13 +90,13 @@ const TranscriptActivity = ({ activity, renderActivity }: TranscriptActivityProp const children = useMemo( () => - renderActivity({ + render(renderAttachment, { hideTimestamp, renderActivityStatus, renderAvatar: renderAvatarForSenderGroup, showCallout }), - [hideTimestamp, renderActivity, renderActivityStatus, renderAvatarForSenderGroup, showCallout] + [hideTimestamp, render, renderActivityStatus, renderAttachment, renderAvatarForSenderGroup, showCallout] ); return ( @@ -99,7 +104,6 @@ const TranscriptActivity = ({ activity, renderActivity }: TranscriptActivityProp {children} ); -}; +} -export default memo(TranscriptActivity); -export { type TranscriptActivityProps }; +export default memo(LegacyActivityBridge); diff --git a/packages/component/src/Middleware/ActivityGrouping/ui/RenderActivityGrouping.tsx b/packages/component/src/Middleware/ActivityGrouping/ui/RenderActivityGrouping.tsx index 26337165c6..bd2c88db83 100644 --- a/packages/component/src/Middleware/ActivityGrouping/ui/RenderActivityGrouping.tsx +++ b/packages/component/src/Middleware/ActivityGrouping/ui/RenderActivityGrouping.tsx @@ -1,28 +1,35 @@ +import { validateProps } from '@msinternal/botframework-webchat-react-valibot'; import { hooks } from 'botframework-webchat-api'; import { type WebChatActivity } from 'botframework-webchat-core'; import React, { Fragment, memo } from 'react'; -import useGetRenderActivityCallback from '../../../providers/RenderingActivities/useGetRenderActivityCallback'; -import TranscriptActivity from '../../../Transcript/TranscriptActivity'; +import { array, custom, object, pipe, readonly, safeParse, type InferInput } from 'valibot'; + +import useActivityRendererMap from '../../../providers/RenderingActivities/useActivityRendererMap'; const { useGetKeyByActivity } = hooks; -type RenderActivityGroupingProps = Readonly<{ - activities: readonly WebChatActivity[]; -}>; +const renderActivityGroupingPropsSchema = pipe( + object({ + activities: pipe(array(custom(value => safeParse(object({}), value).success)), readonly()) + }), + readonly() +); + +type RenderActivityGroupingProps = Readonly>; -const RenderActivityGrouping = ({ activities }: RenderActivityGroupingProps) => { +const RenderActivityGrouping = (props: RenderActivityGroupingProps) => { + const { activities } = validateProps(renderActivityGroupingPropsSchema, props); + + const [activityRendererMap] = useActivityRendererMap(); const getKeyByActivity = useGetKeyByActivity(); - const getRenderActivityCallback = useGetRenderActivityCallback(); return ( - {activities.map(activity => ( - - ))} + {activities.map(activity => { + const children = activityRendererMap.get(activity)?.({}); + + return children && {children}; + })} ); }; @@ -30,4 +37,4 @@ const RenderActivityGrouping = ({ activities }: RenderActivityGroupingProps) => RenderActivityGrouping.displayName = 'RenderActivityGrouping'; export default memo(RenderActivityGrouping); -export { type RenderActivityGroupingProps }; +export { renderActivityGroupingPropsSchema, type RenderActivityGroupingProps }; diff --git a/packages/component/src/providers/RenderingActivities/RenderingActivitiesComposer.tsx b/packages/component/src/providers/RenderingActivities/RenderingActivitiesComposer.tsx index 4f0f077fd6..c66a705fc9 100644 --- a/packages/component/src/providers/RenderingActivities/RenderingActivitiesComposer.tsx +++ b/packages/component/src/providers/RenderingActivities/RenderingActivitiesComposer.tsx @@ -1,20 +1,23 @@ -import { hooks, type ActivityComponentFactory } from 'botframework-webchat-api'; +import { + useBuildRenderActivityCallback, + type ActivityPolyMiddlewareRenderer +} from '@msinternal/botframework-webchat-middleware'; +import { hooks } from 'botframework-webchat-api'; import { type WebChatActivity } from 'botframework-webchat-core'; -import React, { memo, useMemo, type ReactNode } from 'react'; +import React, { memo, useCallback, useMemo, type ReactNode } from 'react'; +import { useReduceMemo } from 'use-reduce-memo'; import RenderingActivitiesContext, { type RenderingActivitiesContextType } from './private/RenderingActivitiesContext'; -import useInternalActivitiesWithRenderer from './private/useInternalActivitiesWithRenderer'; type RenderingActivitiesComposerProps = Readonly<{ children?: ReactNode | undefined; }>; -const { useActivities, useActivityKeys, useCreateActivityRenderer, useGetActivitiesByKey, useGetKeyByActivity } = hooks; +const { useActivities, useActivityKeys, useGetActivitiesByKey, useGetKeyByActivity } = hooks; const RenderingActivitiesComposer = ({ children }: RenderingActivitiesComposerProps) => { const [activities] = useActivities(); const activityKeys = useActivityKeys(); - const createActivityRenderer = useCreateActivityRenderer(); const getActivitiesByKey = useGetActivitiesByKey(); const getKeyByActivity = useGetKeyByActivity(); @@ -41,38 +44,53 @@ const RenderingActivitiesComposer = ({ children }: RenderingActivitiesComposerPr return Object.freeze(activitiesOfLatestRevision); }, [activityKeys, getActivitiesByKey, getKeyByActivity, activities]); - const activitiesWithRenderer = useInternalActivitiesWithRenderer(activitiesOfLatestRevision, createActivityRenderer); + const renderActivity = useBuildRenderActivityCallback(); + + const activityRendererMap = useReduceMemo( + activitiesOfLatestRevision, + useCallback< + ( + activityRendererMap: ReadonlyMap, + activity: WebChatActivity + ) => ReadonlyMap + >( + (activityRendererMap, activity) => { + const renderer = renderActivity({ activity }); + + return renderer ? Object.freeze(new Map(activityRendererMap).set(activity, renderer)) : activityRendererMap; + }, + [renderActivity] + ), + new Map() + ); - const renderingActivitiesState = useMemo( - () => Object.freeze([activitiesWithRenderer.map(({ activity }) => activity)] as const), - [activitiesWithRenderer] + const renderingActivitiesState = useMemo( + () => Object.freeze([Object.freeze(Array.from(activityRendererMap.keys()))]), + [activityRendererMap] ); const renderingActivityKeysState = useMemo(() => { const keys = Object.freeze(renderingActivitiesState[0].map(activity => getKeyByActivity(activity))); if (keys.some(key => !key)) { - throw new Error('botframework-webchat internal: activitiesWithRenderer[].activity must have activity key'); + throw new Error('botframework-webchat internal: activityRendererMap[].activity must have activity key'); } return Object.freeze([keys] as const); - }, [renderingActivitiesState, getKeyByActivity]); - - const renderActivityCallbackMap = useMemo< - ReadonlyMap, false>> - >( - () => - Object.freeze(new Map(activitiesWithRenderer.map(({ activity, renderActivity }) => [activity, renderActivity]))), - [activitiesWithRenderer] + }, [getKeyByActivity, renderingActivitiesState]); + + const activityRendererMapState = useMemo]>( + () => Object.freeze([activityRendererMap]), + [activityRendererMap] ); const contextValue: RenderingActivitiesContextType = useMemo( () => ({ - renderActivityCallbackMap, + activityRendererMapState, renderingActivitiesState, renderingActivityKeysState }), - [renderActivityCallbackMap, renderingActivitiesState, renderingActivityKeysState] + [activityRendererMapState, renderingActivitiesState, renderingActivityKeysState] ); return {children}; diff --git a/packages/component/src/providers/RenderingActivities/private/RenderingActivitiesContext.ts b/packages/component/src/providers/RenderingActivities/private/RenderingActivitiesContext.ts index 86371ab958..938eecbfd1 100644 --- a/packages/component/src/providers/RenderingActivities/private/RenderingActivitiesContext.ts +++ b/packages/component/src/providers/RenderingActivities/private/RenderingActivitiesContext.ts @@ -1,9 +1,9 @@ -import { type ActivityComponentFactory } from 'botframework-webchat-api'; +import { type ActivityPolyMiddlewareRenderer } from '@msinternal/botframework-webchat-middleware'; import { type WebChatActivity } from 'botframework-webchat-core'; import createContextAndHook from '../../createContextAndHook'; type RenderingActivitiesContextType = Readonly<{ - renderActivityCallbackMap: ReadonlyMap, false>>; + activityRendererMapState: readonly [ReadonlyMap]; renderingActivitiesState: readonly [readonly WebChatActivity[]]; renderingActivityKeysState: readonly [readonly string[]]; }>; diff --git a/packages/component/src/providers/RenderingActivities/private/useInternalActivitiesWithRenderer.ts b/packages/component/src/providers/RenderingActivities/private/useInternalActivitiesWithRenderer.ts deleted file mode 100644 index 069d398eb4..0000000000 --- a/packages/component/src/providers/RenderingActivities/private/useInternalActivitiesWithRenderer.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { type ActivityComponentFactory } from 'botframework-webchat-api'; -import { type WebChatActivity } from 'botframework-webchat-core'; -import { useMemo } from 'react'; - -import useMemoWithPrevious from '../../../hooks/internal/useMemoWithPrevious'; - -type ActivityWithRenderer = Readonly<{ - activity: WebChatActivity; - renderActivity: Exclude, false>; -}>; - -type Call = Readonly<{ - activity: WebChatActivity; - nextVisibleActivity: WebChatActivity; - renderActivity: Exclude, false>; -}>; - -type Run = Readonly<{ - createActivityRenderer: ActivityComponentFactory; - calls: readonly Call[]; -}>; - -export default function useInternalActivitiesWithRenderer( - activities: readonly WebChatActivity[], - createActivityRenderer: ActivityComponentFactory -): readonly ActivityWithRenderer[] { - const run = useMemoWithPrevious( - prevRun => { - if (prevRun && !Object.is(prevRun.createActivityRenderer, createActivityRenderer)) { - // If `createActivityRenderer` changed, invalidate the cache. - prevRun = undefined; - } - - const calls: Call[] = []; - let nextVisibleActivity: undefined | WebChatActivity; - - for (let index = activities.length - 1; index >= 0; index--) { - const activity = activities[+index]; - - const prevEntry = prevRun?.calls.find( - entry => Object.is(activity, entry.activity) && Object.is(nextVisibleActivity, entry.nextVisibleActivity) - ); - - if (prevEntry) { - calls.unshift(prevEntry); - - nextVisibleActivity = activity; - } else { - const renderActivity = createActivityRenderer({ activity, nextVisibleActivity }); - - if (renderActivity) { - calls.unshift( - Object.freeze({ - activity, - nextVisibleActivity, - renderActivity - }) - ); - - nextVisibleActivity = activity; - } - } - } - - return Object.freeze({ createActivityRenderer, calls: Object.freeze(calls) }); - }, - [activities, createActivityRenderer] - ); - - return useMemo( - () => - run.calls.map(call => ({ - activity: call.activity, - renderActivity: call.renderActivity - })), - [run] - ); -} diff --git a/packages/component/src/providers/RenderingActivities/useActivityRendererMap.ts b/packages/component/src/providers/RenderingActivities/useActivityRendererMap.ts new file mode 100644 index 0000000000..f17f801fb9 --- /dev/null +++ b/packages/component/src/providers/RenderingActivities/useActivityRendererMap.ts @@ -0,0 +1,9 @@ +import { type ActivityPolyMiddlewareRenderer } from '@msinternal/botframework-webchat-middleware'; +import { type WebChatActivity } from 'botframework-webchat-core'; +import { useRenderingActivitiesContext } from './private/RenderingActivitiesContext'; + +export default function useActivityRendererMap(): readonly [ + ReadonlyMap +] { + return useRenderingActivitiesContext().activityRendererMapState; +} diff --git a/packages/component/src/providers/RenderingActivities/useGetRenderActivityCallback.ts b/packages/component/src/providers/RenderingActivities/useGetRenderActivityCallback.ts deleted file mode 100644 index 9b19da7118..0000000000 --- a/packages/component/src/providers/RenderingActivities/useGetRenderActivityCallback.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { type ActivityComponentFactory } from 'botframework-webchat-api'; -import { type WebChatActivity } from 'botframework-webchat-core'; - -import { useRenderingActivitiesContext } from './private/RenderingActivitiesContext'; - -export default function useGetRenderActivityCallback() { - const { renderActivityCallbackMap } = useRenderingActivitiesContext(); - - return (activity: WebChatActivity): Exclude, false> | undefined => - renderActivityCallbackMap.get(activity); -} diff --git a/packages/middleware/.eslintrc.yml b/packages/middleware/.eslintrc.yml new file mode 100644 index 0000000000..84aac24130 --- /dev/null +++ b/packages/middleware/.eslintrc.yml @@ -0,0 +1,10 @@ +extends: + - ../../.eslintrc.production.yml + +# This package is compatible with web browser. +env: + browser: true + +rules: + # React functional component is better in function style than arrow style + prefer-arrow-callback: off diff --git a/packages/middleware/.gitignore b/packages/middleware/.gitignore new file mode 100644 index 0000000000..62b899b6ae --- /dev/null +++ b/packages/middleware/.gitignore @@ -0,0 +1,4 @@ +/*.tgz +/dist/ +/lib/ +/node_modules/ diff --git a/packages/middleware/README.md b/packages/middleware/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/middleware/legacy.js b/packages/middleware/legacy.js new file mode 100644 index 0000000000..59ad0bafbf --- /dev/null +++ b/packages/middleware/legacy.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-middleware.legacy.js'); diff --git a/packages/middleware/package.json b/packages/middleware/package.json new file mode 100644 index 0000000000..72f0266dd5 --- /dev/null +++ b/packages/middleware/package.json @@ -0,0 +1,83 @@ +{ + "name": "@msinternal/botframework-webchat-middleware", + "version": "0.0.0-0", + "description": "The botframework-webchat middleware package", + "main": "./dist/botframework-webchat-middleware.js", + "types": "./dist/botframework-webchat-middleware.d.ts", + "exports": { + "./legacy": { + "import": { + "types": "./dist/botframework-webchat-middleware.legacy.d.mts", + "default": "./dist/botframework-webchat-middleware.legacy.mjs" + }, + "require": { + "types": "./dist/botframework-webchat-middleware.legacy.d.ts", + "default": "./dist/botframework-webchat-middleware.legacy.js" + } + }, + ".": { + "import": { + "types": "./dist/botframework-webchat-middleware.d.mts", + "default": "./dist/botframework-webchat-middleware.mjs" + }, + "require": { + "types": "./dist/botframework-webchat-middleware.d.ts", + "default": "./dist/botframework-webchat-middleware.js" + } + }, + "./tsconfig.json": "./src/ts-config/config.json", + "./env": { + "types": "./src/env.d.ts" + } + }, + "author": "Microsoft Corporation", + "license": "MIT", + "private": true, + "repository": { + "type": "git", + "url": "git+https://github.com/microsoft/BotFramework-WebChat.git" + }, + "bugs": { + "url": "https://github.com/microsoft/BotFramework-WebChat/issues" + }, + "files": [ + "./dist/**/*", + "./src/**/*", + "*.js" + ], + "localDependencies": { + "botframework-webchat-base": "development" + }, + "homepage": "https://github.com/microsoft/BotFramework-WebChat/tree/main/packages/middleware#readme", + "scripts": { + "build": "tsup --config ./tsup.config.ts", + "bump": "npm run bump:prod && npm run bump:dev && (npm audit fix || exit 0)", + "bump:dev": "PACKAGES_TO_BUMP=$(cat package.json | jq -r '(.pinDependencies // {}) as $P | (.localDependencies // {} | keys) as $L | (.devDependencies // {}) | to_entries | map(select(.key as $K | $L | contains([$K]) | not)) | map(.key + \"@\" + ($P[.key] // [\"latest\"])[0]) | join(\" \")') && [ ! -z \"$PACKAGES_TO_BUMP\" ] && npm install $PACKAGES_TO_BUMP || true", + "bump:prod": "PACKAGES_TO_BUMP=$(cat package.json | jq -r '(.pinDependencies // {}) as $P | (.localDependencies // {} | keys) as $L | (.dependencies // {}) | to_entries | map(select(.key as $K | $L | contains([$K]) | not)) | map(.key + \"@\" + ($P[.key] // [\"latest\"])[0]) | join(\" \")') && [ ! -z \"$PACKAGES_TO_BUMP\" ] && npm install --save-exact $PACKAGES_TO_BUMP || true", + "eslint": "npm run precommit", + "postversion": "cat package.json | jq '.version as $V | (.localDependencies // {} | with_entries(select(.value == \"production\") | { key: .key, value: $V })) as $L1 | (.localDependencies // {} | with_entries(select(.value == \"development\") | { key: .key, value: $V })) as $L2 | ((.dependencies // {}) + $L1 | to_entries | sort_by(.key) | from_entries) as $D1 | ((.devDependencies // {}) + $L2 | to_entries | sort_by(.key) | from_entries) as $D2 | . + { dependencies: $D1, devDependencies: $D2 }' > package-temp.json && mv package-temp.json package.json", + "precommit": "npm run precommit:eslint -- src && npm run precommit:typecheck", + "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": "cat package.json | jq '(.localDependencies // {} | to_entries | map([if .value == \"production\" then \"dependencies\" else \"devDependencies\" end, .key])) as $P | delpaths($P)' > package-temp.json && mv package-temp.json package.json", + "start": "npm run build -- --onSuccess=\"touch ../api/src/index.ts ../component/src/index.ts\" --watch" + }, + "devDependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@tsconfig/strictest": "^2.0.5", + "@msinternal/botframework-webchat-base": "0.0.0-0", + "@msinternal/botframework-webchat-react-valibot": "^0.0.0-0", + "@types/node": "^22.13.4", + "cross-env": "^7.0.3", + "type-fest": "^4.34.1", + "typescript": "^5.7.3" + }, + "peerDependencies": { + "react": ">= 16.8.6" + }, + "dependencies": { + "handler-chain": "^0.1.0", + "react-chain-of-responsibility": "0.4.0-main.a361688", + "valibot": "1.1.0" + } +} diff --git a/packages/middleware/src/PolyMiddlewareComposer.tsx b/packages/middleware/src/PolyMiddlewareComposer.tsx new file mode 100644 index 0000000000..0e9bd187c5 --- /dev/null +++ b/packages/middleware/src/PolyMiddlewareComposer.tsx @@ -0,0 +1,43 @@ +import { reactNode, validateProps } from '@msinternal/botframework-webchat-react-valibot'; +import React, { memo, useMemo } from 'react'; +import { + array, + custom, + function_, + object, + optional, + pipe, + readonly, + safeParse, + transform, + type InferInput +} from 'valibot'; + +import { ActivityPolyMiddlewareProvider, extractActivityPolyMiddleware } from './activityPolyMiddleware'; +import { PolyMiddleware } from './types/PolyMiddleware'; + +const polyMiddlewareComposerPropsSchema = pipe( + object({ + children: optional(reactNode()), + middleware: pipe( + custom(value => safeParse(array(function_()), value).success), + transform(value => Object.freeze(Array.from(value))) + ) + }), + readonly() +); + +type PolyMiddlewareComposerProps = Readonly>; + +function PolyMiddlewareComposer(props: PolyMiddlewareComposerProps) { + const { children, middleware } = validateProps(polyMiddlewareComposerPropsSchema, props); + + const activityPolyMiddleware = useMemo(() => extractActivityPolyMiddleware(middleware), [middleware]); + + return ( + {children} + ); +} + +export default memo(PolyMiddlewareComposer); +export { polyMiddlewareComposerPropsSchema, type PolyMiddlewareComposerProps }; diff --git a/packages/middleware/src/activityPolyMiddleware.tsx b/packages/middleware/src/activityPolyMiddleware.tsx new file mode 100644 index 0000000000..e562829ada --- /dev/null +++ b/packages/middleware/src/activityPolyMiddleware.tsx @@ -0,0 +1,66 @@ +import { validateProps } from '@msinternal/botframework-webchat-react-valibot'; +import { type WebChatActivity } from 'botframework-webchat-core'; +import React, { memo, useMemo } from 'react'; +import { custom, object, pipe, readonly, safeParse, type InferInput } from 'valibot'; + +import templateMiddleware, { + type InferHandler, + type InferHandlerResult, + type InferMiddleware, + type InferProps, + type InferProviderProps, + type InferRenderer, + type InferRequest +} from './private/templateMiddleware'; + +const { + createMiddleware: createActivityPolyMiddleware, + extractMiddleware: extractActivityPolyMiddleware, + Provider: ActivityPolyMiddlewareProvider, + Proxy, + reactComponent: activityComponent, + useBuildRenderCallback: useBuildRenderActivityCallback +} = templateMiddleware<{ readonly activity: WebChatActivity }, { readonly children?: never }>('activity'); + +type ActivityPolyMiddleware = InferMiddleware; +type ActivityPolyMiddlewareHandler = InferHandler; +type ActivityPolyMiddlewareHandlerResult = InferHandlerResult; +type ActivityPolyMiddlewareProps = InferProps; +type ActivityPolyMiddlewareRenderer = InferRenderer; +type ActivityPolyMiddlewareRequest = InferRequest; +type ActivityPolyMiddlewareProviderProps = InferProviderProps; + +const activityPolyMiddlewareProxyPropsSchema = pipe( + object({ + activity: custom>(value => safeParse(object({}), value).success) + }), + readonly() +); + +type ActivityPolyMiddlewareProxyProps = Readonly>; + +// A friendlier version than the organic . +const ActivityPolyMiddlewareProxy = memo(function ActivityPolyMiddlewareProxy(props: ActivityPolyMiddlewareProxyProps) { + const { activity } = validateProps(activityPolyMiddlewareProxyPropsSchema, props); + + const request = useMemo(() => ({ activity }), [activity]); + + return ; +}); + +export { + activityComponent, + ActivityPolyMiddlewareProvider, + ActivityPolyMiddlewareProxy, + createActivityPolyMiddleware, + extractActivityPolyMiddleware, + useBuildRenderActivityCallback, + type ActivityPolyMiddleware, + type ActivityPolyMiddlewareHandler, + type ActivityPolyMiddlewareHandlerResult, + type ActivityPolyMiddlewareProps, + type ActivityPolyMiddlewareProviderProps, + type ActivityPolyMiddlewareProxyProps, + type ActivityPolyMiddlewareRenderer, + type ActivityPolyMiddlewareRequest +}; diff --git a/packages/middleware/src/index.ts b/packages/middleware/src/index.ts new file mode 100644 index 0000000000..48dea7a4d7 --- /dev/null +++ b/packages/middleware/src/index.ts @@ -0,0 +1,27 @@ +export { + activityComponent, + ActivityPolyMiddlewareProvider, + ActivityPolyMiddlewareProxy, + createActivityPolyMiddleware, + extractActivityPolyMiddleware, + useBuildRenderActivityCallback, + type ActivityPolyMiddleware, + type ActivityPolyMiddlewareHandler, + type ActivityPolyMiddlewareHandlerResult, + type ActivityPolyMiddlewareProps, + type ActivityPolyMiddlewareProviderProps, + type ActivityPolyMiddlewareProxyProps, + type ActivityPolyMiddlewareRenderer, + type ActivityPolyMiddlewareRequest +} from './activityPolyMiddleware'; + +export { + bridgeComponentPropsSchema, + default as createActivityPolyMiddlewareFromLegacy, + fallbackComponentPropsSchema, + type BridgeComponentProps, + type FallbackComponentProps +} from './internal/createActivityPolyMiddlewareFromLegacy'; + +export { default as PolyMiddlewareComposer } from './PolyMiddlewareComposer'; +export { type Init, type PolyMiddleware } from './types/PolyMiddleware'; diff --git a/packages/middleware/src/internal/createActivityPolyMiddlewareFromLegacy.tsx b/packages/middleware/src/internal/createActivityPolyMiddlewareFromLegacy.tsx new file mode 100644 index 0000000000..4c3e0647e2 --- /dev/null +++ b/packages/middleware/src/internal/createActivityPolyMiddlewareFromLegacy.tsx @@ -0,0 +1,84 @@ +import { type WebChatActivity } from 'botframework-webchat-core'; +import { composeEnhancer } from 'handler-chain'; +import React, { type ComponentType, type ReactNode } from 'react'; +import type ActivityMiddleware from '../legacy/activityMiddleware'; +import { type RenderAttachment } from '../legacy/attachmentMiddleware'; + +import { custom, function_, never, object, optional, pipe, readonly, safeParse, type InferInput } from 'valibot'; +import { + activityComponent, + createActivityPolyMiddleware, + type ActivityPolyMiddleware +} from '../activityPolyMiddleware'; + +const webChatActivitySchema = custom(value => safeParse(object({}), value).success); + +type LegacyRenderFunction = ( + renderAttachment: RenderAttachment, + options: { + readonly hideTimestamp: boolean; + readonly renderActivityStatus: (options: { hideTimestamp: boolean }) => ReactNode; + readonly renderAvatar: false | (() => Exclude); + readonly showCallout: boolean; + } +) => Exclude; + +const bridgeComponentPropsSchema = pipe( + object({ + activity: webChatActivitySchema, + children: optional(never()), + render: custom(value => safeParse(function_(), value).success) + }), + readonly() +); + +type BridgeComponentProps = Readonly & { children?: never }>; + +const fallbackComponentPropsSchema = pipe( + object({ + activity: webChatActivitySchema, + children: optional(never()) + }), + readonly() +); + +type FallbackComponentProps = Readonly & { children?: never }>; + +function createActivityPolyMiddlewareFromLegacy( + bridgeComponent: ComponentType, + // Use lowercase for argument name, but we need uppercase for JSX. + fallbackComponent: ComponentType, + ...middleware: readonly ActivityMiddleware[] +): ActivityPolyMiddleware; + +function createActivityPolyMiddlewareFromLegacy( + bridgeComponent: ComponentType, + FallbackComponent: ComponentType, + ...middleware: readonly ActivityMiddleware[] +): ActivityPolyMiddleware { + const legacyEnhancer = composeEnhancer(...middleware.map(middleware => middleware())); + + return createActivityPolyMiddleware(() => { + const legacyHandler = legacyEnhancer(request => () => ); + + return ({ activity }) => { + // TODO: `nextVisibleActivity` is deprecated and should be removed. + const legacyResult = legacyHandler({ activity, nextVisibleActivity: undefined as any }); + + if (!legacyResult) { + return undefined; + } + + return activityComponent(bridgeComponent, { activity, render: legacyResult }); + }; + }); +} + +export default createActivityPolyMiddlewareFromLegacy; + +export { + bridgeComponentPropsSchema, + fallbackComponentPropsSchema, + type BridgeComponentProps, + type FallbackComponentProps +}; diff --git a/packages/middleware/src/legacy.ts b/packages/middleware/src/legacy.ts new file mode 100644 index 0000000000..3ea5528472 --- /dev/null +++ b/packages/middleware/src/legacy.ts @@ -0,0 +1,6 @@ +export { + type ActivityComponentFactory, + type default as ActivityMiddleware, + type LegacyActivityRenderer +} from './legacy/activityMiddleware'; +export { type default as AttachmentMiddleware, type RenderAttachment } from './legacy/attachmentMiddleware'; diff --git a/packages/api/src/types/ActivityMiddleware.ts b/packages/middleware/src/legacy/activityMiddleware.ts similarity index 85% rename from packages/api/src/types/ActivityMiddleware.ts rename to packages/middleware/src/legacy/activityMiddleware.ts index 341ca3285a..ef4025bbb1 100644 --- a/packages/api/src/types/ActivityMiddleware.ts +++ b/packages/middleware/src/legacy/activityMiddleware.ts @@ -1,6 +1,8 @@ -import type { ReactNode } from 'react'; -import type { RenderAttachment } from './AttachmentMiddleware'; -import type { WebChatActivity } from 'botframework-webchat-core'; +// TODO: This is moved from /api, need to revisit/rewrite everything in this file. +import { type WebChatActivity } from 'botframework-webchat-core'; +import { type ReactNode } from 'react'; + +import { type RenderAttachment } from './attachmentMiddleware'; type ActivityProps = { hideTimestamp: boolean; diff --git a/packages/api/src/types/AttachmentMiddleware.ts b/packages/middleware/src/legacy/attachmentMiddleware.ts similarity index 78% rename from packages/api/src/types/AttachmentMiddleware.ts rename to packages/middleware/src/legacy/attachmentMiddleware.ts index a3fc769cd2..a0fc48e8aa 100644 --- a/packages/api/src/types/AttachmentMiddleware.ts +++ b/packages/middleware/src/legacy/attachmentMiddleware.ts @@ -1,4 +1,5 @@ -import { ReactNode } from 'react'; +// TODO: This is moved from /api, need to revisit/rewrite everything in this file. +import { type ReactNode } from 'react'; import type { DirectLineAttachment, WebChatActivity } from 'botframework-webchat-core'; type AttachmentProps = { diff --git a/packages/middleware/src/private/templateMiddleware.check.test.tsx b/packages/middleware/src/private/templateMiddleware.check.test.tsx new file mode 100644 index 0000000000..36075a5aa7 --- /dev/null +++ b/packages/middleware/src/private/templateMiddleware.check.test.tsx @@ -0,0 +1,47 @@ +import templateMiddleware from './templateMiddleware'; + +test('should warn if middleware is not an array of function', () => { + const warn = jest.fn(); + const template = templateMiddleware('Check' as any); + + jest.spyOn(console, 'warn').mockImplementation(warn); + + template.extractMiddleware(1 as any); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenNthCalledWith(1, expect.stringContaining('must be an array of function')); +}); + +test('should warn if middleware did not return function', () => { + const warn = jest.fn(); + const template = templateMiddleware('Check' as any); + + jest.spyOn(console, 'warn').mockImplementation(warn); + + template.extractMiddleware([() => 1 as any]); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenNthCalledWith(1, expect.stringContaining('must return enhancer function')); +}); + +test('should not warn if middleware return false', () => { + const warn = jest.fn(); + const template = templateMiddleware('Check' as any); + + jest.spyOn(console, 'warn').mockImplementation(warn); + + template.extractMiddleware([() => false as any]); + + expect(warn).toHaveBeenCalledTimes(0); +}); + +test('should not warn if middleware return function', () => { + const warn = jest.fn(); + const template = templateMiddleware('Check' as any); + + jest.spyOn(console, 'warn').mockImplementation(warn); + + template.extractMiddleware([() => () => 1 as any]); + + expect(warn).toHaveBeenCalledTimes(0); +}); diff --git a/packages/middleware/src/private/templateMiddleware.test.tsx b/packages/middleware/src/private/templateMiddleware.test.tsx new file mode 100644 index 0000000000..4e1c20f88a --- /dev/null +++ b/packages/middleware/src/private/templateMiddleware.test.tsx @@ -0,0 +1,98 @@ +/** @jest-environment @happy-dom/jest-environment */ + +import { render } from '@testing-library/react'; +import React, { type ReactNode } from 'react'; + +import templateMiddleware, { type InferMiddleware } from './templateMiddleware'; + +type ButtonProps = Readonly<{ children?: ReactNode | undefined }>; +type LinkProps = Readonly<{ children?: ReactNode | undefined; href: string }>; + +const ButtonImpl = ({ children }: ButtonProps) => ; + +const ExternalLinkImpl = ({ children, href }: LinkProps) => ( + + {children} + +); + +const InternalLinkImpl = ({ children, href }: LinkProps) => {children}; + +// User story for using templateMiddleware as a building block for uber middleware. +test('an uber middleware', () => { + const buttonTemplate = templateMiddleware('Button' as any); + const { + createMiddleware: createButtonMiddleware, + extractMiddleware: extractButtonMiddleware, + Provider: ButtonProvider, + Proxy: Button, + reactComponent: buttonComponent + } = buttonTemplate; + + type ButtonMiddleware = InferMiddleware; + + const linkTemplate = templateMiddleware<{ external: boolean }, LinkProps>('Link' as any); + const { + createMiddleware: createLinkMiddleware, + extractMiddleware: extractLinkMiddleware, + Provider: LinkProvider, + Proxy: Link, + reactComponent: linkComponent + } = linkTemplate; + + type LinkMiddleware = InferMiddleware; + + const buttonMiddleware: ButtonMiddleware[] = [createButtonMiddleware(() => () => buttonComponent(ButtonImpl))]; + const linkMiddleware: LinkMiddleware[] = [ + createLinkMiddleware(next => request => (request.external ? linkComponent(ExternalLinkImpl) : next(request))), + createLinkMiddleware(() => () => linkComponent(InternalLinkImpl)) + ]; + + const App = ({ + children, + middleware + }: Readonly<{ + children?: ReactNode | undefined; + middleware: ReadonlyArray; + }>) => ( + /* TODO: Should not cast middleware to any */ + + {/* TODO: Should not cast middleware to any */} + {children} + + ); + + const result = render( + + {/* TODO: If "request" is of type "void", we should not need it specified. */} + + + {'Internal link'} + + + {'External link'} + + + ); + + expect(result.container).toMatchInlineSnapshot(` + +`); +}); diff --git a/packages/middleware/src/private/templateMiddleware.tsx b/packages/middleware/src/private/templateMiddleware.tsx new file mode 100644 index 0000000000..23ae88d503 --- /dev/null +++ b/packages/middleware/src/private/templateMiddleware.tsx @@ -0,0 +1,144 @@ +import { warnOnce } from 'botframework-webchat-core'; +import { type Enhancer } from 'handler-chain'; +import React, { memo, type ReactNode } from 'react'; +import { + createChainOfResponsibility, + type ComponentEnhancer, + type ComponentHandler, + type ComponentHandlerResult, + type ComponentRenderer, + type InferMiddleware as InferOrganicMiddleware, + type ProviderProps, + type ProxyProps +} from 'react-chain-of-responsibility/preview'; +import { array, function_, safeParse, type InferOutput } from 'valibot'; + +const arrayOfFunctionSchema = array(function_()); +// TODO: Move marker inside templateMiddleware. Think if every type of middleware should have their own marker and not crossed. +const middlewareFactoryMarker = Symbol(); + +const isArrayOfFunction = (middleware: unknown): middleware is InferOutput => + safeParse(arrayOfFunctionSchema, middleware).success; + +const BYPASS_ENHANCER: Enhancer = next => request => next(request); +const EMPTY_ARRAY = Object.freeze([]); + +// Following @types/react to use {} for props. +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +function templateMiddleware(name: string) { + const { Provider, Proxy, reactComponent, useBuildRenderCallback } = createChainOfResponsibility< + Request, + Props, + string + >(); + + type TemplatedEnhancer = ReturnType>; + type TemplatedMiddleware = (init: string) => TemplatedEnhancer; + + const createMiddleware = (enhancer: TemplatedEnhancer): TemplatedMiddleware => { + const factory: TemplatedMiddleware = init => (init === name ? enhancer : BYPASS_ENHANCER); + + // This is for checking if the middleware is created via factory function or not. + // We enforce middleware to be created using factory function. + + // TODO: Consider using valibot for validation, plus using "Symbol in object" check. + (factory as any)[middlewareFactoryMarker satisfies symbol] = middlewareFactoryMarker; + + return factory; + }; + + const warnInvalidExtraction = warnOnce(`Middleware passed for extraction of "${name}" must be an array of function`); + + const extractMiddleware = ( + middleware: readonly TemplatedMiddleware[] | undefined + ): readonly TemplatedMiddleware[] => { + if (middleware) { + if (isArrayOfFunction(middleware)) { + return Object.freeze( + middleware + .map(middleware => { + // TODO: Validate every middleware is created through `createMiddleware()`. + const result = middleware(name); + + if (typeof result !== 'function' && result) { + console.warn(`botframework-webchat: ${name}.middleware must return enhancer function or false`); + + return false; + } + + return result; + }) + .filter((enhancer): enhancer is ReturnType => !!enhancer) + .map(enhancer => () => enhancer) + ); + } + + warnInvalidExtraction(); + } + + return EMPTY_ARRAY; + }; + + // Bind "init" props. + const TemplatedProvider = memo(function TemplatedProvider({ + children, + middleware + }: { + children?: ReactNode | undefined; + middleware: readonly TemplatedMiddleware[]; + }) { + return ( + + {children} + + ); + }); + + TemplatedProvider.displayName = `${name}Provider`; + + Proxy.displayName = `${name}Proxy`; + + return { + createMiddleware, + extractMiddleware, + Provider: TemplatedProvider as typeof TemplatedProvider & InferenceHelper, + Proxy, + reactComponent, + useBuildRenderCallback + }; +} + +type InferenceHelper = { + '~types': { + handler: ComponentHandler; + handlerResult: ComponentHandlerResult; + middleware: (init: string) => ComponentEnhancer; + props: Props; + providerProps: ProviderProps; + proxyProps: ProxyProps; + renderer: ComponentRenderer; + request: Request; + }; +}; + +type InferHandler> = T['~types']['handler']; +type InferHandlerResult> = T['~types']['handlerResult']; +type InferMiddleware> = T['~types']['middleware']; +type InferProps> = T['~types']['props']; +type InferProviderProps> = T['~types']['providerProps']; +type InferProxyProps> = T['~types']['proxyProps']; +type InferRenderer> = T['~types']['renderer']; +type InferRequest> = T['~types']['request']; + +export default templateMiddleware; +export { + middlewareFactoryMarker, + type InferHandler, + type InferHandlerResult, + type InferMiddleware, + type InferProps, + type InferProviderProps, + type InferProxyProps, + type InferRenderer, + type InferRequest +}; diff --git a/packages/middleware/src/tsconfig.json b/packages/middleware/src/tsconfig.json new file mode 100644 index 0000000000..5677d4e342 --- /dev/null +++ b/packages/middleware/src/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "jsx": "react", + "moduleResolution": "Bundler", + "noEmit": true, + "skipLibCheck": true, + "target": "ESNext" + }, + "extends": "@tsconfig/strictest" +} diff --git a/packages/middleware/src/types/LegacyComponentMiddleware.ts b/packages/middleware/src/types/LegacyComponentMiddleware.ts new file mode 100644 index 0000000000..8f34358c6f --- /dev/null +++ b/packages/middleware/src/types/LegacyComponentMiddleware.ts @@ -0,0 +1,8 @@ +import { type Enhancer, type Handler, type Middleware } from 'handler-chain'; +import { type ComponentType } from 'react'; + +type LegacyComponentHandler = Handler, Request>; +type LegacyComponentEnhancer = Enhancer, Request>; +type LegacyComponentMiddleware = Middleware, Request, Init>; + +export { type LegacyComponentEnhancer, type LegacyComponentHandler, type LegacyComponentMiddleware }; diff --git a/packages/middleware/src/types/PolyMiddleware.ts b/packages/middleware/src/types/PolyMiddleware.ts new file mode 100644 index 0000000000..17244a515e --- /dev/null +++ b/packages/middleware/src/types/PolyMiddleware.ts @@ -0,0 +1,10 @@ +import { type ActivityPolyMiddleware } from '../activityPolyMiddleware'; + +export type PolyMiddleware = ActivityPolyMiddleware; + +export type Init = + | 'activity' + | 'activity border' + | 'activity grouping' + | 'sendBoxMiddleware' + | 'sendBoxToolbarMiddleware'; diff --git a/packages/middleware/tsup.config.ts b/packages/middleware/tsup.config.ts new file mode 100644 index 0000000000..b2526537a8 --- /dev/null +++ b/packages/middleware/tsup.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'tsup'; +import baseConfig from '../../tsup.base.config'; + +const config: typeof baseConfig = { + ...baseConfig, + entry: { + 'botframework-webchat-middleware': './src/index.ts', + 'botframework-webchat-middleware.legacy': './src/legacy.ts' + } +}; + +export default defineConfig([ + { + ...config, + format: 'esm' + }, + { + ...config, + format: 'cjs', + target: [...config.target, 'es2019'] + } +]);