Skip to content
Merged
8 changes: 8 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc
import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen";
import { SettingsT3ConnectRouteScreen } from "./features/settings/SettingsT3ConnectRouteScreen";
import { UsageRouteScreen } from "./features/usage/UsageRouteScreen";
import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";
import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator";
Expand Down Expand Up @@ -155,6 +156,13 @@ const SettingsContentStack = createNativeStackNavigator({
title: "Environments",
},
}),
SettingsT3Connect: createNativeStackScreen({
screen: SettingsT3ConnectRouteScreen,
linking: "t3-connect",
options: {
title: "T3 Connect",
},
}),
SettingsEnvironmentNew: createNativeStackScreen({
screen: ConnectionsNewRouteScreen,
linking: "environment-new",
Expand Down
22 changes: 22 additions & 0 deletions apps/mobile/src/features/cloud/managedRelayState.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import { useAtomValue } from "@effect/atom-react";
import {
createManagedRelayQueryManager,
deregisterManagedRelayEnvironment,
managedRelaySessionAtom,
readManagedRelaySnapshotState,
} from "@t3tools/client-runtime/relay";
import {
createAtomCommandScheduler,
createRuntimeCommand,
} from "@t3tools/client-runtime/state/runtime";
import type {
RelayClientEnvironmentRecord,
RelayEnvironmentStatusResponse,
} from "@t3tools/contracts/relay";
import type { EnvironmentId } from "@t3tools/contracts";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
import { useCallback, useEffect } from "react";

Expand All @@ -22,6 +28,22 @@ export const managedRelayQueryManager = createManagedRelayQueryManager(managedRe
cloudDebugLog(`query:${event.operation}:${event.stage}:${event.phase}`, { ...event }),
});

const managedRelayMutationScheduler = createAtomCommandScheduler();

export const deregisterManagedRelayEnvironmentCommand = createRuntimeCommand(
managedRelayAtomRuntime,
{
label: "mobile:managed-relay:deregister-environment",
scheduler: managedRelayMutationScheduler,
concurrency: {
mode: "serial",
key: (input: { readonly accountId: string; readonly environmentId: EnvironmentId }) =>
input.accountId,
},
execute: (input, registry) => deregisterManagedRelayEnvironment(registry, input),
},
);

const EMPTY_ENVIRONMENTS_ATOM = Atom.make(
AsyncResult.success<ReadonlyArray<RelayClientEnvironmentRecord>>([]),
).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:mobile:environments:null"));
Expand Down
18 changes: 18 additions & 0 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,18 @@ function ConfiguredSettingsRouteScreen() {
navigation.navigate("SettingsSheet", { screen: "SettingsAuth" });
}, [isLoaded, navigation]);

const openT3ConnectAccount = useCallback(() => {
if (!isLoaded) return;
if (!isSignedIn) {
navigation.navigate("SettingsSheet", { screen: "SettingsAuth" });
return;
}
navigation.navigate("SettingsSheet", {
screen: "SettingsContent",
params: { screen: "SettingsT3Connect" },
});
}, [isLoaded, isSignedIn, navigation]);

