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']
+ }
+]);