@opengeni/react-native 1.4.5-canary.37615713321001

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.
Files changed (132) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +74 -0
  3. package/dist/adapters.d.ts +53 -0
  4. package/dist/adapters.js +3 -0
  5. package/dist/adapters.js.map +1 -0
  6. package/dist/attachments.d.ts +75 -0
  7. package/dist/chunk-3VSZAQ7G.js +391 -0
  8. package/dist/chunk-3VSZAQ7G.js.map +1 -0
  9. package/dist/chunk-5ZVK7WKA.js +88 -0
  10. package/dist/chunk-5ZVK7WKA.js.map +1 -0
  11. package/dist/chunk-EEDIMY3L.js +240 -0
  12. package/dist/chunk-EEDIMY3L.js.map +1 -0
  13. package/dist/chunk-M23SDI4R.js +83 -0
  14. package/dist/chunk-M23SDI4R.js.map +1 -0
  15. package/dist/chunk-PHJBSAR3.js +2006 -0
  16. package/dist/chunk-PHJBSAR3.js.map +1 -0
  17. package/dist/environment.d.ts +19 -0
  18. package/dist/expo.d.ts +25 -0
  19. package/dist/expo.js +267 -0
  20. package/dist/expo.js.map +1 -0
  21. package/dist/index.d.ts +13 -0
  22. package/dist/index.js +3345 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/presentation.d.ts +262 -0
  25. package/dist/read-acknowledgement.d.ts +39 -0
  26. package/dist/realtime/call-events.d.ts +11 -0
  27. package/dist/realtime/call-view.d.ts +26 -0
  28. package/dist/realtime/expo-call.d.ts +11 -0
  29. package/dist/realtime/native-call.d.ts +104 -0
  30. package/dist/realtime/native-realtime.d.ts +35 -0
  31. package/dist/realtime/use-native-session-realtime.d.ts +25 -0
  32. package/dist/session-view.d.ts +87 -0
  33. package/dist/timeline/activity.d.ts +34 -0
  34. package/dist/timeline/composer.d.ts +86 -0
  35. package/dist/timeline/controls.d.ts +27 -0
  36. package/dist/timeline/decisions.d.ts +29 -0
  37. package/dist/timeline/feedback.d.ts +26 -0
  38. package/dist/timeline/icon-nodes.generated.d.ts +738 -0
  39. package/dist/timeline/icon.d.ts +11 -0
  40. package/dist/timeline/index.d.ts +32 -0
  41. package/dist/timeline/index.js +5419 -0
  42. package/dist/timeline/index.js.map +1 -0
  43. package/dist/timeline/markdown.d.ts +11 -0
  44. package/dist/timeline/markdown.js +415 -0
  45. package/dist/timeline/markdown.js.map +1 -0
  46. package/dist/timeline/message-attachments.d.ts +13 -0
  47. package/dist/timeline/message-timeline.d.ts +36 -0
  48. package/dist/timeline/model-picker.d.ts +17 -0
  49. package/dist/timeline/primitives.d.ts +85 -0
  50. package/dist/timeline/queue-dock.d.ts +19 -0
  51. package/dist/timeline/session-actions.d.ts +34 -0
  52. package/dist/timeline/session-list.d.ts +50 -0
  53. package/dist/timeline/session-screen.d.ts +35 -0
  54. package/dist/timeline/session-signals.d.ts +37 -0
  55. package/dist/timeline/sheet.d.ts +14 -0
  56. package/dist/timeline/theme.d.ts +58 -0
  57. package/dist/timeline/thinking-orb.d.ts +13 -0
  58. package/dist/timeline/turn.d.ts +80 -0
  59. package/dist/ui/attention.d.ts +14 -0
  60. package/dist/ui/cards.d.ts +21 -0
  61. package/dist/ui/composer.d.ts +21 -0
  62. package/dist/ui/format.d.ts +13 -0
  63. package/dist/ui/icons.d.ts +9 -0
  64. package/dist/ui/index.d.ts +12 -0
  65. package/dist/ui/index.js +1757 -0
  66. package/dist/ui/index.js.map +1 -0
  67. package/dist/ui/steps-sheet.d.ts +10 -0
  68. package/dist/ui/theme.d.ts +63 -0
  69. package/dist/ui/timeline.d.ts +48 -0
  70. package/dist/ui/web-tokens.gen.d.ts +90 -0
  71. package/dist/use-native-session.d.ts +40 -0
  72. package/dist/view-model.d.ts +52 -0
  73. package/dist/voice-input.d.ts +102 -0
  74. package/dist/webrtc.d.ts +9 -0
  75. package/dist/webrtc.js +19 -0
  76. package/dist/webrtc.js.map +1 -0
  77. package/expo-module.config.json +7 -0
  78. package/ios/OpenGeniCall.podspec +22 -0
  79. package/ios/OpenGeniCallAppDelegateSubscriber.swift +29 -0
  80. package/ios/OpenGeniCallCenter.swift +189 -0
  81. package/ios/OpenGeniCallModule.swift +64 -0
  82. package/package.json +93 -0
  83. package/src/adapters.ts +150 -0
  84. package/src/attachments.ts +396 -0
  85. package/src/environment.tsx +214 -0
  86. package/src/expo.ts +295 -0
  87. package/src/index.ts +98 -0
  88. package/src/presentation.ts +285 -0
  89. package/src/read-acknowledgement.ts +72 -0
  90. package/src/realtime/call-events.ts +41 -0
  91. package/src/realtime/call-view.tsx +220 -0
  92. package/src/realtime/expo-call.ts +49 -0
  93. package/src/realtime/native-call.ts +266 -0
  94. package/src/realtime/native-realtime.ts +115 -0
  95. package/src/realtime/use-native-session-realtime.ts +29 -0
  96. package/src/session-view.tsx +2251 -0
  97. package/src/timeline/activity.tsx +736 -0
  98. package/src/timeline/composer.tsx +686 -0
  99. package/src/timeline/controls.tsx +151 -0
  100. package/src/timeline/decisions.tsx +651 -0
  101. package/src/timeline/feedback.tsx +251 -0
  102. package/src/timeline/icon-nodes.generated.ts +1830 -0
  103. package/src/timeline/icon.tsx +44 -0
  104. package/src/timeline/index.ts +67 -0
  105. package/src/timeline/markdown.tsx +510 -0
  106. package/src/timeline/message-attachments.tsx +155 -0
  107. package/src/timeline/message-timeline.tsx +1042 -0
  108. package/src/timeline/model-picker.tsx +450 -0
  109. package/src/timeline/primitives.tsx +561 -0
  110. package/src/timeline/queue-dock.tsx +331 -0
  111. package/src/timeline/session-actions.tsx +379 -0
  112. package/src/timeline/session-list.tsx +338 -0
  113. package/src/timeline/session-screen.tsx +488 -0
  114. package/src/timeline/session-signals.tsx +312 -0
  115. package/src/timeline/sheet.tsx +153 -0
  116. package/src/timeline/theme.tsx +128 -0
  117. package/src/timeline/thinking-orb.tsx +93 -0
  118. package/src/timeline/turn.tsx +569 -0
  119. package/src/ui/attention.tsx +220 -0
  120. package/src/ui/cards.tsx +267 -0
  121. package/src/ui/composer.tsx +296 -0
  122. package/src/ui/format.ts +86 -0
  123. package/src/ui/icons.tsx +131 -0
  124. package/src/ui/index.ts +18 -0
  125. package/src/ui/steps-sheet.tsx +71 -0
  126. package/src/ui/theme.ts +261 -0
  127. package/src/ui/timeline.tsx +837 -0
  128. package/src/ui/web-tokens.gen.ts +94 -0
  129. package/src/use-native-session.ts +219 -0
  130. package/src/view-model.ts +485 -0
  131. package/src/voice-input.ts +382 -0
  132. package/src/webrtc.ts +30 -0
