@blario/react-native 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -3
- package/dist/BlarProvider.d.ts +6 -0
- package/dist/actions/select.d.ts +1 -1
- package/dist/actions/text-input.d.ts +1 -0
- package/dist/agent/compose.d.ts +1 -0
- package/dist/agent-mode/keyboard-guard.d.ts +13 -0
- package/dist/snapshot/capture.d.ts +1 -0
- package/dist/snapshot/visibility.d.ts +1 -1
- package/dist/snapshot/wrappers.d.ts +0 -2
- package/dist/telemetry/contain.d.ts +5 -0
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
- package/src/BlarProvider.tsx +68 -28
- package/src/actions/executor.ts +6 -3
- package/src/actions/select.ts +16 -2
- package/src/actions/text-input.ts +30 -1
- package/src/agent/compose.ts +2 -0
- package/src/agent/controller.ts +3 -1
- package/src/agent-mode/keyboard-guard.ts +35 -0
- package/src/agent-mode/timers.ts +3 -1
- package/src/agent-mode/ui/AgentDock.tsx +8 -7
- package/src/agent-mode/ui/AgentLayer.tsx +2 -1
- package/src/agent-mode/ui/AgentLayerHost.tsx +2 -1
- package/src/agent-mode/ui/AgentPanel.tsx +4 -3
- package/src/agent-mode/ui/AgentTap.tsx +3 -2
- package/src/agent-mode/ui/SpotlightLayer.tsx +4 -3
- package/src/agent-mode/ui/StatusCard.tsx +3 -2
- package/src/agent-mode/ui/TouchShield.tsx +2 -1
- package/src/backend/foreground.ts +11 -6
- package/src/backend/health.ts +10 -6
- package/src/backend/presence.ts +12 -8
- package/src/backend/session-socket.ts +25 -18
- package/src/blar.ts +8 -7
- package/src/chat/ChatSheet.tsx +32 -11
- package/src/chat/ChatView.tsx +12 -10
- package/src/chat/Launcher.tsx +2 -1
- package/src/chat/widget-bundle.generated.ts +2 -2
- package/src/nudge/NudgeBubble.tsx +6 -5
- package/src/snapshot/capture.ts +8 -6
- package/src/snapshot/render.ts +1 -1
- package/src/snapshot/visibility.ts +11 -10
- package/src/snapshot/wrappers.ts +0 -13
- package/src/telemetry/contain.ts +28 -0
- package/src/telemetry/sdk-reporter.ts +3 -0
- package/src/transport/relay-transport.ts +13 -9
- package/src/version.ts +1 -1
|
@@ -7,6 +7,7 @@ import { chooseDock, freeBand, messageCap, type PanelDock, STACK_GAP } from '../
|
|
|
7
7
|
import type { SpotlightSnapshot } from '../spotlight-controller.ts';
|
|
8
8
|
import { type ActiveSpotlight, AgentPanel, panelChrome } from './AgentPanel.tsx';
|
|
9
9
|
import { StatusCard, type StatusCorners } from './StatusCard.tsx';
|
|
10
|
+
import { contain } from '../../telemetry/contain.ts';
|
|
10
11
|
|
|
11
12
|
export interface DockActions {
|
|
12
13
|
place: (id: number, band: Band) => void | Promise<void>;
|
|
@@ -88,7 +89,7 @@ export function AgentDock({ spotlight, actions, card, strings, color, reduceMoti
|
|
|
88
89
|
const pending = useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
|
89
90
|
|
|
90
91
|
const later = (callback: () => void, milliseconds: number): void => {
|
|
91
|
-
pending.current.push(setTimeout(callback, milliseconds));
|
|
92
|
+
pending.current.push(setTimeout(contain('AgentDock.later', callback), milliseconds));
|
|
92
93
|
};
|
|
93
94
|
|
|
94
95
|
const rest = (): void => {
|
|
@@ -118,15 +119,15 @@ export function AgentDock({ spotlight, actions, card, strings, color, reduceMoti
|
|
|
118
119
|
const duration = reduceMotion ? REDUCED_MS : CLOSE_MS;
|
|
119
120
|
const easing = reduceMotion ? Easing.linear : CLOSE;
|
|
120
121
|
if (dockRef.current === 'bottom') {
|
|
121
|
-
Animated.parallel([animate(panelOpacity, 0, duration, easing), animate(panelShift, reduceMotion ? 0 : ENTER_SHIFT, duration, easing)]).start(() => {
|
|
122
|
+
Animated.parallel([animate(panelOpacity, 0, duration, easing), animate(panelShift, reduceMotion ? 0 : ENTER_SHIFT, duration, easing)]).start(contain('AgentDock.close', () => {
|
|
122
123
|
if (presentedId.current === null) {
|
|
123
124
|
rest();
|
|
124
125
|
setPresented(null);
|
|
125
126
|
}
|
|
126
|
-
});
|
|
127
|
+
}));
|
|
127
128
|
return;
|
|
128
129
|
}
|
|
129
|
-
Animated.parallel([animate(stackOpacity, 0, duration, easing), animate(stackShift, reduceMotion ? 0 : -ENTER_SHIFT, duration, easing)]).start(() => {
|
|
130
|
+
Animated.parallel([animate(stackOpacity, 0, duration, easing), animate(stackShift, reduceMotion ? 0 : -ENTER_SHIFT, duration, easing)]).start(contain('AgentDock.closeTop', () => {
|
|
130
131
|
if (presentedId.current !== null) {
|
|
131
132
|
return;
|
|
132
133
|
}
|
|
@@ -134,7 +135,7 @@ export function AgentDock({ spotlight, actions, card, strings, color, reduceMoti
|
|
|
134
135
|
stackOpacity.setValue(0);
|
|
135
136
|
setPresented(null);
|
|
136
137
|
later(() => animate(stackOpacity, 1, STACK_RETURN_MS, Easing.out(Easing.ease)).start(), STACK_RETURN_PAUSE_MS);
|
|
137
|
-
});
|
|
138
|
+
}));
|
|
138
139
|
};
|
|
139
140
|
|
|
140
141
|
useEffect(() => {
|
|
@@ -193,7 +194,7 @@ export function AgentDock({ spotlight, actions, card, strings, color, reduceMoti
|
|
|
193
194
|
Animated.parallel([animate(panelOpacity, 1, duration, easing), animate(panelShift, 0, duration, easing)]).start();
|
|
194
195
|
return;
|
|
195
196
|
}
|
|
196
|
-
animate(stackOpacity, 0, STACK_FADE_MS, Easing.out(Easing.ease)).start(() => {
|
|
197
|
+
animate(stackOpacity, 0, STACK_FADE_MS, Easing.out(Easing.ease)).start(contain('AgentDock.moveTop', () => {
|
|
197
198
|
later(() => {
|
|
198
199
|
if (presentedId.current !== presented.id) {
|
|
199
200
|
return;
|
|
@@ -205,7 +206,7 @@ export function AgentDock({ spotlight, actions, card, strings, color, reduceMoti
|
|
|
205
206
|
stackShift.setValue(reduceMotion ? 0 : -ENTER_SHIFT);
|
|
206
207
|
Animated.parallel([animate(stackOpacity, 1, duration, easing), animate(stackShift, 0, duration, easing)]).start();
|
|
207
208
|
}, STACK_SWAP_PAUSE_MS);
|
|
208
|
-
});
|
|
209
|
+
}));
|
|
209
210
|
}, [presented]);
|
|
210
211
|
|
|
211
212
|
useEffect(() => {
|
|
@@ -14,6 +14,7 @@ import { MessageBubbles } from './MessageBubbles.tsx';
|
|
|
14
14
|
import { PendingOverlay } from './PendingOverlay.tsx';
|
|
15
15
|
import { SpotlightLayer } from './SpotlightLayer.tsx';
|
|
16
16
|
import { TouchShield } from './TouchShield.tsx';
|
|
17
|
+
import { contain } from '../../telemetry/contain.ts';
|
|
17
18
|
|
|
18
19
|
export interface AgentLayerProps {
|
|
19
20
|
snapshot: AgentModeSnapshot;
|
|
@@ -64,7 +65,7 @@ function useBlockedTap(): { toast: boolean; pulse: number; block: () => void } {
|
|
|
64
65
|
if (timer.current !== null) {
|
|
65
66
|
clearTimeout(timer.current);
|
|
66
67
|
}
|
|
67
|
-
timer.current = setTimeout(() => setToast(false), TOAST_MS);
|
|
68
|
+
timer.current = setTimeout(contain('AgentLayer.toast', () => setToast(false)), TOAST_MS);
|
|
68
69
|
};
|
|
69
70
|
return { toast, pulse, block };
|
|
70
71
|
}
|
|
@@ -3,6 +3,7 @@ import { Modal, Platform, StyleSheet, View } from 'react-native';
|
|
|
3
3
|
|
|
4
4
|
import { loadFullWindowOverlay } from './full-window-overlay.ts';
|
|
5
5
|
import { chooseLayerHost } from './layer-host.ts';
|
|
6
|
+
import { contain } from '../../telemetry/contain.ts';
|
|
6
7
|
|
|
7
8
|
export interface AgentLayerHostProps {
|
|
8
9
|
visible: boolean;
|
|
@@ -20,7 +21,7 @@ export function AgentLayerHost({ visible, generation, onRequestClose, children }
|
|
|
20
21
|
}
|
|
21
22
|
if (HOST === 'modal') {
|
|
22
23
|
return (
|
|
23
|
-
<Modal key={generation} visible transparent animationType="none" hardwareAccelerated onRequestClose={onRequestClose}>
|
|
24
|
+
<Modal key={generation} visible transparent animationType="none" hardwareAccelerated onRequestClose={contain('AgentLayerHost.close', onRequestClose)}>
|
|
24
25
|
{children}
|
|
25
26
|
</Modal>
|
|
26
27
|
);
|
|
@@ -9,6 +9,7 @@ import type { PanelDock } from '../panel-geometry.ts';
|
|
|
9
9
|
import type { SpotlightSnapshot } from '../spotlight-controller.ts';
|
|
10
10
|
import { MarkdownText } from './MarkdownText.tsx';
|
|
11
11
|
import { WarningIcon } from './WarningIcon.tsx';
|
|
12
|
+
import { contain } from '../../telemetry/contain.ts';
|
|
12
13
|
|
|
13
14
|
export type ActiveSpotlight = Exclude<SpotlightSnapshot, { kind: 'none' }>;
|
|
14
15
|
|
|
@@ -104,7 +105,7 @@ export function AgentPanel({ spotlight, dock, strings, color, messageMaxHeight,
|
|
|
104
105
|
{spotlight.kind === 'reading' ? (
|
|
105
106
|
<View style={styles.progressRow}>
|
|
106
107
|
<Progress durationMs={spotlight.durationMs} running={spotlight.shown} color={color} />
|
|
107
|
-
<Pressable accessibilityRole="button" onPress={onSkip} style={styles.skip}>
|
|
108
|
+
<Pressable accessibilityRole="button" onPress={contain('AgentPanel.skip', onSkip)} style={styles.skip}>
|
|
108
109
|
<Text style={styles.skipText}>{strings.readingSkip}</Text>
|
|
109
110
|
</Pressable>
|
|
110
111
|
</View>
|
|
@@ -117,12 +118,12 @@ export function AgentPanel({ spotlight, dock, strings, color, messageMaxHeight,
|
|
|
117
118
|
</View>
|
|
118
119
|
) : null}
|
|
119
120
|
<View style={[styles.buttons, destructive ? styles.destructiveButtons : null]}>
|
|
120
|
-
<Pressable accessibilityRole="button" onPress={onStop} style={[styles.button, styles.stop]}>
|
|
121
|
+
<Pressable accessibilityRole="button" onPress={contain('AgentPanel.stop', onStop)} style={[styles.button, styles.stop]}>
|
|
121
122
|
<Text style={[styles.buttonText, { color: INK }]}>{strings.confirmStop}</Text>
|
|
122
123
|
</Pressable>
|
|
123
124
|
<Pressable
|
|
124
125
|
accessibilityRole="button"
|
|
125
|
-
onPress={onConfirm}
|
|
126
|
+
onPress={contain('AgentPanel.confirm', onConfirm)}
|
|
126
127
|
style={[styles.button, destructive ? { backgroundColor: DESTRUCTIVE } : [styles.confirmButton, { backgroundColor: color }]]}
|
|
127
128
|
>
|
|
128
129
|
<Text style={[styles.buttonText, { color: destructive ? '#ffffff' : onColor(color) }]}>
|
|
@@ -3,6 +3,7 @@ import { Animated, Easing, StyleSheet, View } from 'react-native';
|
|
|
3
3
|
|
|
4
4
|
import { centerOf, type Point } from '../../host/rect.ts';
|
|
5
5
|
import type { CursorSnapshot } from '../cursor.ts';
|
|
6
|
+
import { contain } from '../../telemetry/contain.ts';
|
|
6
7
|
|
|
7
8
|
export interface AgentTapProps {
|
|
8
9
|
snapshot: CursorSnapshot;
|
|
@@ -90,7 +91,7 @@ function TapRing({ point, landed, ending, color, reduceMotion, onEnded }: TapRin
|
|
|
90
91
|
|
|
91
92
|
useEffect(() => {
|
|
92
93
|
if (ending) {
|
|
93
|
-
timing(opacity, 0, END_FADE_MS, EASE_OUT).start(() => ended.current());
|
|
94
|
+
timing(opacity, 0, END_FADE_MS, EASE_OUT).start(contain('AgentTap.ended', () => ended.current()));
|
|
94
95
|
}
|
|
95
96
|
}, [ending, opacity, ended]);
|
|
96
97
|
|
|
@@ -107,7 +108,7 @@ function TapRipple({ point, onDone }: { point: Point; onDone: () => void }): Rea
|
|
|
107
108
|
const progress = useRef(new Animated.Value(0)).current;
|
|
108
109
|
const done = useLatest(onDone);
|
|
109
110
|
useEffect(() => {
|
|
110
|
-
timing(progress, 1, RIPPLE_MS, RIPPLE, PRESS_MS).start(() => done.current());
|
|
111
|
+
timing(progress, 1, RIPPLE_MS, RIPPLE, PRESS_MS).start(contain('AgentTap.ripple', () => done.current()));
|
|
111
112
|
}, [progress, done]);
|
|
112
113
|
return (
|
|
113
114
|
<Animated.View
|
|
@@ -5,6 +5,7 @@ import type { SpotlightSnapshot } from '../spotlight-controller.ts';
|
|
|
5
5
|
import type { ActiveSpotlight } from './AgentPanel.tsx';
|
|
6
6
|
import { SPOTLIGHT_DIM, SpotlightOverlay } from './SpotlightOverlay.tsx';
|
|
7
7
|
import { TargetPulse } from './TargetPulse.tsx';
|
|
8
|
+
import { contain } from '../../telemetry/contain.ts';
|
|
8
9
|
|
|
9
10
|
export interface SpotlightLayerProps {
|
|
10
11
|
spotlight: SpotlightSnapshot;
|
|
@@ -33,11 +34,11 @@ export function SpotlightLayer({ spotlight, color, reduceMotion, onBackdropPress
|
|
|
33
34
|
Animated.timing(opacity, { toValue: 1, duration, easing: Easing.out(Easing.ease), useNativeDriver: true }).start();
|
|
34
35
|
return;
|
|
35
36
|
}
|
|
36
|
-
Animated.timing(opacity, { toValue: 0, duration, easing: Easing.out(Easing.ease), useNativeDriver: true }).start(({ finished }) => {
|
|
37
|
+
Animated.timing(opacity, { toValue: 0, duration, easing: Easing.out(Easing.ease), useNativeDriver: true }).start(contain('SpotlightLayer.fade', ({ finished }: { finished: boolean }) => {
|
|
37
38
|
if (finished) {
|
|
38
39
|
setGone(true);
|
|
39
40
|
}
|
|
40
|
-
});
|
|
41
|
+
}));
|
|
41
42
|
}, [visible, reduceMotion, opacity]);
|
|
42
43
|
|
|
43
44
|
const current = last.current;
|
|
@@ -46,7 +47,7 @@ export function SpotlightLayer({ spotlight, color, reduceMotion, onBackdropPress
|
|
|
46
47
|
}
|
|
47
48
|
return (
|
|
48
49
|
<Animated.View pointerEvents={visible ? 'box-none' : 'none'} style={[StyleSheet.absoluteFill, { opacity }]}>
|
|
49
|
-
{current.kind === 'confirming' ? <Pressable style={StyleSheet.absoluteFill} onPress={onBackdropPress} /> : null}
|
|
50
|
+
{current.kind === 'confirming' ? <Pressable style={StyleSheet.absoluteFill} onPress={contain('SpotlightLayer.backdrop', onBackdropPress)} /> : null}
|
|
50
51
|
{current.hole === null ? <View pointerEvents="none" style={styles.dim} /> : <SpotlightOverlay hole={current.hole} />}
|
|
51
52
|
{current.kind === 'confirming' && current.target !== null ? (
|
|
52
53
|
<TargetPulse target={current.target} radius={current.request.target?.radius ?? 0} color={color} reduceMotion={reduceMotion} />
|
|
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
|
|
2
2
|
import { Animated, Pressable, StyleSheet, Text, View } from 'react-native';
|
|
3
3
|
|
|
4
4
|
import { onColor, withAlpha } from '../colors.ts';
|
|
5
|
+
import { contain } from '../../telemetry/contain.ts';
|
|
5
6
|
|
|
6
7
|
export interface StatusCardProps {
|
|
7
8
|
status: string;
|
|
@@ -54,7 +55,7 @@ function useStopPulse(pulse: number): { scale: Animated.Value; bordered: boolean
|
|
|
54
55
|
]);
|
|
55
56
|
const animation = Animated.loop(beat, { iterations: PULSE_TIMES });
|
|
56
57
|
animation.start();
|
|
57
|
-
const timer = setTimeout(() => setBordered(false), BORDER_MS);
|
|
58
|
+
const timer = setTimeout(contain('StatusCard.border', () => setBordered(false)), BORDER_MS);
|
|
58
59
|
return () => {
|
|
59
60
|
animation.stop();
|
|
60
61
|
clearTimeout(timer);
|
|
@@ -83,7 +84,7 @@ export function StatusCard({ status, queued, stopLabel, color, stopHidden, corne
|
|
|
83
84
|
accessibilityLabel={stopLabel}
|
|
84
85
|
hitSlop={STOP_HIT_SLOP}
|
|
85
86
|
style={[styles.stop, { backgroundColor: withAlpha(ink, 0.2) }, bordered ? { borderWidth: 2, borderColor: withAlpha(ink, 0.8) } : null]}
|
|
86
|
-
onPress={onStop}
|
|
87
|
+
onPress={contain('StatusCard.stop', onStop)}
|
|
87
88
|
>
|
|
88
89
|
<View style={[styles.stopIcon, { backgroundColor: ink }]} />
|
|
89
90
|
</Pressable>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { StyleSheet, View } from 'react-native';
|
|
3
|
+
import { contain } from '../../telemetry/contain.ts';
|
|
3
4
|
|
|
4
5
|
export interface TouchShieldProps {
|
|
5
6
|
onBlockedTap: () => void;
|
|
@@ -10,7 +11,7 @@ export function TouchShield({ onBlockedTap }: TouchShieldProps): React.JSX.Eleme
|
|
|
10
11
|
<View
|
|
11
12
|
style={styles.shield}
|
|
12
13
|
onStartShouldSetResponder={() => true}
|
|
13
|
-
onResponderRelease={onBlockedTap}
|
|
14
|
+
onResponderRelease={contain('TouchShield.tap', onBlockedTap)}
|
|
14
15
|
accessibilityElementsHidden
|
|
15
16
|
importantForAccessibility="no-hide-descendants"
|
|
16
17
|
/>
|
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import { AppState } from 'react-native';
|
|
1
|
+
import { AppState, type AppStateStatus } from 'react-native';
|
|
2
|
+
|
|
3
|
+
import { contain } from '../telemetry/contain.ts';
|
|
2
4
|
|
|
3
5
|
export function watchForeground(onActive: () => void): () => void {
|
|
4
|
-
const subscription = AppState.addEventListener(
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
const subscription = AppState.addEventListener(
|
|
7
|
+
'change',
|
|
8
|
+
contain('watchForeground', (state: AppStateStatus) => {
|
|
9
|
+
if (state === 'active') {
|
|
10
|
+
onActive();
|
|
11
|
+
}
|
|
12
|
+
}),
|
|
13
|
+
);
|
|
9
14
|
return () => subscription.remove();
|
|
10
15
|
}
|
package/src/backend/health.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AppState, type AppStateStatus, type NativeEventSubscription } from 'react-native';
|
|
2
2
|
|
|
3
3
|
import { SDK_VERSION } from '../version.ts';
|
|
4
|
+
import { contain } from '../telemetry/contain.ts';
|
|
4
5
|
|
|
5
6
|
export interface ClientHealth {
|
|
6
7
|
visibility: 'visible' | 'hidden';
|
|
@@ -27,13 +28,16 @@ export class ClientHealthProbe {
|
|
|
27
28
|
return;
|
|
28
29
|
}
|
|
29
30
|
this.hiddenSince = AppState.currentState === 'active' ? null : Date.now();
|
|
30
|
-
this.subscription = AppState.addEventListener('change', (state) => this.onStateChange(state));
|
|
31
|
+
this.subscription = AppState.addEventListener('change', contain('ClientHealth.state', (state: AppStateStatus) => this.onStateChange(state)));
|
|
31
32
|
this.expectedTickAt = Date.now() + PROBE_INTERVAL_MS;
|
|
32
|
-
this.timer = setInterval(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
this.timer = setInterval(
|
|
34
|
+
contain('ClientHealth.probe', () => {
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
this.worstLagMs = Math.max(this.worstLagMs, now - this.expectedTickAt);
|
|
37
|
+
this.expectedTickAt = now + PROBE_INTERVAL_MS;
|
|
38
|
+
}),
|
|
39
|
+
PROBE_INTERVAL_MS,
|
|
40
|
+
);
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
stop(): void {
|
package/src/backend/presence.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { AppState, type NativeEventSubscription } from 'react-native';
|
|
1
|
+
import { AppState, type AppStateStatus, type NativeEventSubscription } from 'react-native';
|
|
2
2
|
|
|
3
3
|
import type { ApiClient } from './api-client.ts';
|
|
4
|
+
import { contain } from '../telemetry/contain.ts';
|
|
4
5
|
|
|
5
6
|
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
6
7
|
const IDLE_TIMEOUT_MS = 3 * 60_000;
|
|
@@ -29,12 +30,15 @@ export class PresenceManager {
|
|
|
29
30
|
}
|
|
30
31
|
this.started = true;
|
|
31
32
|
this.lastInteraction = this.now();
|
|
32
|
-
this.subscription = AppState.addEventListener(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
this.subscription = AppState.addEventListener(
|
|
34
|
+
'change',
|
|
35
|
+
contain('Presence.state', (state: AppStateStatus) => {
|
|
36
|
+
if (state === 'active') {
|
|
37
|
+
this.lastInteraction = this.now();
|
|
38
|
+
this.fireNow();
|
|
39
|
+
}
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
38
42
|
this.fireNow();
|
|
39
43
|
}
|
|
40
44
|
|
|
@@ -71,7 +75,7 @@ export class PresenceManager {
|
|
|
71
75
|
if (this.timer !== null) {
|
|
72
76
|
clearTimeout(this.timer);
|
|
73
77
|
}
|
|
74
|
-
this.timer = setTimeout(() => this.fireNow(), HEARTBEAT_INTERVAL_MS);
|
|
78
|
+
this.timer = setTimeout(contain('Presence.beat', () => this.fireNow()), HEARTBEAT_INTERVAL_MS);
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
private async send(): Promise<void> {
|
|
@@ -3,6 +3,7 @@ import { backoffDelay, isExhausted, SOCKET_BACKOFF } from './backoff.ts';
|
|
|
3
3
|
import type { IdentityProof } from './identity.ts';
|
|
4
4
|
import { ResponseCache } from './response-cache.ts';
|
|
5
5
|
import { buildSocketUrl } from './socket-url.ts';
|
|
6
|
+
import { contain } from '../telemetry/contain.ts';
|
|
6
7
|
|
|
7
8
|
export type SocketState = 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'failed';
|
|
8
9
|
|
|
@@ -149,7 +150,7 @@ export class SessionSocket {
|
|
|
149
150
|
return;
|
|
150
151
|
}
|
|
151
152
|
this.socket = socket;
|
|
152
|
-
socket.onopen = () => {
|
|
153
|
+
socket.onopen = contain('SessionSocket.open', () => {
|
|
153
154
|
if (this.epoch !== epoch) {
|
|
154
155
|
return;
|
|
155
156
|
}
|
|
@@ -159,8 +160,8 @@ export class SessionSocket {
|
|
|
159
160
|
this.options.onState('connected');
|
|
160
161
|
socket.send(JSON.stringify({ type: 'joined' }));
|
|
161
162
|
this.cache.drain().forEach((payload) => socket.send(payload));
|
|
162
|
-
};
|
|
163
|
-
socket.onmessage = (event) => {
|
|
163
|
+
});
|
|
164
|
+
socket.onmessage = contain('SessionSocket.message', (event: { data?: unknown }) => {
|
|
164
165
|
if (this.epoch !== epoch || typeof event.data !== 'string') {
|
|
165
166
|
return;
|
|
166
167
|
}
|
|
@@ -170,13 +171,13 @@ export class SessionSocket {
|
|
|
170
171
|
this.identityRefreshUsed = false;
|
|
171
172
|
}
|
|
172
173
|
this.options.onMessage(event.data, frame);
|
|
173
|
-
};
|
|
174
|
+
});
|
|
174
175
|
socket.onerror = () => undefined;
|
|
175
|
-
socket.onclose = (event) => {
|
|
176
|
+
socket.onclose = contain('SessionSocket.close', (event: { code?: number }) => {
|
|
176
177
|
if (this.epoch === epoch) {
|
|
177
178
|
this.handleClose(epoch, event.code ?? STALE_CLOSE_CODE);
|
|
178
179
|
}
|
|
179
|
-
};
|
|
180
|
+
});
|
|
180
181
|
}
|
|
181
182
|
|
|
182
183
|
private handleClose(epoch: number, code: number): void {
|
|
@@ -210,17 +211,20 @@ export class SessionSocket {
|
|
|
210
211
|
if (isExhausted(SOCKET_BACKOFF, this.attempts)) {
|
|
211
212
|
this.options.onState('failed');
|
|
212
213
|
this.exhausted = true;
|
|
213
|
-
this.reconnectTimer = setTimeout(() => this.recover(), EXHAUSTED_RETRY_MS);
|
|
214
|
+
this.reconnectTimer = setTimeout(contain('SessionSocket.recover', () => this.recover()), EXHAUSTED_RETRY_MS);
|
|
214
215
|
return;
|
|
215
216
|
}
|
|
216
217
|
const delay = backoffDelay(SOCKET_BACKOFF, this.attempts);
|
|
217
218
|
this.options.onState('reconnecting');
|
|
218
|
-
this.reconnectTimer = setTimeout(
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
219
|
+
this.reconnectTimer = setTimeout(
|
|
220
|
+
contain('SessionSocket.reconnect', () => {
|
|
221
|
+
this.attempts += 1;
|
|
222
|
+
if (this.sessionId !== null) {
|
|
223
|
+
this.connect(this.sessionId);
|
|
224
|
+
}
|
|
225
|
+
}),
|
|
226
|
+
delay,
|
|
227
|
+
);
|
|
224
228
|
}
|
|
225
229
|
|
|
226
230
|
private recover(): void {
|
|
@@ -240,11 +244,14 @@ export class SessionSocket {
|
|
|
240
244
|
private startLiveness(): void {
|
|
241
245
|
this.stopLiveness();
|
|
242
246
|
this.lastFrameAt = Date.now();
|
|
243
|
-
this.livenessTimer = setInterval(
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
247
|
+
this.livenessTimer = setInterval(
|
|
248
|
+
contain('SessionSocket.liveness', () => {
|
|
249
|
+
if (Date.now() - this.lastFrameAt >= STALE_AFTER_MS) {
|
|
250
|
+
this.dropStaleSocket();
|
|
251
|
+
}
|
|
252
|
+
}),
|
|
253
|
+
LIVENESS_CHECK_MS,
|
|
254
|
+
);
|
|
248
255
|
}
|
|
249
256
|
|
|
250
257
|
private stopLiveness(): void {
|
package/src/blar.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { BackendTransport } from './backend/backend-transport.ts';
|
|
2
2
|
import type { BlarIdentity } from './backend/identity.ts';
|
|
3
3
|
import type { MessageMetadata } from './backend/message-metadata.ts';
|
|
4
|
+
import { attempt } from './telemetry/contain.ts';
|
|
4
5
|
|
|
5
6
|
export interface MessageMetadataOptions {
|
|
6
7
|
includeTextDom: boolean;
|
|
@@ -49,19 +50,19 @@ class BlarClient {
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
open(): void {
|
|
52
|
-
this.chat?.open();
|
|
53
|
+
attempt('Blar.open', () => this.chat?.open());
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
close(): void {
|
|
56
|
-
this.chat?.close();
|
|
57
|
+
attempt('Blar.close', () => this.chat?.close());
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
getUnreadCount(): number {
|
|
60
|
-
return this.chat?.getUnread() ?? 0;
|
|
61
|
+
return attempt('Blar.getUnreadCount', () => this.chat?.getUnread()) ?? 0;
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
isChatOpen(): boolean {
|
|
64
|
-
return this.chat?.isOpen() ?? false;
|
|
65
|
+
return attempt('Blar.isChatOpen', () => this.chat?.isOpen()) ?? false;
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
async setUser(identity: BlarIdentity): Promise<void> {
|
|
@@ -81,15 +82,15 @@ class BlarClient {
|
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
isConnected(): boolean {
|
|
84
|
-
return this.transport?.isConnected() ?? false;
|
|
85
|
+
return attempt('Blar.isConnected', () => this.transport?.isConnected()) ?? false;
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
getTabId(): string | null {
|
|
88
|
-
return this.transport?.getTabId() ?? null;
|
|
89
|
+
return attempt('Blar.getTabId', () => this.transport?.getTabId()) ?? null;
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
getSessionState(): ReturnType<BackendTransport['getState']> | null {
|
|
92
|
-
return this.transport?.getState() ?? null;
|
|
93
|
+
return attempt('Blar.getSessionState', () => this.transport?.getState()) ?? null;
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
getMessageMetadata(options: MessageMetadataOptions = { includeTextDom: false }): Promise<MessageMetadata | null> {
|
package/src/chat/ChatSheet.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import React, { useEffect, useRef } from 'react';
|
|
2
|
-
import { Animated, BackHandler, Dimensions, Easing,
|
|
2
|
+
import { Animated, BackHandler, Dimensions, Easing, Keyboard, type KeyboardEvent, Platform, Pressable, StyleSheet, View } from 'react-native';
|
|
3
3
|
|
|
4
4
|
import { useInsets } from '../ui/useInsets.ts';
|
|
5
5
|
import type { UiStrings } from '../ui/strings.ts';
|
|
6
6
|
import type { SheetMode } from './chat-state.ts';
|
|
7
|
+
import { attempt, contain } from '../telemetry/contain.ts';
|
|
7
8
|
|
|
8
9
|
export interface ChatSheetProps {
|
|
9
10
|
mode: SheetMode;
|
|
@@ -16,12 +17,38 @@ export interface ChatSheetProps {
|
|
|
16
17
|
const TOP_GAP = 12;
|
|
17
18
|
const ANIMATION_MS = 240;
|
|
18
19
|
|
|
20
|
+
// iOS lifts the sheet over the keyboard while the chat is open. Not KeyboardAvoidingView: it reacts to every keyboard in
|
|
21
|
+
// the app even with the chat closed, and its LayoutAnimation applies to the host's whole next commit, which aborted a
|
|
22
|
+
// debug build as soon as a keyboard opened while the host mounted views. On Android the window resizes by itself.
|
|
23
|
+
function useKeyboardInset(active: boolean, homeIndicator: number): Animated.Value {
|
|
24
|
+
const inset = useRef(new Animated.Value(0)).current;
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!active || Platform.OS !== 'ios') {
|
|
27
|
+
inset.setValue(0);
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const subscription = Keyboard.addListener('keyboardWillChangeFrame', contain('ChatSheet.keyboard', (event: KeyboardEvent) => {
|
|
31
|
+
const covered = Dimensions.get('window').height - event.endCoordinates.screenY;
|
|
32
|
+
Animated.timing(inset, {
|
|
33
|
+
// The keyboard covers the home-indicator inset, so the sheet's bottom padding sits under it, not above it.
|
|
34
|
+
toValue: Math.max(0, covered - homeIndicator),
|
|
35
|
+
duration: event.duration,
|
|
36
|
+
easing: Easing.out(Easing.cubic),
|
|
37
|
+
useNativeDriver: false,
|
|
38
|
+
}).start();
|
|
39
|
+
}));
|
|
40
|
+
return () => subscription.remove();
|
|
41
|
+
}, [active, homeIndicator, inset]);
|
|
42
|
+
return inset;
|
|
43
|
+
}
|
|
44
|
+
|
|
19
45
|
export function ChatSheet({ mode, mounted, strings, onClose, children }: ChatSheetProps): React.JSX.Element | null {
|
|
20
46
|
const insets = useInsets();
|
|
21
47
|
const windowHeight = Dimensions.get('window').height;
|
|
22
48
|
const sheetHeight = windowHeight - insets.top - TOP_GAP;
|
|
23
49
|
const progress = useRef(new Animated.Value(mode === 'open' ? 1 : 0)).current;
|
|
24
50
|
const isOpen = mode === 'open';
|
|
51
|
+
const keyboardInset = useKeyboardInset(isOpen, insets.bottom);
|
|
25
52
|
|
|
26
53
|
useEffect(() => {
|
|
27
54
|
Animated.timing(progress, { toValue: isOpen ? 1 : 0, duration: ANIMATION_MS, easing: Easing.out(Easing.cubic), useNativeDriver: true }).start();
|
|
@@ -32,7 +59,7 @@ export function ChatSheet({ mode, mounted, strings, onClose, children }: ChatShe
|
|
|
32
59
|
return undefined;
|
|
33
60
|
}
|
|
34
61
|
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
|
|
35
|
-
onClose
|
|
62
|
+
attempt('ChatSheet.back', onClose);
|
|
36
63
|
return true;
|
|
37
64
|
});
|
|
38
65
|
return () => subscription.remove();
|
|
@@ -46,15 +73,9 @@ export function ChatSheet({ mode, mounted, strings, onClose, children }: ChatShe
|
|
|
46
73
|
return (
|
|
47
74
|
<View style={StyleSheet.absoluteFill} pointerEvents={isOpen ? 'auto' : 'none'}>
|
|
48
75
|
<Animated.View style={[styles.backdrop, { opacity: backdropOpacity }]}>
|
|
49
|
-
<Pressable accessibilityLabel={strings.chatClose} style={StyleSheet.absoluteFill} onPress={onClose} />
|
|
76
|
+
<Pressable accessibilityLabel={strings.chatClose} style={StyleSheet.absoluteFill} onPress={contain('ChatSheet.backdrop', onClose)} />
|
|
50
77
|
</Animated.View>
|
|
51
|
-
<
|
|
52
|
-
style={[styles.container, { paddingTop: insets.top + TOP_GAP }]}
|
|
53
|
-
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
54
|
-
// The keyboard covers the home-indicator inset, so the sheet's bottom padding sits under it, not above it.
|
|
55
|
-
keyboardVerticalOffset={-insets.bottom}
|
|
56
|
-
pointerEvents="box-none"
|
|
57
|
-
>
|
|
78
|
+
<Animated.View style={[styles.container, { paddingTop: insets.top + TOP_GAP, paddingBottom: keyboardInset }]} pointerEvents="box-none">
|
|
58
79
|
<Animated.View
|
|
59
80
|
accessibilityElementsHidden={!isOpen}
|
|
60
81
|
importantForAccessibility={isOpen ? 'yes' : 'no-hide-descendants'}
|
|
@@ -62,7 +83,7 @@ export function ChatSheet({ mode, mounted, strings, onClose, children }: ChatShe
|
|
|
62
83
|
>
|
|
63
84
|
{children}
|
|
64
85
|
</Animated.View>
|
|
65
|
-
</
|
|
86
|
+
</Animated.View>
|
|
66
87
|
</View>
|
|
67
88
|
);
|
|
68
89
|
}
|
package/src/chat/ChatView.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import type { SessionRequest } from './chat-coordinator.ts';
|
|
|
10
10
|
import { type ChatNavigation, leavesChat } from './navigation.ts';
|
|
11
11
|
import { buildWidgetShell, type WidgetShellOptions } from './widget-shell.ts';
|
|
12
12
|
import { loadWebView, type WebViewHandle } from './webview.ts';
|
|
13
|
+
import { attempt, contain } from '../telemetry/contain.ts';
|
|
13
14
|
|
|
14
15
|
export interface ChatViewProps {
|
|
15
16
|
shell: WidgetShellOptions;
|
|
@@ -58,7 +59,7 @@ export function ChatView({ shell, unavailableText, getMetadata, getProof, onClos
|
|
|
58
59
|
);
|
|
59
60
|
|
|
60
61
|
const onMessage = useCallback(
|
|
61
|
-
(event: WebViewMessageEvent) => {
|
|
62
|
+
contain('ChatView.message', (event: WebViewMessageEvent) => {
|
|
62
63
|
const message = parsePageMessage(event.nativeEvent.data);
|
|
63
64
|
if (message === null) {
|
|
64
65
|
return;
|
|
@@ -74,18 +75,19 @@ export function ChatView({ shell, unavailableText, getMetadata, getProof, onClos
|
|
|
74
75
|
} else {
|
|
75
76
|
log?.('webview ready');
|
|
76
77
|
}
|
|
77
|
-
},
|
|
78
|
+
}),
|
|
78
79
|
[handleCall, log, onClosed, onSessionChanged],
|
|
79
80
|
);
|
|
80
81
|
|
|
81
82
|
const onNavigation = useCallback(
|
|
82
|
-
(navigation: ChatNavigation) =>
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
83
|
+
(navigation: ChatNavigation) =>
|
|
84
|
+
attempt('ChatView.navigation', () => {
|
|
85
|
+
if (!leavesChat(navigation, shell.apiBaseUrl)) {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
Linking.openURL(navigation.url).catch(() => log?.('chat link could not be opened'));
|
|
89
|
+
return false;
|
|
90
|
+
}) ?? false,
|
|
89
91
|
[log, shell.apiBaseUrl],
|
|
90
92
|
);
|
|
91
93
|
|
|
@@ -107,7 +109,7 @@ export function ChatView({ shell, unavailableText, getMetadata, getProof, onClos
|
|
|
107
109
|
mixedContentMode="always"
|
|
108
110
|
setSupportMultipleWindows={false}
|
|
109
111
|
keyboardDisplayRequiresUserAction={false}
|
|
110
|
-
// The sheet
|
|
112
|
+
// The sheet already lifts the chat above the keyboard. WKWebView would also scroll its
|
|
111
113
|
// own page up to reveal the composer, pushing the widget out of the sheet; with scrolling off it resets that
|
|
112
114
|
// offset (the message list is an overflow container and keeps scrolling). The form bar (^ v Done) is for
|
|
113
115
|
// multi-field forms, not a one-field composer.
|
package/src/chat/Launcher.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import React from 'react';
|
|
|
2
2
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
|
3
3
|
|
|
4
4
|
import { useInsets } from '../ui/useInsets.ts';
|
|
5
|
+
import { contain } from '../telemetry/contain.ts';
|
|
5
6
|
|
|
6
7
|
export interface LauncherPlacement {
|
|
7
8
|
side: 'left' | 'right';
|
|
@@ -26,7 +27,7 @@ export function Launcher({ unread, label, color, placement, onPress }: LauncherP
|
|
|
26
27
|
<Pressable
|
|
27
28
|
accessibilityRole="button"
|
|
28
29
|
accessibilityLabel={label}
|
|
29
|
-
onPress={onPress}
|
|
30
|
+
onPress={contain('Launcher.press', onPress)}
|
|
30
31
|
style={[styles.button, horizontal, { bottom: insets.bottom + placement.offsetY, backgroundColor: color }]}
|
|
31
32
|
>
|
|
32
33
|
<View style={styles.bubble}>
|