return (
<View collapsable={false} className="flex-1 bg-sheet">
<ScrollView
Expand All @@ -456,6 +468,12 @@ function ConfiguredSettingsRouteScreen() {
value={accountLabel}
onPress={openAccount}
/>
<SettingsRow
icon="server.rack"
label="T3 Connect"
value={isSignedIn ? "Manage" : "Sign in"}
onPress={openT3ConnectAccount}
/>
</SettingsSection>
<Text className="px-2 text-sm text-foreground-muted">
T3 Code works locally without signing in. Cloud features are optional.
Expand Down
232 changes: 232 additions & 0 deletions apps/mobile/src/features/settings/SettingsT3ConnectRouteScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import { findErrorTraceId } from "@t3tools/client-runtime/errors";
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import type { EnvironmentId } from "@t3tools/contracts";
import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
import { useAuth } from "@clerk/clerk-expo";
import { useNavigation } from "@react-navigation/native";
import { useRef, useState } from "react";
import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
import { AppText as Text } from "../../components/AppText";
import { showConfirmDialog } from "../../components/ConfirmDialogHost";
import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
import { NativeStackScreenOptions } from "../../native/StackHeader";
import {
deregisterManagedRelayEnvironmentCommand,
useManagedRelayEnvironments,
} from "../cloud/managedRelayState";
import { useAtomCommand } from "../../state/use-atom-command";

const linkedAtFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" });

function linkedAtLabel(value: string): string {
const linkedAt = new Date(value);
return Number.isNaN(linkedAt.getTime())
? "Link date unavailable"
: `Linked ${linkedAtFormatter.format(linkedAt)}`;
}

function endpointLabel(environment: RelayClientEnvironmentRecord): string {
return environment.endpoint.providerKind === "cloudflare_tunnel"
? "Managed tunnel"
: "Activity publishing only";
}

export function SettingsT3ConnectRouteScreen() {
const { isLoaded: isAuthLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
Comment thread
StiensWout marked this conversation as resolved.
Outdated
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const environmentsState = useManagedRelayEnvironments();
const deregisterEnvironment = useAtomCommand(deregisterManagedRelayEnvironmentCommand, {
reportFailure: false,
});
const mutationPendingRef = useRef(false);
const [deregisteringEnvironmentId, setDeregisteringEnvironmentId] =
useState<EnvironmentId | null>(null);
const [removedEnvironments, setRemovedEnvironments] = useState<{
readonly accountId: string | null;
readonly linkedAtById: ReadonlyMap<EnvironmentId, string>;
}>({ accountId: null, linkedAtById: new Map() });

const deregister = async (environment: RelayClientEnvironmentRecord) => {
const accountId = environmentsState.accountId;
if (!accountId || mutationPendingRef.current) return;

mutationPendingRef.current = true;
setDeregisteringEnvironmentId(environment.environmentId);
const result = await deregisterEnvironment({
accountId,
environmentId: environment.environmentId,
});
mutationPendingRef.current = false;
setDeregisteringEnvironmentId(null);

if (result._tag === "Success") {
setRemovedEnvironments((current) => {
const linkedAtById = new Map(current.accountId === accountId ? current.linkedAtById : []);
linkedAtById.set(environment.environmentId, environment.linkedAt);
return { accountId, linkedAtById };
});
environmentsState.refresh();
Alert.alert(
"Server deregistered",
`${environment.label} no longer has T3 Connect access. A host space is now available.`,
);
return;
}
if (isAtomCommandInterrupted(result)) return;

const cause = squashAtomCommandFailure(result);
const message = cause instanceof Error ? cause.message : "Could not deregister the server.";
const traceId = findErrorTraceId(cause);
console.error("[t3-connect] Could not deregister environment", {
environmentId: environment.environmentId,
message,
traceId,
cause,
});
Alert.alert(
"Could not deregister server",
message,
traceId
? [
{ text: "Dismiss", style: "cancel" },
{
text: "Copy trace ID",
onPress: () => copyTextWithHaptic(traceId, { target: "trace ID" }),
},
]
: undefined,
);
};

const confirmDeregister = (environment: RelayClientEnvironmentRecord) => {
const title = `Deregister “${environment.label}”?`;
const message =
"This revokes this server’s T3 Connect access, removes any managed tunnel, and frees a host space. Local connections on your devices are not changed.";
const onConfirm = () => void deregister(environment);
if (Platform.OS === "ios") {
Alert.alert(title, message, [
{ text: "Cancel", style: "cancel" },
{ text: "Deregister", style: "destructive", onPress: onConfirm },
]);
return;
}
showConfirmDialog({
title,
message,
confirmText: "Deregister",
destructive: true,
onConfirm,
});
};

const removedEnvironmentLinkedAt =
removedEnvironments.accountId === environmentsState.accountId
? removedEnvironments.linkedAtById
: new Map<EnvironmentId, string>();
const environments = (environmentsState.data ?? []).filter(
(environment) =>
removedEnvironmentLinkedAt.get(environment.environmentId) !== environment.linkedAt,
);
const isSignedOut = isAuthLoaded && !isSignedIn;
const isAccountLoading = !isSignedOut && environmentsState.accountId === null;
const isInitialLoad =
environmentsState.accountId !== null &&
environmentsState.data === null &&
!environmentsState.error;

return (
<View collapsable={false} className="flex-1 bg-sheet">
{Platform.OS === "android" ? (
<>
<NativeStackScreenOptions options={{ headerShown: false }} />
<AndroidScreenHeader title="T3 Connect" onBack={() => navigation.goBack()} />
</>
) : null}
<ScrollView
contentInsetAdjustmentBehavior="automatic"
showsVerticalScrollIndicator={false}
className="flex-1"
contentContainerClassName="px-5 pt-4"
contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
refreshControl={
environmentsState.accountId ? (
<RefreshControl
refreshing={environmentsState.isPending}
onRefresh={environmentsState.refresh}
/>
) : undefined
}
>
<View className="gap-1 pb-5">
<Text className="text-lg font-t3-bold text-foreground">Account environments</Text>
<Text className="text-sm leading-normal text-foreground-muted">
Servers registered to your account. Connections on this device stay in Environments.
</Text>
</View>

{environmentsState.error ? (
Comment thread
StiensWout marked this conversation as resolved.
Outdated
<View className="border-y border-danger-border py-4">
<Text className="text-base font-t3-medium text-danger-foreground">
Could not load T3 Connect environments
</Text>
<Text className="mt-1 text-sm text-foreground-muted">{environmentsState.error}</Text>
</View>
) : null}

{isSignedOut ? (
<Text className="border-y border-border py-6 text-sm text-foreground-muted">
Sign in to T3 Connect to manage account environments.
</Text>
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
) : isAccountLoading || isInitialLoad ? (
<Text className="border-y border-border py-6 text-sm text-foreground-muted">
Loading environments…
</Text>
) : environments.length > 0 ? (
<View>
{environments.map((environment, index) => (
<View
key={environment.environmentId}
className={index === 0 ? "py-4" : "border-t border-border py-4"}
>
<View className="flex-row items-center gap-4">
<View className="min-w-0 flex-1">
<Text className="text-base font-t3-medium text-foreground" numberOfLines={1}>
{environment.label}
</Text>
<Text className="mt-1 text-sm text-foreground-muted" numberOfLines={1}>
{linkedAtLabel(environment.linkedAt)} · {endpointLabel(environment)}
</Text>
</View>
<Pressable
accessibilityLabel={`Deregister ${environment.label}`}
accessibilityRole="button"
disabled={deregisteringEnvironmentId !== null}
className="px-2 py-2 disabled:opacity-40"
onPress={() => confirmDeregister(environment)}
>
<Text className="font-t3-medium text-danger-foreground">
{deregisteringEnvironmentId === environment.environmentId
? "Deregistering…"
: "Deregister"}
</Text>
</Pressable>
</View>
</View>
))}
</View>
) : environmentsState.error ? null : (
<Text className="border-y border-border py-6 text-sm text-foreground-muted">
No environments are registered to this T3 Connect account.
</Text>
)}
</ScrollView>
</View>
);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export type SettingsSheetTarget =
| "SettingsEnvironments"
| "SettingsT3Connect"
| "SettingsArchive"
| "SettingsAppearance"
| "SettingsProjectGrouping"
Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/cloud/managedRelayState.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import { useAtomValue } from "@effect/atom-react";
import {
createManagedRelayQueryManager,
deregisterManagedRelayEnvironment,
ManagedRelay,
managedRelaySessionAtom,
readManagedRelaySnapshotState,
} from "@t3tools/client-runtime/relay";
import {
createAtomCommandScheduler,
createRuntimeCommand,
} from "@t3tools/client-runtime/state/runtime";
import type {
RelayClientDeviceRecord,
RelayClientEnvironmentRecord,
} from "@t3tools/contracts/relay";
import type { EnvironmentId } from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand All @@ -29,6 +35,22 @@ const managedRelayAtomRuntime = Atom.runtime(

export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime);

const managedRelayMutationScheduler = createAtomCommandScheduler();

export const deregisterManagedRelayEnvironmentCommand = createRuntimeCommand(
managedRelayAtomRuntime,
{
label: "web:managed-relay:deregister-environment",
scheduler: managedRelayMutationScheduler,
concurrency: {
mode: "serial",
key: (input: { readonly accountId: string; readonly environmentId: EnvironmentId }) =>
input.accountId,
},
execute: (input, registry) => deregisterManagedRelayEnvironment(registry, input),
},
);

const EMPTY_ENVIRONMENTS_ATOM = Atom.make(
AsyncResult.success<ReadonlyArray<RelayClientEnvironmentRecord>>([]),
).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:web:environments:null"));
Expand Down
Loading
Loading