clawdex-mobile 2.0.1 → 3.0.0

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 (71) hide show
  1. package/.github/workflows/pages.yml +41 -0
  2. package/AGENTS.md +263 -110
  3. package/README.md +1 -1
  4. package/apps/mobile/.env.example +2 -2
  5. package/apps/mobile/App.tsx +175 -14
  6. package/apps/mobile/app.json +27 -9
  7. package/apps/mobile/eas.json +14 -4
  8. package/apps/mobile/package.json +13 -13
  9. package/apps/mobile/src/api/__tests__/chatMapping.test.ts +219 -0
  10. package/apps/mobile/src/api/__tests__/client.test.ts +579 -6
  11. package/apps/mobile/src/api/__tests__/ws.test.ts +27 -0
  12. package/apps/mobile/src/api/account.ts +47 -0
  13. package/apps/mobile/src/api/chatMapping.ts +435 -18
  14. package/apps/mobile/src/api/client.ts +296 -36
  15. package/apps/mobile/src/api/rateLimits.ts +143 -0
  16. package/apps/mobile/src/api/types.ts +106 -0
  17. package/apps/mobile/src/api/ws.ts +10 -1
  18. package/apps/mobile/src/components/ChatHeader.tsx +12 -12
  19. package/apps/mobile/src/components/ChatInput.tsx +154 -88
  20. package/apps/mobile/src/components/ChatMessage.tsx +548 -93
  21. package/apps/mobile/src/components/ComposerUsageLimits.tsx +167 -0
  22. package/apps/mobile/src/components/SelectionSheet.tsx +466 -0
  23. package/apps/mobile/src/components/ToolBlock.tsx +17 -15
  24. package/apps/mobile/src/components/VoiceRecordingWaveform.tsx +181 -0
  25. package/apps/mobile/src/components/WorkspacePickerModal.tsx +572 -0
  26. package/apps/mobile/src/components/__tests__/chat-input-layout.test.ts +35 -0
  27. package/apps/mobile/src/components/__tests__/chatImageSource.test.ts +44 -0
  28. package/apps/mobile/src/components/__tests__/composerUsageLimits.test.ts +138 -0
  29. package/apps/mobile/src/components/__tests__/voiceWaveform.test.ts +31 -0
  30. package/apps/mobile/src/components/chat-input-layout.ts +59 -0
  31. package/apps/mobile/src/components/chatImageSource.ts +86 -0
  32. package/apps/mobile/src/components/usageLimitBadges.ts +109 -0
  33. package/apps/mobile/src/components/voiceWaveform.ts +46 -0
  34. package/apps/mobile/src/config.ts +9 -2
  35. package/apps/mobile/src/hooks/useVoiceRecorder.ts +8 -1
  36. package/apps/mobile/src/navigation/DrawerContent.tsx +607 -457
  37. package/apps/mobile/src/navigation/__tests__/chatThreadTree.test.ts +89 -0
  38. package/apps/mobile/src/navigation/__tests__/drawerChats.test.ts +65 -0
  39. package/apps/mobile/src/navigation/chatThreadTree.ts +191 -0
  40. package/apps/mobile/src/navigation/drawerChats.ts +9 -0
  41. package/apps/mobile/src/screens/GitScreen.tsx +2 -0
  42. package/apps/mobile/src/screens/MainScreen.tsx +4244 -1237
  43. package/apps/mobile/src/screens/OnboardingScreen.tsx +2 -0
  44. package/apps/mobile/src/screens/SettingsScreen.tsx +256 -226
  45. package/apps/mobile/src/screens/TerminalScreen.tsx +2 -5
  46. package/apps/mobile/src/screens/__tests__/agentThreadDisplay.test.ts +80 -0
  47. package/apps/mobile/src/screens/__tests__/agentThreads.test.ts +170 -0
  48. package/apps/mobile/src/screens/__tests__/planCardState.test.ts +88 -0
  49. package/apps/mobile/src/screens/__tests__/subAgentTranscript.test.ts +102 -0
  50. package/apps/mobile/src/screens/__tests__/transcriptMessages.test.ts +97 -0
  51. package/apps/mobile/src/screens/agentThreadDisplay.ts +261 -0
  52. package/apps/mobile/src/screens/agentThreads.ts +167 -0
  53. package/apps/mobile/src/screens/planCardState.ts +40 -0
  54. package/apps/mobile/src/screens/subAgentTranscript.ts +149 -0
  55. package/apps/mobile/src/screens/transcriptMessages.ts +102 -0
  56. package/apps/mobile/src/theme.ts +6 -12
  57. package/docs/codex-app-server-cli-gap-tracker.md +14 -5
  58. package/docs/privacy-policy.md +54 -0
  59. package/docs/setup-and-operations.md +4 -3
  60. package/docs/terms-of-service.md +33 -0
  61. package/package.json +3 -3
  62. package/services/mac-bridge/package.json +6 -6
  63. package/services/rust-bridge/Cargo.lock +56 -47
  64. package/services/rust-bridge/Cargo.toml +1 -1
  65. package/services/rust-bridge/package.json +1 -1
  66. package/services/rust-bridge/src/main.rs +507 -9
  67. package/site/index.html +54 -0
  68. package/site/privacy/index.html +80 -0
  69. package/site/styles.css +135 -0
  70. package/site/support/index.html +51 -0
  71. package/site/terms/index.html +68 -0
