import { useAtomValue } from "@effect/atom-react"; import { type AtomCommandResult, isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { AuthOrchestrationOperateScope, type ChatAttachment, type ScopedThreadRef, type ThreadGuestMessage, type ThreadGuestShare, } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; import { type ReactNode, useEffect, useMemo, useState } from "react"; import { View } from "react-native"; import { Button } from "../../components/ui/Button"; import { AppText as Text } from "../../components/ui/AppText"; import { SymbolView } from "../../components/ui/AppSymbol"; import * as Dialog from "../../components/ui/Dialog"; import { useEnvironmentScope } from "../../state/session"; import { environmentThreadDetails, threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; import { GUEST_PENDING_BUBBLE_CLASS_NAME, GuestMessageName } from "./guestMessageStyle"; const NO_GUEST_SHARE_ATOM = Atom.make(null); function guestShareAtom(ref: ScopedThreadRef) { let previous: ThreadGuestShare | null = null; return Atom.make((get) => { const next = get(environmentThreadDetails.threadAtom(ref))?.projection.thread.guestShare ?? null; // The thread record is replaced on unrelated updates; only share changes matter here. if (JSON.stringify(next) !== JSON.stringify(previous)) previous = next; return previous; }); } export function useThreadGuestShare(threadRef: ScopedThreadRef | null): ThreadGuestShare | null { const shareAtom = useMemo( () => (threadRef === null ? NO_GUEST_SHARE_ATOM : guestShareAtom(threadRef)), [threadRef], ); return useAtomValue(shareAtom); } function alertOnFailure(failureTitle: string, result: AtomCommandResult) { if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); Dialog.alert(failureTitle, error instanceof Error ? error.message : "Try again."); } } /** * Owner controls for a guest link, above the composer: who the link is for and a way to stop * sharing. Links are created from the thread's menu, see `ThreadGuestShareSheet`. Waiting * messages show in the feed, see `ThreadGuestPendingMessage`. */ export function ThreadGuestShareBar(props: { readonly threadRef: ScopedThreadRef | null; readonly canOperateThread: boolean; }) { const { threadRef } = props; const share = useThreadGuestShare(threadRef); const closeGuestShare = useAtomCommand(threadEnvironment.closeGuestShare, "stop guest sharing"); const [busy, setBusy] = useState(false); // The server keeps an expired link so waiting messages stay resolvable; the label flips when // it expires. // Keyed by the expiry, so a replaced link (even one already expired) gets its own timer. const [expiredAt, setExpiredAt] = useState(null); const expiresAt = share?.expiresAt; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- arms a timer that flips the share label when the guest link expires useEffect(() => { if (expiresAt === undefined) return; const timer = setTimeout( () => setExpiredAt(expiresAt), Math.max(0, Date.parse(expiresAt) - Date.now()), ); return () => clearTimeout(timer); }, [expiresAt]); if (threadRef === null || !share) return null; const disabled = busy || !props.canOperateThread; const stopSharing = () => Dialog.confirm({ title: "Stop sharing?", message: "The guest loses access and the link stops working.", confirmText: "Stop sharing", destructive: true, onConfirm: () => { if (disabled) return; setBusy(true); void closeGuestShare({ environmentId: threadRef.environmentId, input: { threadId: threadRef.threadId }, }).then((result) => { setBusy(false); alertOnFailure("Could not stop sharing", result); }); }, }); return ( {expiredAt === share.expiresAt ? "Guest link expired" : share.guestName === null ? "Guest link active" : `${share.guestName} is a guest`}