Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"effect": "catalog:",
"expo": "~56.0.12",
"expo-asset": "~56.0.17",
"expo-audio": "~56.0.12",
"expo-auth-session": "~56.0.14",
"expo-blur": "~56.0.3",
"expo-build-properties": "~56.0.19",
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ConfirmDialogHost } from "./components/ConfirmDialogHost";
import { MobilePolicyNotice } from "./features/settings/MobilePolicyNotice";
import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene";
import { IncomingShareProvider } from "./features/sharing/IncomingShareProvider";
import { ReplyPlaybackProvider } from "./features/replyPlayback/ReplyPlaybackProvider";
import {
AppearancePreferencesProvider,
useAppearancePreferences,
Expand Down Expand Up @@ -62,7 +63,9 @@ export default function App() {
return (
<RegistryContext.Provider value={appAtomRegistry}>
<AppearancePreferencesProvider>
<AppContent />
<ReplyPlaybackProvider>
<AppContent />
</ReplyPlaybackProvider>
</AppearancePreferencesProvider>
</RegistryContext.Provider>
);
Expand Down
176 changes: 176 additions & 0 deletions apps/mobile/src/features/replyPlayback/ReplyPlaybackControls.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { Children, isValidElement, type ReactNode } from "react";
import { describe, expect, it, vi } from "vite-plus/test";
import {
createReplyPlaybackController,
createReplyReadoutPreference,
type ReplyPlaybackRequest,
} from "@t3tools/client-runtime/reply-playback";

vi.mock("react", async (importOriginal) => ({
...(await importOriginal<typeof import("react")>()),
useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) => snapshot(),
}));
vi.mock("react-native", () => ({ View: "View", Pressable: "Pressable" }));
vi.mock("../../components/AppText", () => ({ AppText: "AppText" }));
vi.mock("../../components/ThemedSwitch", () => ({ ThemedSwitch: "ThemedSwitch" }));

import { ReplyPlaybackControls } from "./ReplyPlaybackControls";
import { ReplyReadoutPreference } from "./ReplyReadoutPreference";

type ElementProps = {
children?: ReactNode;
accessibilityLabel?: string;
accessibilityRole?: string;
accessibilityState?: { checked?: boolean; disabled?: boolean; busy?: boolean };
accessibilityHint?: string;
disabled?: boolean;
value?: boolean;
onPress?: () => void;
onValueChange?: (value: boolean) => void;
};
function elements(node: ReactNode): ElementProps[] {
return Children.toArray(node).flatMap((child) =>
isValidElement<ElementProps>(child) ? [child.props, ...elements(child.props.children)] : [],
);
}
function named(node: ReactNode, label: string) {
const found = elements(node).find((props) => props.accessibilityLabel === label);
expect(found, label).toBeDefined();
return found!;
}
const request: ReplyPlaybackRequest = {
identity: {
environmentId: "remote",
threadId: "chat",
messageId: "reply",
contentVersion: "v1",
provider: "speech",
voice: "voice",
},
text: "A completed reply",
automatic: false,
};
function setup() {
const audio = { play: vi.fn(async () => {}), pause: vi.fn(), dispose: vi.fn() };
const prepare = vi.fn(async () => audio);
const controller = createReplyPlaybackController(prepare);
controller.setContext({ ...request.identity, connected: true, mediaBlocked: false });
return {
controller,
audio,
prepare,
render: () => ReplyPlaybackControls({ controller, request }),
};
}

describe("native reply playback controls", () => {
it("exposes named buttons, pauses, resumes, and stops through the shared controller", async () => {
const { controller, render, audio } = setup();
expect(named(render(), "Read aloud").accessibilityRole).toBe("button");
await controller.start(request);
named(render(), "Pause readout").onPress?.();
expect(controller.getSnapshot().status).toBe("paused");
expect(audio.pause).toHaveBeenCalledOnce();
const resume = vi.spyOn(controller, "resume");
named(render(), "Resume readout").onPress?.();
await resume.mock.results[0]!.value;
expect(controller.getSnapshot().status).toBe("playing");
named(render(), "Stop readout").onPress?.();
expect(controller.getSnapshot().status).toBe("idle");
expect(audio.dispose).toHaveBeenCalledOnce();
});

it("marks loading as busy while retaining a usable Stop button", async () => {
const { controller, render } = setup();
const starting = controller.start(request);
expect(named(render(), "Preparing audio").accessibilityState).toEqual({
disabled: true,
busy: true,
});
named(render(), "Stop readout").onPress?.();
await starting;
expect(controller.getSnapshot().status).toBe("idle");
});

it("starts manual readout and does not pause a different reply", async () => {
const { controller } = setup();
await controller.start({ ...request, identity: { ...request.identity, messageId: "other" } });
const start = vi.spyOn(controller, "start");
const tree = ReplyPlaybackControls({ controller, request: { ...request, automatic: true } });
named(tree, "Read aloud").onPress?.();
expect(start).toHaveBeenCalledWith({ ...request, automatic: false });
await start.mock.results[0]!.value;
controller.dispose();
});

it("exposes unavailable reasons and service disclosure without starting playback", () => {
const { controller, prepare } = setup();
const tree = ReplyPlaybackControls({
controller,
request,
unavailableReason: "Connect to this environment",
disclosure: "Your speech service may charge for audio.",
});
const button = named(tree, "Read aloud");
expect(button.disabled).toBe(true);
expect(button.accessibilityHint).toBe("Connect to this environment");
button.onPress?.();
expect(prepare).not.toHaveBeenCalled();
expect(
elements(tree).some(
(props) => props.children === "Your speech service may charge for audio.",
),
).toBe(true);
});

it("announces a playback error and retries through the controller", async () => {
const { controller, render, audio } = setup();
audio.play.mockRejectedValueOnce(new Error("decoder"));
await controller.start(request);
expect(elements(render()).some((props) => props.accessibilityRole === "alert")).toBe(true);
const retry = vi.spyOn(controller, "retry");
named(render(), "Retry readout").onPress?.();
await retry.mock.results[0]!.value;
expect(controller.getSnapshot().status).toBe("playing");
controller.dispose();
});
});

describe("native reply readout preference", () => {
it("defaults off and exposes an accessible switch for both opt-in and opt-out", async () => {
const storage = { getItem: vi.fn(async () => null), setItem: vi.fn(async () => {}) };
const disabled = vi.fn();
const preference = createReplyReadoutPreference(storage, disabled);
const render = () => ReplyReadoutPreference({ preference });
const label = "Automatically read new replies in this chat on this device";
const toggle = named(render(), label);
expect(toggle.accessibilityRole).toBe("switch");
expect(toggle.accessibilityState).toEqual({ checked: false });
const setEnabled = vi.spyOn(preference, "setEnabled");
toggle.onValueChange?.(true);
await setEnabled.mock.results[0]!.value;
expect(named(render(), label).value).toBe(true);
named(render(), label).onValueChange?.(false);
await setEnabled.mock.results[1]!.value;
expect(named(render(), label).value).toBe(false);
expect(disabled).toHaveBeenCalledOnce();
});

it("announces failed persistence without hiding the current local choice", async () => {
const preference = createReplyReadoutPreference(
{
getItem: async () => null,
setItem: async () => {
throw new Error("storage");
},
},
() => {},
);
await preference.setEnabled(true);
const tree = ReplyReadoutPreference({ preference });
expect(elements(tree).some((props) => props.accessibilityRole === "alert")).toBe(true);
expect(named(tree, "Automatically read new replies in this chat on this device").value).toBe(
true,
);
});
});
93 changes: 93 additions & 0 deletions apps/mobile/src/features/replyPlayback/ReplyPlaybackControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { useSyncExternalStore } from "react";
import { Pressable, View } from "react-native";
import {
sameReplyPlaybackIdentity,
type ReplyPlaybackController,
type ReplyPlaybackRequest,
} from "@t3tools/client-runtime/reply-playback";

import { AppText } from "../../components/AppText";

export interface ReplyPlaybackControlsProps {
readonly controller: ReplyPlaybackController;
readonly request: ReplyPlaybackRequest;
readonly unavailableReason?: string;
readonly disclosure?: string;
}

export function ReplyPlaybackControls({
controller,
request,
unavailableReason,
disclosure,
}: ReplyPlaybackControlsProps) {
const snapshot = useSyncExternalStore(
controller.subscribe,
controller.getSnapshot,
controller.getSnapshot,
);
const state =
snapshot.status !== "idle" && sameReplyPlaybackIdentity(snapshot.identity, request.identity)
? snapshot.status
: "idle";
const label =
state === "loading"
? "Preparing audio"
: state === "playing"
? "Pause readout"
: state === "paused"
? "Resume readout"
: state === "error"
? "Retry readout"
: "Read aloud";
const disabled = Boolean(unavailableReason) || state === "loading";
const activate = () => {
if (disabled) return;
if (state === "playing") controller.pause();
else if (state === "paused") void controller.resume();
else if (state === "error") void controller.retry();
else void controller.start({ ...request, automatic: false });
};
return (
<View className="gap-1">
<View className="flex-row flex-wrap items-center gap-2">
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint={unavailableReason ?? disclosure}
accessibilityState={{ disabled, busy: state === "loading" }}
disabled={disabled}
onPress={activate}
className="min-h-11 min-w-11 items-center justify-center rounded-xl bg-subtle px-3"
>
<AppText className="text-sm">{label}</AppText>
</Pressable>
{state === "loading" || state === "playing" || state === "paused" ? (
<Pressable
accessibilityRole="button"
accessibilityLabel="Stop readout"
onPress={controller.stop}
className="min-h-11 min-w-11 items-center justify-center rounded-xl bg-subtle px-3"
>
<AppText className="text-sm">Stop</AppText>
</Pressable>
) : null}
</View>
{state === "error" ? (
<AppText
accessibilityRole="alert"
accessibilityLiveRegion="polite"
className="text-sm text-muted-foreground"
>
Audio could not play. Retry, or check voice settings.
</AppText>
) : null}
{disclosure ? (
<AppText className="text-sm text-muted-foreground">{disclosure}</AppText>
) : null}
{unavailableReason ? (
<AppText className="text-sm text-muted-foreground">{unavailableReason}</AppText>
) : null}
</View>
);
}
36 changes: 36 additions & 0 deletions apps/mobile/src/features/replyPlayback/ReplyPlaybackProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { createContext, useContext, useEffect, useMemo, type ReactNode } from "react";
import {
createReplyPlaybackSession,
type ReplyPlaybackSession,
} from "@t3tools/client-runtime/reply-playback";
import * as SecureStore from "expo-secure-store";

const ReplyPlaybackContext = createContext<ReplyPlaybackSession | null>(null);

export function ReplyPlaybackProvider({ children }: { readonly children: ReactNode }) {
const session = useMemo(
() =>
createReplyPlaybackSession({
storage: {
getItem: async (key) => SecureStore.getItemAsync(key),
setItem: async (key, value) => {
await SecureStore.setItemAsync(key, value);
},
},
prepare: async (_request, signal) => {
if (signal.aborted) throw new Error("Cancelled.");
throw new Error("Stored-reply speech is not connected yet.");
},
}),
[],
);
useEffect(() => {
void session.preference.load();
return () => session.dispose();
}, [session]);
return <ReplyPlaybackContext.Provider value={session}>{children}</ReplyPlaybackContext.Provider>;
}

export function useOptionalReplyPlayback() {
return useContext(ReplyPlaybackContext);
}
44 changes: 44 additions & 0 deletions apps/mobile/src/features/replyPlayback/ReplyReadoutPreference.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useSyncExternalStore } from "react";
import { View } from "react-native";
import type { ReplyReadoutPreference as Preference } from "@t3tools/client-runtime/reply-playback";

import { AppText } from "../../components/AppText";
import { ThemedSwitch } from "../../components/ThemedSwitch";

export function ReplyReadoutPreference({ preference }: { readonly preference: Preference }) {
const state = useSyncExternalStore(
preference.subscribe,
preference.getSnapshot,
preference.getSnapshot,
);
const label = "Automatically read new replies in this chat on this device";
return (
<View className="gap-2">
<View className="min-h-11 flex-row items-center gap-3">
<AppText className="flex-1 text-base">{label}</AppText>
<ThemedSwitch
accessibilityRole="switch"
accessibilityLabel={label}
accessibilityState={{ checked: state.enabled }}
value={state.enabled}
onValueChange={(enabled) => {
void preference.setEnabled(enabled);
}}
/>
</View>
<AppText className="text-sm text-muted-foreground">
Only new completed replies are read. History is never replayed. Your selected speech service
may charge for audio.
</AppText>
{state.persistenceError ? (
<AppText
accessibilityRole="alert"
accessibilityLiveRegion="polite"
className="text-sm text-muted-foreground"
>
This preference could not be saved on this device.
</AppText>
) : null}
</View>
);
}
Loading
Loading