@@ -0,0 +1,138 @@
1
+ import {
2
+ buildComposerUsageLimitBadges,
3
+ formatComposerUsageLimitLabel,
4
+ } from '../usageLimitBadges';
5
+
6
+ describe('composerUsageLimits', () => {
7
+ it('maps primary and secondary windows into remaining-percentage badges', () => {
8
+ const badges = buildComposerUsageLimitBadges({
9
+ limitId: 'codex',
10
+ limitName: 'Codex',
11
+ planType: 'plus',
12
+ credits: null,
13
+ primary: {
14
+ usedPercent: 34,
15
+ windowDurationMins: 300,
16
+ resetsAt: 1_700_000_000,
17
+ },
18
+ secondary: {
19
+ usedPercent: 79,
20
+ windowDurationMins: 10_080,
21
+ resetsAt: 1_700_000_100,
22
+ },
23
+ });
24
+
25
+ expect(badges).toEqual([
26
+ {
27
+ id: 'primary',
28
+ label: '5h',
29
+ remainingPercent: 66,
30
+ tone: 'neutral',
31
+ },
32
+ {
33
+ id: 'secondary',
34
+ label: 'weekly',
35
+ remainingPercent: 21,
36
+ tone: 'warning',
37
+ },
38
+ ]);
39
+ });
40
+
41
+ it('clamps remaining percent and escalates critical limits', () => {
42
+ const badges = buildComposerUsageLimitBadges({
43
+ limitId: 'codex',
44
+ limitName: null,
45
+ planType: 'pro',
46
+ credits: null,
47
+ primary: {
48
+ usedPercent: 140,
49
+ windowDurationMins: 60,
50
+ resetsAt: null,
51
+ },
52
+ secondary: {
53
+ usedPercent: 89.6,
54
+ windowDurationMins: 1_440,
55
+ resetsAt: null,
56
+ },
57
+ });
58
+
59
+ expect(badges).toEqual([
60
+ {
61
+ id: 'primary',
62
+ label: '1h',
63
+ remainingPercent: 0,
64
+ tone: 'critical',
65
+ },
66
+ {
67
+ id: 'secondary',
68
+ label: '1d',
69
+ remainingPercent: 10,
70
+ tone: 'critical',
71
+ },
72
+ ]);
73
+ });
74
+
75
+ it('omits missing windows', () => {
76
+ const badges = buildComposerUsageLimitBadges({
77
+ limitId: 'codex',
78
+ limitName: null,
79
+ planType: 'team',
80
+ credits: null,
81
+ primary: null,
82
+ secondary: {
83
+ usedPercent: 45,
84
+ windowDurationMins: 120,
85
+ resetsAt: null,
86
+ },
87
+ });
88
+
89
+ expect(badges).toEqual([
90
+ {
91
+ id: 'secondary',
92
+ label: '2h',
93
+ remainingPercent: 55,
94
+ tone: 'neutral',
95
+ },
96
+ ]);
97
+ });
98
+
99
+ it('formats generic duration labels', () => {
100
+ expect(formatComposerUsageLimitLabel(45)).toBe('45m');
101
+ expect(formatComposerUsageLimitLabel(720)).toBe('12h');
102
+ expect(formatComposerUsageLimitLabel(2_880)).toBe('2d');
103
+ });
104
+
105
+ it('falls back to 5h and weekly for the default codex pair when duration is omitted', () => {
106
+ const badges = buildComposerUsageLimitBadges({
107
+ limitId: 'codex',
108
+ limitName: 'Codex',
109
+ planType: 'plus',
110
+ credits: null,
111
+ primary: {
112
+ usedPercent: 31,
113
+ windowDurationMins: null,
114
+ resetsAt: 1_700_000_000,
115
+ },
116
+ secondary: {
117
+ usedPercent: 82,
118
+ windowDurationMins: null,
119
+ resetsAt: 1_700_000_100,
120
+ },
121
+ });
122
+
123
+ expect(badges).toEqual([
124
+ {
125
+ id: 'primary',
126
+ label: '5h',
127
+ remainingPercent: 69,
128
+ tone: 'neutral',
129
+ },
130
+ {
131
+ id: 'secondary',
132
+ label: 'weekly',
133
+ remainingPercent: 18,
134
+ tone: 'warning',
135
+ },
136
+ ]);
137
+ });
138
+ });
@@ -0,0 +1,31 @@
1
+ import {
2
+ appendVoiceWaveformSample,
3
+ fallbackVoiceWaveformLevel,
4
+ formatVoiceRecordingDuration,
5
+ normalizeVoiceMetering,
6
+ } from '../voiceWaveform';
7
+
8
+ describe('voiceWaveform', () => {
9
+ it('formats recording duration as mm:ss', () => {
10
+ expect(formatVoiceRecordingDuration(0)).toBe('00:00');
11
+ expect(formatVoiceRecordingDuration(9_000)).toBe('00:09');
12
+ expect(formatVoiceRecordingDuration(65_000)).toBe('01:05');
13
+ });
14
+
15
+ it('normalizes metering into a bounded waveform level', () => {
16
+ expect(normalizeVoiceMetering(undefined)).toBe(0);
17
+ expect(normalizeVoiceMetering(-160)).toBe(0);
18
+ expect(normalizeVoiceMetering(5)).toBe(1);
19
+ expect(normalizeVoiceMetering(-5)).toBeGreaterThan(normalizeVoiceMetering(-35));
20
+ });
21
+
22
+ it('keeps only the newest waveform samples', () => {
23
+ expect(appendVoiceWaveformSample([0.1, 0.2], 0.3, 4)).toEqual([0.1, 0.2, 0.3]);
24
+ expect(appendVoiceWaveformSample([0.1, 0.2, 0.3], 0.4, 3)).toEqual([0.2, 0.3, 0.4]);
25
+ });
26
+
27
+ it('provides a subtle fallback waveform level when metering is unavailable', () => {
28
+ expect(fallbackVoiceWaveformLevel(0)).toBeGreaterThan(0);
29
+ expect(fallbackVoiceWaveformLevel(10)).toBeLessThanOrEqual(1);
30
+ });
31
+ });
@@ -0,0 +1,59 @@
1
+ import { spacing } from '../theme';
2
+
3
+ const IOS_HOME_INDICATOR_THRESHOLD = 20;
4
+ const ANDROID_NAV_BUTTONS_THRESHOLD = 16;
5
+
6
+ export interface ComposerBottomSpacing {
7
+ baseBottomPadding: number;
8
+ extraBottomInset: number;
9
+ totalBottomPadding: number;
10
+ }
11
+
12
+ export function resolveComposerBottomSpacing(
13
+ platform: string,
14
+ safeAreaBottomInset: number,
15
+ keyboardVisible: boolean
16
+ ): ComposerBottomSpacing {
17
+ const normalizedInset = Number.isFinite(safeAreaBottomInset)
18
+ ? Math.max(0, safeAreaBottomInset)
19
+ : 0;
20
+
21
+ const baseBottomPadding = resolveBaseBottomPadding(platform, keyboardVisible);
22
+ const extraBottomInset = keyboardVisible
23
+ ? 0
24
+ : resolveRestingBottomInset(platform, normalizedInset);
25
+
26
+ return {
27
+ baseBottomPadding,
28
+ extraBottomInset,
29
+ totalBottomPadding: baseBottomPadding + extraBottomInset,
30
+ };
31
+ }
32
+
33
+ function resolveBaseBottomPadding(platform: string, keyboardVisible: boolean): number {
34
+ if (platform === 'ios') {
35
+ return keyboardVisible ? 2 : spacing.xs + 2;
36
+ }
37
+
38
+ if (platform === 'android') {
39
+ return keyboardVisible ? 0 : spacing.sm;
40
+ }
41
+
42
+ return keyboardVisible ? 0 : spacing.sm;
43
+ }
44
+
45
+ function resolveRestingBottomInset(platform: string, safeAreaBottomInset: number): number {
46
+ if (platform === 'ios') {
47
+ return safeAreaBottomInset >= IOS_HOME_INDICATOR_THRESHOLD ? spacing.sm : 0;
48
+ }
49
+
50
+ if (platform === 'android') {
51
+ if (safeAreaBottomInset >= ANDROID_NAV_BUTTONS_THRESHOLD) {
52
+ return spacing.sm;
53
+ }
54
+
55
+ return safeAreaBottomInset > 0 ? 2 : 0;
56
+ }
57
+
58
+ return safeAreaBottomInset;
59
+ }
@@ -0,0 +1,86 @@
1
+ type RemoteImageSource = {
2
+ uri: string;
3
+ headers?: Record<string, string>;
4
+ };
5
+
6
+ const REMOTE_SCHEME_PATTERN =
7
+ /^(?:https?:\/\/|data:image\/|content:\/\/|assets-library:\/\/|ph:\/\/|blob:)/i;
8
+ const FILE_SCHEME_PATTERN = /^file:\/\//i;
9
+ const WINDOWS_ABSOLUTE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
10
+
11
+ export function toMarkdownImageSource(
12
+ rawSource: string,
13
+ bridgeUrl: string | null | undefined,
14
+ bridgeToken: string | null | undefined
15
+ ): RemoteImageSource | null {
16
+ const normalizedSource = rawSource.trim();
17
+ if (!normalizedSource) {
18
+ return null;
19
+ }
20
+
21
+ if (FILE_SCHEME_PATTERN.test(normalizedSource)) {
22
+ const withoutScheme = normalizedSource.replace(FILE_SCHEME_PATTERN, '');
23
+ return toBridgeImageSource(withoutScheme, bridgeUrl, bridgeToken);
24
+ }
25
+
26
+ if (REMOTE_SCHEME_PATTERN.test(normalizedSource)) {
27
+ return { uri: normalizedSource };
28
+ }
29
+
30
+ if (normalizedSource.startsWith('/') || WINDOWS_ABSOLUTE_PATH_PATTERN.test(normalizedSource)) {
31
+ return toBridgeImageSource(normalizedSource, bridgeUrl, bridgeToken);
32
+ }
33
+
34
+ return null;
35
+ }
36
+
37
+ function toBridgeImageSource(
38
+ rawPath: string,
39
+ bridgeUrl: string | null | undefined,
40
+ bridgeToken: string | null | undefined
41
+ ): RemoteImageSource | null {
42
+ const normalizedBridgeUrl = bridgeUrl?.trim();
43
+ if (!normalizedBridgeUrl) {
44
+ return null;
45
+ }
46
+
47
+ const normalizedPath = normalizeLocalPath(rawPath);
48
+ if (!normalizedPath) {
49
+ return null;
50
+ }
51
+
52
+ const uri = `${normalizedBridgeUrl.replace(/\/$/, '')}/local-image?path=${encodeURIComponent(
53
+ normalizedPath
54
+ )}`;
55
+ const token = bridgeToken?.trim();
56
+
57
+ return token
58
+ ? {
59
+ uri,
60
+ headers: {
61
+ Authorization: `Bearer ${token}`,
62
+ },
63
+ }
64
+ : { uri };
65
+ }
66
+
67
+ function normalizeLocalPath(rawPath: string): string | null {
68
+ let normalizedPath = rawPath.trim();
69
+ if (!normalizedPath) {
70
+ return null;
71
+ }
72
+
73
+ normalizedPath = normalizedPath.replace(/\\/g, '/');
74
+
75
+ try {
76
+ normalizedPath = decodeURI(normalizedPath);
77
+ } catch {
78
+ // Keep original path when URI decoding fails.
79
+ }
80
+
81
+ if (WINDOWS_ABSOLUTE_PATH_PATTERN.test(normalizedPath) && !normalizedPath.startsWith('/')) {
82
+ normalizedPath = `/${normalizedPath}`;
83
+ }
84
+
85
+ return normalizedPath;
86
+ }
@@ -0,0 +1,109 @@
1
+ import type { AccountRateLimitSnapshot, AccountRateLimitWindow } from '../api/types';
2
+
3
+ export type ComposerUsageLimitTone = 'neutral' | 'warning' | 'critical';
4
+
5
+ export interface ComposerUsageLimitBadgeModel {
6
+ id: 'primary' | 'secondary';
7
+ label: string;
8
+ remainingPercent: number;
9
+ tone: ComposerUsageLimitTone;
10
+ }
11
+
12
+ export function buildComposerUsageLimitBadges(
13
+ snapshot: AccountRateLimitSnapshot | null
14
+ ): ComposerUsageLimitBadgeModel[] {
15
+ if (!snapshot) {
16
+ return [];
17
+ }
18
+
19
+ const badges: ComposerUsageLimitBadgeModel[] = [];
20
+ const primary = toComposerUsageLimitBadge('primary', snapshot.primary, snapshot);
21
+ if (primary) {
22
+ badges.push(primary);
23
+ }
24
+
25
+ const secondary = toComposerUsageLimitBadge('secondary', snapshot.secondary, snapshot);
26
+ if (secondary) {
27
+ badges.push(secondary);
28
+ }
29
+
30
+ return badges;
31
+ }
32
+
33
+ export function formatComposerUsageLimitLabel(windowDurationMins: number | null): string {
34
+ if (windowDurationMins === null || windowDurationMins <= 0) {
35
+ return 'limit';
36
+ }
37
+
38
+ if (windowDurationMins === 300) {
39
+ return '5h';
40
+ }
41
+
42
+ if (windowDurationMins === 10_080) {
43
+ return 'weekly';
44
+ }
45
+
46
+ if (windowDurationMins < 60) {
47
+ return `${String(windowDurationMins)}m`;
48
+ }
49
+
50
+ if (windowDurationMins < 1_440) {
51
+ return `${String(Math.round(windowDurationMins / 60))}h`;
52
+ }
53
+
54
+ return `${String(Math.round(windowDurationMins / 1_440))}d`;
55
+ }
56
+
57
+ function toComposerUsageLimitBadge(
58
+ id: ComposerUsageLimitBadgeModel['id'],
59
+ window: AccountRateLimitWindow | null,
60
+ snapshot: AccountRateLimitSnapshot
61
+ ): ComposerUsageLimitBadgeModel | null {
62
+ if (!window) {
63
+ return null;
64
+ }
65
+
66
+ const remainingPercent = clampPercent(100 - window.usedPercent);
67
+ return {
68
+ id,
69
+ label: resolveComposerUsageLimitLabel(id, window.windowDurationMins, snapshot),
70
+ remainingPercent,
71
+ tone:
72
+ remainingPercent <= 10
73
+ ? 'critical'
74
+ : remainingPercent <= 25
75
+ ? 'warning'
76
+ : 'neutral',
77
+ };
78
+ }
79
+
80
+ function resolveComposerUsageLimitLabel(
81
+ id: ComposerUsageLimitBadgeModel['id'],
82
+ windowDurationMins: number | null,
83
+ snapshot: AccountRateLimitSnapshot
84
+ ): string {
85
+ const explicitLabel = formatComposerUsageLimitLabel(windowDurationMins);
86
+ if (explicitLabel !== 'limit') {
87
+ return explicitLabel;
88
+ }
89
+
90
+ const normalizedLimitId = snapshot.limitId?.trim().toLowerCase() ?? null;
91
+ const hasPrimary = Boolean(snapshot.primary);
92
+ const hasSecondary = Boolean(snapshot.secondary);
93
+ const looksLikeDefaultCodexPair =
94
+ hasPrimary && hasSecondary && (!normalizedLimitId || normalizedLimitId === 'codex');
95
+
96
+ if (looksLikeDefaultCodexPair) {
97
+ return id === 'primary' ? '5h' : 'weekly';
98
+ }
99
+
100
+ return 'limit';
101
+ }
102
+
103
+ function clampPercent(value: number): number {
104
+ if (!Number.isFinite(value)) {
105
+ return 0;
106
+ }
107
+
108
+ return Math.max(0, Math.min(100, Math.round(value)));
109
+ }
@@ -0,0 +1,46 @@
1
+ export const VOICE_WAVEFORM_BAR_COUNT = 28;
2
+ export const VOICE_WAVEFORM_SAMPLE_INTERVAL_MS = 80;
3
+
4
+ const METERING_FLOOR_DB = -55;
5
+
6
+ export function createVoiceWaveformSeed(barCount: number): number[] {
7
+ return Array.from({ length: Math.max(1, barCount) }, () => 0);
8
+ }
9
+
10
+ export function appendVoiceWaveformSample(
11
+ previousSamples: number[],
12
+ nextSample: number,
13
+ maxBars: number
14
+ ): number[] {
15
+ const trimmedSamples = previousSamples.slice(-(Math.max(1, maxBars) - 1));
16
+ return [...trimmedSamples, clampWaveformLevel(nextSample)];
17
+ }
18
+
19
+ export function normalizeVoiceMetering(metering: number | null | undefined): number {
20
+ if (typeof metering !== 'number' || Number.isNaN(metering)) {
21
+ return 0;
22
+ }
23
+
24
+ const clampedMetering = Math.max(METERING_FLOOR_DB, Math.min(0, metering));
25
+ const normalizedLevel = (clampedMetering - METERING_FLOOR_DB) / Math.abs(METERING_FLOOR_DB);
26
+ return clampWaveformLevel(Math.pow(normalizedLevel, 0.65));
27
+ }
28
+
29
+ export function fallbackVoiceWaveformLevel(step: number): number {
30
+ const oscillation = Math.abs(Math.sin(step / 2.35));
31
+ return clampWaveformLevel(0.12 + oscillation * 0.24);
32
+ }
33
+
34
+ export function formatVoiceRecordingDuration(durationMillis: number): string {
35
+ const totalSeconds = Math.max(0, Math.floor(durationMillis / 1_000));
36
+ const minutes = Math.floor(totalSeconds / 60);
37
+ const seconds = totalSeconds % 60;
38
+ return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
39
+ }
40
+
41
+ function clampWaveformLevel(level: number): number {
42
+ if (!Number.isFinite(level)) {
43
+ return 0;
44
+ }
45
+ return Math.max(0, Math.min(1, level));
46
+ }
@@ -1,5 +1,10 @@
1
1
  import { isInsecureRemoteUrl, normalizeBridgeUrlInput } from './bridgeUrl';
