Skip to content

Commit 36f7844

Browse files
authored
Add useIsSending
1 parent a7d57db commit 36f7844

12 files changed

Lines changed: 104 additions & 94 deletions

File tree

packages/api/src/boot/hook.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export {
3232
useGroupActivities,
3333
useGroupActivitiesByName,
3434
useGroupTimestamp,
35+
useIsSending,
3536
useLanguage,
3637
useLastAcknowledgedActivityKey,
3738
useLastReadActivityKey,

packages/api/src/hooks/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import useGetKeyByActivityId from './useGetKeyByActivityId';
3030
import useGetSendTimeoutForActivity from './useGetSendTimeoutForActivity';
3131
import useGrammars from './useGrammars';
3232
import useGroupTimestamp from './useGroupTimestamp';
33+
import useIsSending from './useIsSending';
3334
import useLanguage from './useLanguage';
3435
import useLastAcknowledgedActivityKey from './useLastAcknowledgedActivityKey';
3536
import useLastReadActivityKey from './useLastReadActivityKey';
@@ -113,6 +114,7 @@ export {
113114
useGroupActivities,
114115
useGroupActivitiesByName,
115116
useGroupTimestamp,
117+
useIsSending,
116118
useLanguage,
117119
useLastAcknowledgedActivityKey,
118120
useLastReadActivityKey,
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as useIsSending } from '../providers/ActivitySendStatus/useIsSending';

packages/api/src/providers/ActivitySendStatus/ActivitySendStatusComposer.tsx

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import React, { useEffect, useMemo, useRef, type ReactNode } from 'react';
21
import { querySendStatusFromOutgoingActivity } from 'botframework-webchat-core/activity';
2+
import { isPresentational } from 'botframework-webchat-core/internal';
3+
import React, { useEffect, useMemo, useRef, type ReactNode } from 'react';
34

4-
import { useActivities, usePonyfill } from '../../hooks/index';
5+
import { useActivities, useGetActivityByKey, usePonyfill } from '../../hooks/index';
56
import useForceRender from '../../hooks/internal/useForceRender';
67
import useGetSendTimeoutForActivity from '../../hooks/useGetSendTimeoutForActivity';
78
import type { SendStatus } from '../../types/SendStatus';
@@ -10,15 +11,18 @@ import useGetKeyByActivity from '../ActivityKeyer/useGetKeyByActivity';
1011
import type { ActivitySendStatusContextType } from './private/Context';
1112
import ActivitySendStatusContext from './private/Context';
1213
import isMapEqual from './private/isMapEqual';
14+
import type { ActivitySendStatusSubContextType } from './private/SubContext';
15+
import ActivitySendStatusSubContext from './private/SubContext';
1316

1417
// Magic numbers for `expiryByActivityKey`.
1518
const EXPIRY_SEND_FAILED = -Infinity;
1619
const EXPIRY_SENT = Infinity;
1720

1821
const ActivitySendStatusComposer = ({ children }: Readonly<{ children?: ReactNode | undefined }>) => {
19-
const [activities] = useActivities();
2022
const [{ clearTimeout, Date, setTimeout }] = usePonyfill();
23+
const [activities] = useActivities();
2124
const forceRender = useForceRender();
25+
const getActivityByKey = useGetActivityByKey();
2226
const getKeyByActivity = useGetKeyByActivity();
2327
const getSendTimeoutForActivity = useGetSendTimeoutForActivity();
2428
const sendStatusByActivityKeyRef = useRef<ReadonlyMap<string, SendStatus>>(Object.freeze(new Map()));
@@ -93,11 +97,30 @@ const ActivitySendStatusComposer = ({ children }: Readonly<{ children?: ReactNod
9397
[sendStatusByActivityKey]
9498
);
9599

100+
const isSendingState = useMemo<readonly [boolean]>(
101+
() =>
102+
Object.freeze([
103+
sendStatusByActivityKey.entries().some(([activityKey, status]) => {
104+
if (status === 'sending') {
105+
const activity = getActivityByKey(activityKey);
106+
107+
return activity && !isPresentational(activity);
108+
}
109+
})
110+
]),
111+
[getActivityByKey, sendStatusByActivityKey]
112+
);
113+
96114
const context = useMemo<ActivitySendStatusContextType>(
97-
() => ({ sendStatusByActivityKeyState }),
115+
() => Object.freeze({ sendStatusByActivityKeyState }),
98116
[sendStatusByActivityKeyState]
99117
);
100118

119+
const subContext = useMemo<ActivitySendStatusSubContextType>(
120+
() => Object.freeze({ isSendingState }),
121+
[isSendingState]
122+
);
123+
101124
// Finds the closest expiry. This is the time we should recompute `sendStatusByActivityKey`.
102125
const nextExpiry = Array.from(expiryByActivityKey.values())
103126
// Ignores activities which are already marked as `"send failed"`, because the magic number its `-Infinity`.
@@ -120,7 +143,11 @@ const ActivitySendStatusComposer = ({ children }: Readonly<{ children?: ReactNod
120143
}
121144
}, [clearTimeout, Date, forceRender, nextExpiry, setTimeout]);
122145

123-
return <ActivitySendStatusContext.Provider value={context}>{children}</ActivitySendStatusContext.Provider>;
146+
return (
147+
<ActivitySendStatusContext.Provider value={context}>
148+
<ActivitySendStatusSubContext.Provider value={subContext}>{children}</ActivitySendStatusSubContext.Provider>
149+
</ActivitySendStatusContext.Provider>
150+
);
124151
};
125152

126153
export default ActivitySendStatusComposer;

packages/api/src/providers/ActivitySendStatus/private/Context.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createContext } from 'react';
33
import type { SendStatus } from '../../../types/SendStatus';
44