@@ -0,0 +1,285 @@
1
+ export interface OpenGeniNativeTheme {
2
+ dark: boolean;
3
+ colors: {
4
+ background: string;
5
+ surface: string;
6
+ elevated: string;
7
+ border: string;
8
+ text: string;
9
+ secondaryText: string;
10
+ mutedText: string;
11
+ accent: string;
12
+ accentForeground: string;
13
+ userBubble: string;
14
+ userBubbleText: string;
15
+ danger: string;
16
+ dangerSurface: string;
17
+ warning: string;
18
+ warningSurface: string;
19
+ success: string;
20
+ };
21
+ }
22
+
23
+ export interface OpenGeniNativeLabels {
24
+ knowledgeOutcome?: Record<"published" | "pending" | "rejected" | "archived" | "failed", string>;
25
+ emptyTitle: string;
26
+ emptyDescription: string;
27
+ loadEarlier: string;
28
+ loadingEarlier: string;
29
+ reconnecting: string;
30
+ connecting: string;
31
+ offlinePaused: string;
32
+ error: string;
33
+ retry: string;
34
+ dismiss: string;
35
+ queueTitle: string;
36
+ queueEmpty: string;
37
+ edit: string;
38
+ runNext: string;
39
+ remove: string;
40
+ moveEarlier: string;
41
+ moveLater: string;
42
+ approvalsTitle: string;
43
+ approve: string;
44
+ deny: string;
45
+ questionsTitle: string;
46
+ humanInputAsked: string;
47
+ humanInputOutcome: {
48
+ answered: string;
49
+ skipped: string;
50
+ expired: string;
51
+ cancelled: string;
52
+ };
53
+ submitAnswers: string;
54
+ skip: string;
55
+ other: string;
56
+ required: string;
57
+ attachDocument: string;
58
+ attachImage: string;
59
+ preparingAttachment: string;
60
+ uploading: string;
61
+ uploadFailed: string;
62
+ attachmentError: string;
63
+ removeAttachment: string;
64
+ retryUpload: string;
65
+ restoredAttachment: string;
66
+ composerPlaceholder: string;
67
+ send: string;
68
+ steer: string;
69
+ pause: string;
70
+ resume: string;
71
+ savingDraft: string;
72
+ draftConflict: string;
73
+ keepMine: string;
74
+ useRemote: string;
75
+ thinking: string;
76
+ thought: string;
77
+ reasoning: string;
78
+ tool: string;
79
+ worker: string;
80
+ user: string;
81
+ assistant: string;
82
+ sandbox: string;
83
+ goal: string;
84
+ goalAction: {
85
+ set: string;
86
+ updated: string;
87
+ completed: string;
88
+ paused: string;
89
+ resumed: string;
90
+ cleared: string;
91
+ held: string;
92
+ continuation: string;
93
+ };
94
+ contextUpdated: string;
95
+ contextCompactionPhase: {
96
+ started: string;
97
+ compacted: string;
98
+ skipped: string;
99
+ };
100
+ machineUpdates: string;
101
+ connectionRequired: string;
102
+ memory: string;
103
+ memoryAction: {
104
+ saved: string;
105
+ corrected: string;
106
+ updated: string;
107
+ archived: string;
108
+ };
109
+ fleetDecision: string;
110
+ fleetOutcome: {
111
+ selected: string;
112
+ waiting: string;
113
+ none: string;
114
+ };
115
+ fleetReason: {
116
+ leaseReused: string;
117
+ pin: string;
118
+ rotation: string;
119
+ active: string;
120
+ allCapped: string;
121
+ none: string;
122
+ };
123
+ turn: string;
124
+ activity: string;
125
+ statusQueued: string;
126
+ statusRunning: string;
127
+ statusComplete: string;
128
+ statusRecovering: string;
129
+ statusWaitingCapacity: string;
130
+ statusIdle: string;
131
+ statusRequiresAction: string;
132
+ statusFailed: string;
133
+ statusCancelled: string;
134
+ relativeNow: string;
135
+ relativeSeconds: string;
136
+ relativeMinutes: string;
137
+ relativeHours: string;
138
+ relativeDays: string;
139
+ }
140
+
141
+ export const DEFAULT_OPENGENI_NATIVE_LABELS = {
142
+ knowledgeOutcome: {
143
+ published: "Knowledge published",
144
+ pending: "Knowledge awaiting review",
145
+ rejected: "Knowledge rejected",
146
+ archived: "Knowledge archived",
147
+ failed: "Knowledge could not be saved",
148
+ },
149
+ emptyTitle: "Start a conversation",
150
+ emptyDescription: "Ask the agent to help with work in this session.",
151
+ loadEarlier: "Load earlier activity",
152
+ loadingEarlier: "Loading earlier activity…",
153
+ reconnecting: "Reconnecting…",
154
+ connecting: "Connecting…",
155
+ offlinePaused: "Live updates pause while the app is in the background.",
156
+ error: "Something went wrong. Try again.",
157
+ retry: "Retry",
158
+ dismiss: "Dismiss",
159
+ queueTitle: "Queued prompts",
160
+ queueEmpty: "No queued prompts",
161
+ edit: "Edit",
162
+ runNext: "Run next",
163
+ remove: "Remove",
164
+ moveEarlier: "Move earlier",
165
+ moveLater: "Move later",
166
+ approvalsTitle: "Approval required",
167
+ approve: "Approve",
168
+ deny: "Deny",
169
+ questionsTitle: "Your input is needed",
170
+ humanInputAsked: "Agent asked",
171
+ humanInputOutcome: {
172
+ answered: "You answered",
173
+ skipped: "Skipped",
174
+ expired: "Expired",
175
+ cancelled: "Cancelled",
176
+ },
177
+ submitAnswers: "Submit answers",
178
+ skip: "Skip",
179
+ other: "Other",
180
+ required: "Required",
181
+ attachDocument: "Attach document",
182
+ attachImage: "Attach image",
183
+ preparingAttachment: "Preparing…",
184
+ uploading: "Uploading…",
185
+ uploadFailed: "Upload failed",
186
+ attachmentError: "Could not open the file picker.",
187
+ removeAttachment: "Remove attachment",
188
+ retryUpload: "Retry upload",
189
+ restoredAttachment: "Attached file",
190
+ composerPlaceholder: "Message the agent…",
191
+ send: "Send",
192
+ steer: "Steer",
193
+ pause: "Pause",
194
+ resume: "Resume",
195
+ savingDraft: "Saving draft…",
196
+ draftConflict: "This draft changed elsewhere.",
197
+ keepMine: "Keep mine",
198
+ useRemote: "Use saved draft",
199
+ thinking: "Thinking",
200
+ thought: "Thought",
201
+ reasoning: "Reasoning",
202
+ tool: "Tool",
203
+ worker: "Worker",
204
+ user: "You",
205
+ assistant: "Assistant",
206
+ sandbox: "Sandbox",
207
+ goal: "Goal",
208
+ goalAction: {
209
+ set: "Goal set",
210
+ updated: "Goal updated",
211
+ completed: "Goal completed",
212
+ paused: "Goal paused",
213
+ resumed: "Goal resumed",
214
+ cleared: "Goal cleared",
215
+ held: "Goal held",
216
+ continuation: "Continuing toward the goal",
217
+ },
218
+ contextUpdated: "Context updated",
219
+ contextCompactionPhase: {
220
+ started: "Compacting conversation history",
221
+ compacted: "Conversation history compacted",
222
+ skipped: "Conversation history compaction skipped",
223
+ },
224
+ machineUpdates: "Machine updates: {value}",
225
+ connectionRequired: "Connection required for {provider}",
226
+ memory: "Memory",
227
+ memoryAction: {
228
+ saved: "Saved to memory",
229
+ corrected: "Updated memory",
230
+ updated: "Updated memory in place",
231
+ archived: "Archived memory",
232
+ },
233
+ fleetDecision: "Fleet decision",
234
+ fleetOutcome: {
235
+ selected: "Selected {candidate}",
236
+ waiting: "Waiting for capacity",
237
+ none: "No candidate selected",
238
+ },
239
+ fleetReason: {
240
+ leaseReused: "Reused the fenced lease",
241
+ pin: "Kept the session pin",
242
+ rotation: "Rotated for capacity",
243
+ active: "Used the active subscription",
244
+ allCapped: "All observed subscriptions were capped",
245
+ none: "No production candidate was selected",
246
+ },
247
+ turn: "Turn",
248
+ activity: "Activity",
249
+ statusQueued: "Queued",
250
+ statusRunning: "Running",
251
+ statusComplete: "Complete",
252
+ statusRecovering: "Recovering",
253
+ statusWaitingCapacity: "Waiting for capacity",
254
+ statusIdle: "Idle",
255
+ statusRequiresAction: "Waiting on you",
256
+ statusFailed: "Failed",
257
+ statusCancelled: "Cancelled",
258
+ relativeNow: "now",
259
+ relativeSeconds: "{value}s",
260
+ relativeMinutes: "{value}m",
261
+ relativeHours: "{value}h",
262
+ relativeDays: "{value}d",
263
+ } satisfies OpenGeniNativeLabels;
264
+
265
+ export const DEFAULT_OPENGENI_NATIVE_THEME: OpenGeniNativeTheme = {
266
+ dark: false,
267
+ colors: {
268
+ background: "#f7f7f5",
269
+ surface: "#ffffff",
270
+ elevated: "#f0f0ed",
271
+ border: "#dfdfda",
272
+ text: "#1b1b19",
273
+ secondaryText: "#5c5c57",
274
+ mutedText: "#83837b",
275
+ accent: "#2563eb",
276
+ accentForeground: "#ffffff",
277
+ userBubble: "#1f2937",
278
+ userBubbleText: "#ffffff",
279
+ danger: "#b42318",
280
+ dangerSurface: "#fef3f2",
281
+ warning: "#b54708",
282
+ warningSurface: "#fffaeb",
283
+ success: "#067647",
284
+ },
285
+ };
@@ -0,0 +1,72 @@
1
+ import { useEffect, useMemo, useRef } from "react";
2
+ import type { SessionEvent } from "@opengeni/sdk";
3
+ import { sessionAttentionReadThroughSequence } from "@opengeni/react/session-attention-model";
4
+
5
+ /** The optional attention mutation a host client may expose (the SDK client does). */
6
+ export type SessionAttentionClientLike = {
7
+ updateSessionAttention?(
8
+ workspaceId: string,
9
+ sessionId: string,
10
+ body: { unread?: boolean; acknowledgedThroughSequence?: number },
11
+ ): Promise<unknown>;
12
+ };
13
+
14
+ /**
15
+ * Where an open conversation should be acknowledged through, and whether it
16
+ * needs it: the server says unread, or newer attention events have arrived
17
+ * since the session row was read.
18
+ */
19
+ export function readAcknowledgementFrontier(
20
+ session: { unread?: boolean; lastSequence?: number } | null,
21
+ attentionThrough: number,
22
+ ): { readThrough: number; unread: boolean } {
23
+ const lastSequence = session?.lastSequence ?? 0;
24
+ return {
25
+ readThrough: Math.max(lastSequence, attentionThrough),
26
+ unread: Boolean(session?.unread) || attentionThrough > lastSequence,
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Mark the open conversation read, as the web session page does: while it is on
32
+ * screen and the app is in the foreground, acknowledge through the latest
33
+ * delivered attention boundary (completed reply, question, failure), never per
34
+ * streaming flush. Reading a session does not acknowledge it on the server;
35
+ * without this, every reply stays unread and attention notifications (pushes,
36
+ * webhooks a host turns into alerts) fire even while the person watches it.
37
+ */
38
+ export function useNativeReadAcknowledgement(input: {
39
+ client: SessionAttentionClientLike;
40
+ workspaceId: string;
41
+ sessionId: string;
42
+ session: { unread?: boolean; lastSequence?: number } | null;
43
+ events: readonly SessionEvent[];
44
+ active: boolean;
45
+ }) {
46
+ const { client, workspaceId, sessionId, session, events, active } = input;
47
+ const attentionThrough = useMemo(() => sessionAttentionReadThroughSequence(events), [events]);
48
+ const { readThrough, unread } = readAcknowledgementFrontier(session, attentionThrough);
49
+ const acknowledged = useRef<{ key: string; retried: boolean } | null>(null);
50
+
51
+ useEffect(() => {
52
+ if (!active || !session || !unread || readThrough <= 0) return;
53
+ if (!client.updateSessionAttention) return;
54
+ const key = `${workspaceId}:${sessionId}:${readThrough}`;
55
+ const previous = acknowledged.current;
56
+ if (previous?.key === key) return;
57
+ acknowledged.current = { key, retried: false };
58
+ const send = (): Promise<unknown> =>
59
+ client.updateSessionAttention!(workspaceId, sessionId, {
60
+ unread: false,
61
+ acknowledgedThroughSequence: readThrough,
62
+ });
63
+ send().catch(() => {
64
+ // Retry one transient failure for this exact frontier, then stop: a
65
+ // permanent rejection must not become a request loop.
66
+ const current = acknowledged.current;
67
+ if (current?.key !== key || current.retried) return;
68
+ current.retried = true;
69
+ void send().catch(() => undefined);
70
+ });
71
+ }, [active, client, readThrough, session, sessionId, unread, workspaceId]);
72
+ }
@@ -0,0 +1,41 @@
1
+ import type { NativeCallAudioRoute, NativeCallEvent } from "./native-call";
2
+
3
+ /** A call event as the native module sends it. */
4
+ export type RawNativeCallEvent = {
5
+ type: string;
6
+ callId?: string;
7
+ muted?: boolean;
8
+ route?: string;
9
+ target?: string | null;
10
+ };
11
+
12
+ const ROUTES = new Set<NativeCallAudioRoute>([
13
+ "receiver",
14
+ "speaker",
15
+ "bluetooth",
16
+ "headphones",
17
+ "other",
18
+ ]);
19
+
20
+ /** Map a raw native event onto the typed call event, dropping anything unknown. */
21
+ export function parseNativeCallEvent(raw: RawNativeCallEvent): NativeCallEvent | null {
22
+ switch (raw.type) {
23
+ case "ended":
24
+ return raw.callId ? { type: "ended", callId: raw.callId } : null;
25
+ case "muteChanged":
26
+ return raw.callId
27
+ ? { type: "muteChanged", callId: raw.callId, muted: Boolean(raw.muted) }
28
+ : null;
29
+ case "routeChanged": {
30
+ const route = raw.route as NativeCallAudioRoute;
31
+ return { type: "routeChanged", route: ROUTES.has(route) ? route : "other" };
32
+ }
33
+ case "audioSessionActivated":
34
+ case "audioSessionDeactivated":
35
+ return { type: raw.type };
36
+ case "startRequested":
37
+ return { type: "startRequested", target: raw.target ?? null };
38
+ default:
39
+ return null;
40
+ }
41
+ }
@@ -0,0 +1,220 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Pressable, StyleSheet, Text, View } from "react-native";
3
+ import { Icon, type NativeIconName } from "../timeline/icon";
4
+ import { ThinkingOrb } from "../timeline/thinking-orb";
5
+ import { fontStyle, useNativeTimelineTheme } from "../timeline/theme";
6
+ import type { NativeRealtimeCall } from "./native-call";
7
+
8
+ export type OpenGeniNativeCallLabels = {
9
+ connecting: string;
10
+ live: string;
11
+ reconnecting: string;
12
+ ending: string;
13
+ muted: string;
14
+ mute: string;
15
+ unmute: string;
16
+ speaker: string;
17
+ end: string;
18
+ minimize: string;
19
+ };
20
+
21
+ export const DEFAULT_OPENGENI_NATIVE_CALL_LABELS: OpenGeniNativeCallLabels = {
22
+ connecting: "Connecting…",
23
+ live: "Listening",
24
+ reconnecting: "Reconnecting…",
25
+ ending: "Ending…",
26
+ muted: "Muted",
27
+ mute: "Mute",
28
+ unmute: "Unmute",
29
+ speaker: "Speaker",
30
+ end: "End",
31
+ minimize: "Back to chat",
32
+ };
33
+
34
+ function formatDuration(seconds: number): string {
35
+ const minutes = Math.floor(seconds / 60);
36
+ const rest = seconds % 60;
37
+ return `${minutes}:${String(rest).padStart(2, "0")}`;
38
+ }
39
+
40
+ function useCallDuration(active: boolean): number | null {
41
+ const [since, setSince] = useState<number | null>(null);
42
+ const [now, setNow] = useState(() => Date.now());
43
+ useEffect(() => {
44
+ if (!active) return setSince(null);
45
+ setSince((current) => current ?? Date.now());
46
+ const timer = setInterval(() => setNow(Date.now()), 1000);
47
+ return () => clearInterval(timer);
48
+ }, [active]);
49
+ return since === null ? null : Math.max(0, Math.floor((now - since) / 1000));
50
+ }
51
+
52
+ function CallButton({
53
+ icon,
54
+ label,
55
+ onPress,
56
+ selected = false,
57
+ destructive = false,
58
+ disabled = false,
59
+ }: {
60
+ icon: NativeIconName;
61
+ label: string;
62
+ onPress: () => void;
63
+ selected?: boolean;
64
+ destructive?: boolean;
65
+ disabled?: boolean;
66
+ }) {
67
+ const theme = useNativeTimelineTheme();
68
+ const { colors } = theme;
69
+ // Hanging up is the system's solid red in either appearance, like the Phone app.
70
+ const hangUp = theme.scheme === "dark" ? "#FF453A" : "#FF3B30";
71
+ const background = destructive ? hangUp : selected ? colors.fg : colors["surface-2"];
72
+ const foreground = destructive ? "#FFFFFF" : selected ? colors.bg : colors.fg;
73
+ return (
74
+ <View style={styles.buttonColumn}>
75
+ <Pressable
76
+ accessibilityRole="button"
77
+ accessibilityLabel={label}
78
+ accessibilityState={{ selected, disabled }}
79
+ disabled={disabled}
80
+ onPress={onPress}
81
+ style={({ pressed }) => [
82
+ styles.button,
83
+ { backgroundColor: background, opacity: disabled ? 0.4 : pressed ? 0.75 : 1 },
84
+ ]}
85
+ >
86
+ <Icon name={icon} size={26} color={foreground} />
87
+ </Pressable>
88
+ <Text style={[styles.buttonLabel, fontStyle(theme, 500), { color: colors["fg-muted"] }]}>
89
+ {label}
90
+ </Text>
91
+ </View>
92
+ );
93
+ }
94
+
95
+ /**
96
+ * The full-screen call with an agent: the web's thinking orb, the call state,
97
+ * and mute, speaker and end controls. The host owns navigation (`onMinimize`
98
+ * returns to the conversation while the call continues) and safe areas.
99
+ */
100
+ export function OpenGeniNativeCallView({
101
+ call,
102
+ title,
103
+ subtitle,
104
+ onMinimize,
105
+ labels: labelOverrides,
106
+ }: {
107
+ call: NativeRealtimeCall;
108
+ title: string;
109
+ subtitle?: string | null | undefined;
110
+ onMinimize?: (() => void) | undefined;
111
+ labels?: Partial<OpenGeniNativeCallLabels> | undefined;
112
+ }) {
113
+ const theme = useNativeTimelineTheme();
114
+ const { colors } = theme;
115
+ const labels = { ...DEFAULT_OPENGENI_NATIVE_CALL_LABELS, ...labelOverrides };
116
+ const duration = useCallDuration(call.phase === "active");
117
+ const status =
118
+ call.phase === "connecting" || call.phase === "idle"
119
+ ? labels.connecting
120
+ : call.phase === "reconnecting"
121
+ ? labels.reconnecting
122
+ : call.phase === "ending"
123
+ ? labels.ending
124
+ : call.muted
125
+ ? labels.muted
126
+ : labels.live;
127
+ const orbState =
128
+ call.phase === "active" ? (call.muted ? "breathing" : "listening") : "connecting";
129
+ const live = call.phase !== "idle" && call.phase !== "ending";
130
+ return (
131
+ <View style={[styles.screen, { backgroundColor: colors.canvas }]} testID="opengeni-call">
132
+ <View style={styles.header}>
133
+ {onMinimize ? (
134
+ <Pressable
135
+ accessibilityRole="button"
136
+ accessibilityLabel={labels.minimize}
137
+ hitSlop={12}
138
+ onPress={onMinimize}
139
+ style={({ pressed }) => [styles.minimize, { opacity: pressed ? 0.6 : 1 }]}
140
+ >
141
+ <Icon name="chevron-down" size={24} color={colors["fg-muted"]} />
142
+ </Pressable>
143
+ ) : null}
144
+ </View>
145
+ <View style={styles.center}>
146
+ <ThinkingOrb state={orbState} size={64} displaySize={176} speed={0.7} />
147
+ <Text
148
+ style={[styles.title, fontStyle(theme, 600), { color: colors.fg }]}
149
+ numberOfLines={2}
150
+ accessibilityRole="header"
151
+ >
152
+ {title}
153
+ </Text>
154
+ {subtitle ? (
155
+ <Text
156
+ style={[styles.subtitle, fontStyle(theme, 400), { color: colors["fg-muted"] }]}
157
+ numberOfLines={1}
158
+ >
159
+ {subtitle}
160
+ </Text>
161
+ ) : null}
162
+ <Text
163
+ style={[styles.status, fontStyle(theme, 500), { color: colors["fg-muted"] }]}
164
+ accessibilityLiveRegion="polite"
165
+ >
166
+ {duration !== null && !call.muted ? `${status} · ${formatDuration(duration)}` : status}
167
+ </Text>
168
+ {call.error ? (
169
+ <Text style={[styles.error, fontStyle(theme, 400), { color: colors.danger }]}>
170
+ {call.error}
171
+ </Text>
172
+ ) : null}
173
+ </View>
174
+ <View style={styles.controls}>
175
+ <CallButton
176
+ icon={call.muted ? "mic-off" : "mic"}
177
+ label={call.muted ? labels.unmute : labels.mute}
178
+ selected={call.muted}
179
+ disabled={!live}
180
+ onPress={() => call.setMuted(!call.muted)}
181
+ />
182
+ <CallButton
183
+ icon="volume-2"
184
+ label={labels.speaker}
185
+ selected={call.speaker}
186
+ disabled={!live}
187
+ onPress={() => call.setSpeaker(!call.speaker)}
188
+ />
189
+ <CallButton
190
+ icon="phone-off"
191
+ label={labels.end}
192
+ destructive
193
+ disabled={call.phase === "idle" || call.phase === "ending"}
194
+ onPress={() => void call.end()}
195
+ />
196
+ </View>
197
+ </View>
198
+ );
199
+ }
200
+
201
+ const styles = StyleSheet.create({
202
+ screen: { flex: 1, paddingHorizontal: 24, paddingBottom: 32 },
203
+ header: { height: 52, flexDirection: "row", alignItems: "center" },
204
+ minimize: { width: 44, height: 44, alignItems: "center", justifyContent: "center" },
205
+ center: { flex: 1, alignItems: "center", justifyContent: "center", gap: 10 },
206
+ title: { fontSize: 26, lineHeight: 32, textAlign: "center", marginTop: 28 },
207
+ subtitle: { fontSize: 15, lineHeight: 20, textAlign: "center" },
208
+ status: { fontSize: 17, lineHeight: 22, marginTop: 4, fontVariant: ["tabular-nums"] },
209
+ error: { fontSize: 15, lineHeight: 20, textAlign: "center", marginTop: 8 },
210
+ controls: { flexDirection: "row", justifyContent: "space-evenly", alignItems: "flex-start" },
211
+ buttonColumn: { alignItems: "center", gap: 8, width: 88 },
212
+ button: {
213
+ width: 72,
214
+ height: 72,
215
+ borderRadius: 36,
216
+ alignItems: "center",
217
+ justifyContent: "center",
218
+ },
219
+ buttonLabel: { fontSize: 13, lineHeight: 16 },
220
+ });
@@ -0,0 +1,49 @@
1
+ import { requireOptionalNativeModule } from "expo-modules-core";
2
+ import { parseNativeCallEvent, type RawNativeCallEvent } from "./call-events";
3
+ import type { NativeCallAdapter } from "./native-call";
4
+
5
+ type OpenGeniCallNativeModule = {
6
+ configure(includeInRecents: boolean): void;
7
+ startCall(callId: string, title: string, target: string | null): Promise<void>;
8
+ reportConnected(callId: string): void;
9
+ endCall(callId: string, failed: boolean): Promise<void>;
10
+ setMuted(callId: string, muted: boolean): Promise<void>;
11
+ setSpeaker(on: boolean): Promise<void>;
12
+ takePendingStartRequest(): Promise<{ target: string | null } | null>;
13
+ addListener(
14
+ eventName: "onCallEvent",
15
+ listener: (event: RawNativeCallEvent) => void,
16
+ ): { remove(): void };
17
+ };
18
+
19
+ export { parseNativeCallEvent };
20
+
21
+ /**
22
+ * CallKit for Expo hosts, from the native module this package ships (linked by
23
+ * Expo autolinking). Returns null where it is unavailable (Android, web, or a
24
+ * build without the module), so voice still works without a system call.
25
+ */
26
+ export function createExpoCallAdapter(
27
+ options: { includeInRecents?: boolean } = {},
28
+ ): NativeCallAdapter | null {
29
+ const native = requireOptionalNativeModule<OpenGeniCallNativeModule>("OpenGeniCall");
30
+ if (!native) return null;
31
+ native.configure(options.includeInRecents ?? true);
32
+ // Call ids are UUIDs; CallKit reports them upper-case, the hook compares lower-case.
33
+ const id = (callId: string) => callId.toLowerCase();
34
+ return {
35
+ startCall: ({ callId, title, target }) => native.startCall(id(callId), title, target),
36
+ reportConnected: (callId) => native.reportConnected(id(callId)),
37
+ endCall: (callId, reason = "ended") => native.endCall(id(callId), reason === "failed"),
38
+ setMuted: (callId, muted) => native.setMuted(id(callId), muted),
39
+ setSpeaker: (on) => native.setSpeaker(on),
40
+ takePendingStartRequest: () => native.takePendingStartRequest(),
41
+ subscribe(listener) {
42
+ const subscription = native.addListener("onCallEvent", (raw) => {
43
+ const event = parseNativeCallEvent(raw);
44
+ if (event) listener(event);
45
+ });
46
+ return () => subscription.remove();
47
+ },
48
+ };
49
+ }