2
2
 
3
+ const defaultPrivacyPolicyUrl =
4
+ 'https://mohit-patil.github.io/clawdex-mobile/privacy/';
5
+ const defaultTermsOfServiceUrl =
6
+ 'https://mohit-patil.github.io/clawdex-mobile/terms/';
7
+
3
8
  const legacyHostBridgeUrl = normalizeBridgeUrlInput(
4
9
  process.env.EXPO_PUBLIC_HOST_BRIDGE_URL ??
5
10
  process.env.EXPO_PUBLIC_MAC_BRIDGE_URL ??
@@ -15,8 +20,10 @@ const allowWsQueryTokenAuth =
15
20
  const allowInsecureRemoteBridge =
16
21
  process.env.EXPO_PUBLIC_ALLOW_INSECURE_REMOTE_BRIDGE?.trim().toLowerCase() ===
17
22
  'true';
18
- const privacyPolicyUrl = process.env.EXPO_PUBLIC_PRIVACY_POLICY_URL?.trim() || null;
19
- const termsOfServiceUrl = process.env.EXPO_PUBLIC_TERMS_OF_SERVICE_URL?.trim() || null;
23
+ const privacyPolicyUrl =
24
+ process.env.EXPO_PUBLIC_PRIVACY_POLICY_URL?.trim() || defaultPrivacyPolicyUrl;
25
+ const termsOfServiceUrl =
26
+ process.env.EXPO_PUBLIC_TERMS_OF_SERVICE_URL?.trim() || defaultTermsOfServiceUrl;
20
27
  const externalStatusFullSyncDebounceMs = parseNonNegativeIntEnv(
21
28
  process.env.EXPO_PUBLIC_EXTERNAL_STATUS_FULL_SYNC_DEBOUNCE_MS,
22
29
  450
@@ -6,6 +6,7 @@ import {
6
6
  requestRecordingPermissionsAsync,
7
7
  setAudioModeAsync,
8
8
  useAudioRecorder,
9
+ useAudioRecorderState,
9
10
  } from 'expo-audio';
10
11
  import * as FileSystem from 'expo-file-system/legacy';
11
12
  import { useCallback, useEffect, useRef, useState } from 'react';
@@ -30,9 +31,10 @@ interface UseVoiceRecorderOptions {
30
31
  const MIN_RECORDING_DURATION_MS = 1_000;
31
32
  const MAX_RECORDING_FILE_BYTES = 20 * 1024 * 1024;
32
33
  const MAX_RECORDING_FILE_MB = MAX_RECORDING_FILE_BYTES / (1024 * 1024);
34
+ const RECORDER_STATE_POLL_INTERVAL_MS = 80;
33
35
 
34
36
  const RECORDING_OPTIONS: RecordingOptions = {
35
- isMeteringEnabled: false,
37
+ isMeteringEnabled: true,
36
38
  extension: '.m4a',
37
39
  sampleRate: 16_000,
38
40
  numberOfChannels: 1,
@@ -119,6 +121,7 @@ export function useVoiceRecorder({
119
121
  }: UseVoiceRecorderOptions) {
120
122
  const [voiceState, setVoiceState] = useState<VoiceState>('idle');
121
123
  const recorder = useAudioRecorder(RECORDING_OPTIONS);
124
+ const recorderState = useAudioRecorderState(recorder, RECORDER_STATE_POLL_INTERVAL_MS);
122
125
  const startTimeRef = useRef<number>(0);
123
126
  const recorderRef = useRef<AudioRecorder>(recorder);
124
127
  recorderRef.current = recorder;
@@ -256,6 +259,10 @@ export function useVoiceRecorder({
256
259
 
257
260
  return {
258
261
  voiceState,
262
+ recordingDurationMillis:
263
+ voiceState === 'recording' ? recorderState.durationMillis : 0,
264
+ recordingMetering:
265
+ voiceState === 'recording' ? recorderState.metering ?? null : null,
259
266
  startRecording,
260
267
  stopRecordingAndTranscribe,
261
268
  cancelRecording,