55
type ActivitySendStatusContextType = {
6-
sendStatusByActivityKeyState: readonly [ReadonlyMap<string, SendStatus>];
6+
readonly sendStatusByActivityKeyState: readonly [ReadonlyMap<string, SendStatus>];
77
};
88

99
const ActivitySendStatusContext = createContext<ActivitySendStatusContextType>(
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { createContext, useContext } from 'react';
2+
3+
// Smaller context for lesser chance of update.
4+
type ActivitySendStatusSubContextType = {
5+
readonly isSendingState: readonly [boolean];
6+
};
7+
8+
const ActivitySendStatusSubContext = createContext<ActivitySendStatusSubContextType>(
9+
new Proxy({} as ActivitySendStatusSubContextType, {
10+
get() {
11+
throw new Error('botframework-webchat internal: This hook can only be used under <ActivitySendStatusComposer>.');
12+
}
13+
})
14+
);
15+
16+
function useActivitySendStatusSubContext(): ActivitySendStatusSubContextType {
17+
return useContext(ActivitySendStatusSubContext);
18+
}
19+
20+
export default ActivitySendStatusSubContext;
21+
22+
export { useActivitySendStatusSubContext, type ActivitySendStatusSubContextType };
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { useActivitySendStatusSubContext } from './private/SubContext';
2+
3+
/**
4+
* Returns `true` if there is at least one outgoing activity currently in the `"sending"` state, otherwise `false`.
5+
*
6+
* Note: presentational activities (e.g. event activity or message activity without visible contents) are excluded.
7+
*/
8+
export default function useIsSending(): readonly [boolean] {
9+
return useActivitySendStatusSubContext().isSendingState;
10+
}

packages/component/src/Transcript/LiveRegion/LongSend.tsx

Lines changed: 13 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,38 @@
11
import { hooks } from 'botframework-webchat-api';
2-
import { memo, useEffect, useRef, useState } from 'react';
2+
import { memo, useEffect, useState } from 'react';
33

44
import { useLiveRegion } from '../../providers/LiveRegionTwin';
5-
import { SENDING } from '../../types/internal/SendStatus';
6-
import useActivityKeysOfSendStatus from './useActivityKeysOfSendStatus';
75

8-
const { useLocalizer, usePonyfill } = hooks;
6+
const { useIsSending, useLocalizer, usePonyfill } = hooks;
97

10-
const SENDING_ANNOUNCEMENT_DELAY = 3000;
8+
const SENDING_ANNOUNCEMENT_INTERVAL = 3_000;
119

1210
/**
13-
* React component to narrate "Sending message." into the live region, but only when the
14-
* outgoing activity has been stuck in the `sending` state for at least 3 seconds.
11+
* React component to narrate "Sending message." into the live region repeatedly every 3 seconds,
12+
* but only while there are outgoing activities stuck in the `sending` state with none timed out.
1513
*
1614
* Fast sends (acknowledged by the server within 3 seconds) stay silent to avoid noisy
1715
* announcements. Slow or stalled sends get narrated so the user knows what is happening.
18-
*
19-
* Presentational activities (e.g. `event` or `typing`) are excluded to reduce noise.
2016
*/
2117
const LiveRegionLongSend = () => {
18+
const [{ clearInterval, setInterval }] = usePonyfill();
19+
const [isSending] = useIsSending();
2220
const localize = useLocalizer();
23-
const [{ clearTimeout, setTimeout }] = usePonyfill();
2421

2522
const liveRegionSendSendingAlt = localize('TRANSCRIPT_LIVE_REGION_SEND_SENDING_ALT');
2623

27-
/** Keys we have already announced "Sending message." for — prevents repeated announcements. */
28-
const announcedKeysRef = useRef<Set<string>>(new Set());
29-
30-
/** Monotonic counter; incrementing it causes `useLiveRegion` to queue the announcement. */
31-
const [tick, setTick] = useState(0);
32-
33-
/** Keys of outgoing non-presentational activities that are currently in the sending state. */
34-
const [sendingKeys] = useActivityKeysOfSendStatus(SENDING);
24+
// Invalidate will queue the announcement.
25+
const [tick, setTick] = useState<object | undefined>();
3526

36-
/**
37-
* Arm a per-key timer when a key newly enters `sendingKeys`.
38-
* Cancel all pending timers when a key leaves (cleanup handles this via deps change).
39-
* Clean up the `announcedKeysRef` for keys that are no longer sending.
40-
*/
4127
useEffect(() => {
42-
// Prune announced keys that are no longer sending.
43-
for (const key of Array.from(announcedKeysRef.current)) {
44-
if (!sendingKeys.has(key)) {
45-
announcedKeysRef.current.delete(key);
46-
}
47-
}
48-
49-
if (!sendingKeys.size) {
28+
if (!isSending) {
5029
return;
5130
}
5231

53-
const timeouts: ReturnType<typeof setTimeout>[] = [];
54-
55-
for (const key of sendingKeys) {
56-
if (announcedKeysRef.current.has(key)) {
57-
continue;
58-
}
59-
60-
const timeout = setTimeout(() => {
61-
if (!sendingKeys.has(key)) {
62-
return;
63-
}
64-
65-
announcedKeysRef.current.add(key);
66-
setTick(t => t + 1);
67-
}, SENDING_ANNOUNCEMENT_DELAY);
68-
69-
timeouts.push(timeout);
70-
}
32+
const interval = setInterval(() => setTick({}), SENDING_ANNOUNCEMENT_INTERVAL);
7133

72-
return () => timeouts.forEach(id => clearTimeout(id));
73-
}, [clearTimeout, sendingKeys, setTimeout]);
34+
return () => clearInterval(interval);
35+
}, [clearInterval, isSending, setInterval]);
7436

7537
useLiveRegion(() => (tick ? liveRegionSendSendingAlt : false), [liveRegionSendSendingAlt, tick]);
7638

packages/component/src/Transcript/LiveRegion/SendFailed.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { hooks } from 'botframework-webchat-api';
2+
import { isPresentational } from 'botframework-webchat-core/internal';
23
import { memo, useMemo } from 'react';
34

45
import usePrevious from '../../hooks/internal/usePrevious';
56
import { useLiveRegion } from '../../providers/LiveRegionTwin';
67
import { SEND_FAILED } from '../../types/internal/SendStatus';
7-
import useActivityKeysOfSendStatus from './useActivityKeysOfSendStatus';
88

9-
const { useLocalizer } = hooks;
9+
const { useGetActivityByKey, useLocalizer, useSendStatusByActivityKey } = hooks;
1010

1111
/**
1212
* React component to on-demand narrate "Failed to send message" at the end of the live region.
@@ -19,6 +19,8 @@ const { useLocalizer } = hooks;
1919
* Thus, we need to use a live region "footnote" to indicate the message was failed to send.
2020
*/
2121
const LiveRegionSendFailed = () => {
22+
const [sendStatusByActivityKey] = useSendStatusByActivityKey();
23+
const getActivityByKey = useGetActivityByKey();
2224
const localize = useLocalizer();
2325

2426
/**
@@ -27,7 +29,21 @@ const LiveRegionSendFailed = () => {
2729
* Activities which are presentational, such as `event` or `typing`, are ignored to reduce confusions.
2830
* "Failed to send message" should not be narrated for presentational activities.
2931
*/
30-
const [activityKeysOfSendFailed] = useActivityKeysOfSendStatus(SEND_FAILED);
32+
const activityKeysOfSendFailed = useMemo<Set<string>>(
33+
() =>
34+
Array.from(sendStatusByActivityKey).reduce((activityKeysOfSendFailed, [key, sendStatus]) => {
35+
if (sendStatus === SEND_FAILED) {
36+
const activity = getActivityByKey(key);
37+
38+
if (activity && !isPresentational(activity)) {
39+
activityKeysOfSendFailed.add(key);
40+
}
41+
}
42+
43+
return activityKeysOfSendFailed;
44+
}, new Set<string>()),
45+
[getActivityByKey, sendStatusByActivityKey]
46+
);
3147

3248
/** Returns localized "Failed to send message." */
3349
const liveRegionSendFailedAlt = localize('TRANSCRIPT_LIVE_REGION_SEND_FAILED_ALT');

packages/component/src/Transcript/LiveRegion/useActivityKeysOfSendStatus.ts

Lines changed: 0 additions & 32 deletions
This file was deleted.

0 commit comments

Comments
 (0)