Skip to content

Commit 36ce124

Browse files
committed
Supports typing from multiple livestreams
1 parent 44f8882 commit 36ce124

File tree

5 files changed

+142
-21
lines changed

5 files changed

+142
-21
lines changed

__tests__/html/hooks.useActiveTyping.livestream.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@
7474
{
7575
bot: {
7676
at: 600,
77-
expireAt: 5600,
77+
expireAt: Infinity,
7878
name: 'Bot',
7979
role: 'bot',
8080
type: 'livestream'
14.3 KB
Loading

package-lock.json

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@
131131
"dependencies": {
132132
"botframework-webchat-core": "0.0.0-0",
133133
"globalize": "1.7.0",
134+
"iter-fest": "^0.2.1",
134135
"math-random": "2.0.1",
135136
"prop-types": "15.8.1",
136137
"react-chain-of-responsibility": "0.2.0",

packages/api/src/providers/ActivityTyping/ActivityTypingComposer.tsx

Lines changed: 127 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getActivityLivestreamingMetadata, type WebChatActivity } from 'botframework-webchat-core';
2+
import { iteratorFind } from 'iter-fest';
23
import React, { memo, useCallback, useMemo, type ReactNode } from 'react';
34

45
import numberWithInfinity from '../../hooks/private/numberWithInfinity';
@@ -7,49 +8,155 @@ import ActivityTypingContext, { ActivityTypingContextType } from './private/Cont
78
import useReduceActivities from './private/useReduceActivities';
89
import { type AllTyping } from './types/AllTyping';
910

11+
type Entry = {
12+
livestreamActivities: Map<
13+
string,
14+
{
15+
activity: WebChatActivity;
16+
contentful: boolean;
17+
firstReceivedAt: number;
18+
lastReceivedAt: number;
19+
}
20+
>;
21+
name: string | undefined;
22+
role: 'bot' | 'user';
23+
typingIndicator:
24+
| {
25+
activity: WebChatActivity;
26+
duration: number;
27+
firstReceivedAt: number;
28+
lastReceivedAt: number;
29+
}
30+
| undefined;
31+
};
32+
1033
type Props = Readonly<{ children?: ReactNode | undefined }>;
1134

1235
const ActivityTypingComposer = ({ children }: Props) => {
1336
const [{ Date }] = usePonyfill();
1437

1538
const reducer = useCallback(
1639
(
17-
prevTypingState: ReadonlyMap<string, AllTyping> | undefined,
40+
prevTypingState: ReadonlyMap<string, Readonly<Entry>> | undefined,
1841
activity: WebChatActivity
19-
): ReadonlyMap<string, AllTyping> | undefined => {
42+
): ReadonlyMap<string, Readonly<Entry>> | undefined => {
2043
const {
21-
from,
22-
from: { id, role },
44+
from: { id, name, role },
2345
type
2446
} = activity;
2547

48+
if (role === 'channel') {
49+
return prevTypingState;
50+
}
51+
52+
// A normal message activity, or final activity (which could be "message" or "typing"), will remove the typing indicator.
53+
const receivedAt = activity.channelData.webChat.receivedAt || Date.now();
54+
2655
const livestreamingMetadata = getActivityLivestreamingMetadata(activity);
2756
const typingState = new Map(prevTypingState);
57+
const existingEntry = typingState.get(id);
58+
const mutableEntry: Entry = {
59+
typingIndicator: undefined,
60+
...existingEntry,
61+
livestreamActivities: new Map(existingEntry?.livestreamActivities),
62+
name,
63+
role
64+
};
65+
66+
if (livestreamingMetadata) {
67+
mutableEntry.typingIndicator = undefined;
68+
69+
const { sessionId } = livestreamingMetadata;
2870

29-
if (type === 'message' || livestreamingMetadata?.type === 'final activity') {
30-
// A normal message activity, or final activity (which could be "message" or "typing"), will remove the typing indicator.
31-
typingState.delete(id);
32-
} else if (type === 'typing' && (role === 'bot' || role === 'user')) {
33-
const currentTyping = typingState.get(id);
34-
// TODO: When we rework on types of DLActivity, we will make sure all activities has "webChat.receivedAt", this coalesces can be removed.
35-
const receivedAt = activity.channelData.webChat?.receivedAt || Date.now();
36-
37-
typingState.set(id, {
38-
firstReceivedAt: currentTyping?.firstReceivedAt || receivedAt,
39-
lastActivityDuration: numberWithInfinity(activity.channelData.webChat?.styleOptions?.typingAnimationDuration),
40-
lastReceivedAt: receivedAt,
41-
name: from.name,
42-
role,
43-
type: livestreamingMetadata && livestreamingMetadata.type !== 'indicator only' ? 'livestream' : 'busy'
71+
if (livestreamingMetadata.type === 'final activity') {
72+
mutableEntry.livestreamActivities.delete(sessionId);
73+
} else {
74+
mutableEntry.livestreamActivities.set(
75+
sessionId,
76+
Object.freeze({
77+
firstReceivedAt: Date.now(),
78+
...mutableEntry.livestreamActivities.get(sessionId),
79+
activity,
80+
contentful: livestreamingMetadata.type !== 'indicator only',
81+
lastReceivedAt: receivedAt
82+
})
83+
);
84+
}
85+
} else if (type === 'message') {
86+
mutableEntry.typingIndicator = undefined;
87+
} else if (type === 'typing') {
88+
mutableEntry.typingIndicator = Object.freeze({
89+
activity,
90+
duration: numberWithInfinity(activity.channelData.webChat?.styleOptions?.typingAnimationDuration),
91+
firstReceivedAt: mutableEntry.typingIndicator?.firstReceivedAt || Date.now(),
92+
lastReceivedAt: receivedAt
4493
});
4594
}
4695

96+
typingState.set(id, Object.freeze(mutableEntry));
97+
4798
return Object.freeze(typingState);
4899
},
49100
[Date]
50101
);
51102

52-
const allTyping: ReadonlyMap<string, AllTyping> = useReduceActivities(reducer) || Object.freeze(new Map());
103+
const state: ReadonlyMap<string, Entry> = useReduceActivities(reducer) || Object.freeze(new Map());
104+
105+
const allTyping = useMemo(() => {
106+
const map = new Map<string, AllTyping>();
107+
108+
for (const [id, entry] of state.entries()) {
109+
const firstContentfulLivestream = iteratorFind(
110+
entry.livestreamActivities.values(),
111+
({ contentful }) => contentful
112+
);
113+
114+
const firstContentlessLivestream = iteratorFind(
115+
entry.livestreamActivities.values(),
116+
({ contentful }) => !contentful
117+
);
118+
119+
if (firstContentfulLivestream) {
120+
map.set(
121+
id,
122+
Object.freeze({
123+
firstReceivedAt: firstContentfulLivestream.firstReceivedAt,
124+
lastActivityDuration: Infinity,
125+
lastReceivedAt: firstContentfulLivestream.lastReceivedAt,
126+
name: entry.name,
127+
role: entry.role,
128+
type: 'livestream'
129+
} satisfies AllTyping)
130+
);
131+
} else if (firstContentlessLivestream) {
132+
map.set(
133+
id,
134+
Object.freeze({
135+
firstReceivedAt: firstContentlessLivestream.firstReceivedAt,
136+
lastActivityDuration: Infinity,
137+
lastReceivedAt: firstContentlessLivestream.lastReceivedAt,
138+
name: entry.name,
139+
role: entry.role,
140+
type: 'busy'
141+
} satisfies AllTyping)
142+
);
143+
} else if (entry.typingIndicator) {
144+
map.set(
145+
id,
146+
Object.freeze({
147+
firstReceivedAt: entry.typingIndicator.firstReceivedAt,
148+
lastActivityDuration: entry.typingIndicator.duration,
149+
lastReceivedAt: entry.typingIndicator.lastReceivedAt,
150+
name: entry.name,
151+
role: entry.role,
152+
type: 'busy'
153+
} satisfies AllTyping)
154+
);
155+
}
156+
}
157+
158+
return map;
159+
}, [state]);
53160

54161
const allTypingState = useMemo(() => Object.freeze([allTyping] as const), [allTyping]);
55162

0 commit comments

Comments
 (0)