Skip to content
Merged
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
86 changes: 86 additions & 0 deletions apps/web/src/components/clerk/ClerkUserProfilePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { RefreshCwIcon } from "lucide-react";
import type { ReactNode } from "react";

import { cn } from "../../lib/utils";
import { Button } from "../ui/button";

export function ClerkUserProfilePage({
action,
children,
className,
description,
title,
}: {
readonly action?: ReactNode;
readonly children: ReactNode;
readonly className?: string;
readonly description?: ReactNode;
readonly title: ReactNode;
}) {
return (
<div className={cn("w-full min-w-0 text-foreground", className)}>
<header className="flex flex-col gap-3 pb-4 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div className="min-w-0">
<h2 className="text-[1.0625rem] leading-6 font-semibold">{title}</h2>
{description ? (
<p className="mt-1 max-w-[34rem] text-[0.8125rem] leading-[1.125rem] text-muted-foreground sm:min-h-9">
{description}
</p>
) : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</header>

{children}
</div>
);
}

export function ClerkUserProfileRefreshButton({
className,
disabled = false,
isPending,
onClick,
}: {
readonly className?: string;
readonly disabled?: boolean;
readonly isPending: boolean;
readonly onClick: () => void;
}) {
return (
<Button
size="sm"
variant="outline"
className={cn("text-[0.8125rem]", className)}
disabled={disabled || isPending}
onClick={onClick}
>
<RefreshCwIcon aria-hidden="true" className={cn("size-3.5", isPending && "animate-spin")} />
Refresh
</Button>
);
}

export function ClerkUserProfileRow({
children,
className,
icon,
}: {
readonly children: ReactNode;
readonly className?: string;
readonly icon: ReactNode;
}) {
return (
<li className={cn("border-t py-4 first:border-t-0", className)}>
<div className="flex items-start gap-3">
<div
aria-hidden="true"
className="flex size-8 shrink-0 items-center justify-center rounded-lg border bg-muted/32 text-muted-foreground"
>
{icon}
</div>
<div className="min-w-0 flex-1">{children}</div>
</div>
</li>
);
}
115 changes: 52 additions & 63 deletions apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay";
import { RefreshCwIcon, SmartphoneIcon } from "lucide-react";
import { SmartphoneIcon } from "lucide-react";

import { useManagedRelayDevices } from "../../cloud/managedRelayState";
import { cn } from "../../lib/utils";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
Expand All @@ -12,6 +11,11 @@ import {
mobileClientPlatformLabel,
mobileClientUpdatedAtLabel,
} from "./MobileClientsUserProfilePage.logic";
import {
ClerkUserProfilePage,
ClerkUserProfileRefreshButton,
ClerkUserProfileRow,
} from "./ClerkUserProfilePage";

const MOBILE_CLIENT_SKELETON_ROWS = ["primary", "secondary"] as const;

Expand All @@ -31,53 +35,47 @@ function MobileClientStatusBadge({

function MobileClientRow({ device }: { readonly device: RelayClientDeviceRecord }) {
return (
<li className="rounded-xl border bg-card p-4 text-card-foreground shadow-sm/4">
<div className="flex items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border bg-muted/40 text-muted-foreground">
<SmartphoneIcon className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<div className="min-w-0">
<h3 className="truncate text-sm font-semibold text-foreground">{device.label}</h3>
<p className="text-xs text-muted-foreground">{mobileClientPlatformLabel(device)}</p>
</div>
<p className="shrink-0 text-[11px] text-muted-foreground/75">
{mobileClientUpdatedAtLabel(device.updatedAt)}
</p>
</div>
<div className="mt-3 flex flex-wrap gap-1.5">
<MobileClientStatusBadge
enabled={device.notifications.enabled}
label="Push notifications"
/>
<MobileClientStatusBadge
enabled={device.liveActivities.enabled}
label="Live Activities"
/>
</div>
<p className="mt-2 text-xs leading-relaxed text-muted-foreground/80">
{mobileClientNotificationDetail(device)}
<ClerkUserProfileRow icon={<SmartphoneIcon className="size-4" />}>
<div className="flex flex-col gap-0.5 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<div className="min-w-0">
<h3 className="truncate text-[0.8125rem] leading-[1.125rem] font-medium text-foreground">
{device.label}
</h3>
<p className="text-xs leading-[1.125rem] text-muted-foreground">
{mobileClientPlatformLabel(device)}
</p>
</div>
<p className="shrink-0 text-[0.6875rem] leading-4 text-muted-foreground/75">
{mobileClientUpdatedAtLabel(device.updatedAt)}
</p>
</div>
</li>
<div className="mt-2 flex flex-wrap gap-1.5">
<MobileClientStatusBadge
enabled={device.notifications.enabled}
label="Push notifications"
/>
<MobileClientStatusBadge enabled={device.liveActivities.enabled} label="Live Activities" />
</div>
<p className="mt-1.5 text-xs leading-[1.125rem] text-muted-foreground/80">
{mobileClientNotificationDetail(device)}
</p>
</ClerkUserProfileRow>
);
}

function MobileClientsSkeleton() {
return (
<div aria-label="Loading mobile clients" className="space-y-3" role="status">
<div aria-label="Loading mobile clients" className="divide-y border-t" role="status">
{MOBILE_CLIENT_SKELETON_ROWS.map((row) => (
<div key={row} className="rounded-xl border p-4">
<div key={row} className="py-4">
<div className="flex gap-3">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<Skeleton className="size-8 shrink-0 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-36" />
<Skeleton className="h-3.5 w-36" />
<Skeleton className="h-3 w-28" />
<div className="flex gap-2 pt-1">
<Skeleton className="h-5 w-28" />
<Skeleton className="h-5 w-24" />
<div className="flex gap-2">
<Skeleton className="h-4.5 w-28" />
<Skeleton className="h-4.5 w-24" />
</div>
</div>
</div>
Expand All @@ -89,13 +87,13 @@ function MobileClientsSkeleton() {

function EmptyMobileClients() {
return (
<Empty className="min-h-72 rounded-xl border border-dashed bg-muted/15">
<EmptyMedia variant="icon">
<Empty className="min-h-64 gap-4 border-t px-6 py-10 md:p-10">
<EmptyMedia className="mb-0" variant="icon">
<SmartphoneIcon />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>No mobile clients</EmptyTitle>
<EmptyDescription>
<EmptyTitle className="text-[1.0625rem] leading-6">No mobile clients</EmptyTitle>
<EmptyDescription className="text-[0.8125rem] leading-[1.125rem]">
Sign in to T3 Code on your iPhone to register it for push notifications and Live
Activities.
</EmptyDescription>
Expand All @@ -112,29 +110,20 @@ export function MobileClientsUserProfilePage() {
const hasErrorWithoutData = devicesState.error !== null && devicesState.data === null;

return (
<div className="flex min-h-[30rem] w-full flex-col bg-background text-foreground">
<header className="flex flex-col gap-4 border-b px-6 py-5 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-base font-semibold tracking-[-0.01em]">Mobile clients</h2>
<p className="mt-1 text-sm text-muted-foreground">
Devices registered to receive T3 Connect activity from your environments.
</p>
</div>
<Button
size="sm"
variant="outline"
disabled={devicesState.isPending}
<ClerkUserProfilePage
title="Mobile clients"
description="Devices registered to receive T3 Connect activity from your environments."
action={
<ClerkUserProfileRefreshButton
isPending={devicesState.isPending}
onClick={devicesState.refresh}
>
<RefreshCwIcon className={cn("size-3.5", devicesState.isPending && "animate-spin")} />
Refresh
</Button>
</header>

<div className="flex-1 p-6">
/>
}
>
<div>
{devicesState.error ? (
<div
className="mb-4 flex flex-col gap-3 rounded-lg border border-destructive/25 bg-destructive/5 p-3 text-sm sm:flex-row sm:items-center sm:justify-between"
className="mb-4 flex flex-col gap-3 border-t border-destructive/35 py-3 text-[0.8125rem] sm:flex-row sm:items-center sm:justify-between"
role="alert"
>
<div>
Expand All @@ -152,7 +141,7 @@ export function MobileClientsUserProfilePage() {
{isInitialLoad ? (
<MobileClientsSkeleton />
) : hasErrorWithoutData ? null : devices.length > 0 ? (
<ul className="space-y-3">
<ul className="border-t">
{devices.map((device) => (
<MobileClientRow key={device.deviceId} device={device} />
))}
Expand All @@ -161,6 +150,6 @@ export function MobileClientsUserProfilePage() {
<EmptyMobileClients />
)}
</div>
</div>
</ClerkUserProfilePage>
);
}
10 changes: 9 additions & 1 deletion apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { UserButton, useAuth } from "@clerk/react";
import { LogInIcon, SmartphoneIcon } from "lucide-react";
import { LogInIcon, ServerIcon, SmartphoneIcon } from "lucide-react";

import { hasCloudPublicConfig } from "../../cloud/publicConfig";
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar";
import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage";
import { T3ConnectUserProfilePage } from "./T3ConnectUserProfilePage";
import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt";

export function T3ConnectSidebarSignIn() {
Expand Down Expand Up @@ -39,6 +40,13 @@ function ConfiguredT3ConnectSidebarAvatar() {
>
<MobileClientsUserProfilePage />
</UserButton.UserProfilePage>
<UserButton.UserProfilePage
label="T3 Connect"
labelIcon={<ServerIcon className="size-4" />}
url="t3-connect"
>
<T3ConnectUserProfilePage />
</UserButton.UserProfilePage>
</UserButton>
);
}
Expand Down
Loading
Loading