Skip to content
Source
Sign inSign up

ThreadGuestShareBar.tsx

190 lines6.9 KB

1import { useAtomValue } from "@effect/atom-react";
2import {
3 type AtomCommandResult,
4 isAtomCommandInterrupted,
5 squashAtomCommandFailure,
6} from "@t3tools/client-runtime/state/runtime";
7import {
8 AuthOrchestrationOperateScope,
9 type ChatAttachment,
10 type ScopedThreadRef,
11 type ThreadGuestMessage,
12 type ThreadGuestShare,
13} from "@t3tools/contracts";
14import { Atom } from "effect/reactivity";
15import { type ReactNode, useEffect, useMemo, useState } from "react";
16import { View } from "react-native";
17​
18import { Button } from "../../components/ui/Button";
19import { AppText as Text } from "../../components/ui/AppText";
20import { SymbolView } from "../../components/ui/AppSymbol";
21import * as Dialog from "../../components/ui/Dialog";
22import { useEnvironmentScope } from "../../state/session";
23import { environmentThreadDetails, threadEnvironment } from "../../state/threads";
24import { useAtomCommand } from "../../state/use-atom-command";
25import { GUEST_PENDING_BUBBLE_CLASS_NAME, GuestMessageName } from "./guestMessageStyle";
26​
27const NO_GUEST_SHARE_ATOM = Atom.make<ThreadGuestShare | null>(null);
28​
29function guestShareAtom(ref: ScopedThreadRef) {
30 let previous: ThreadGuestShare | null = null;
31 return Atom.make((get) => {
32 const next =
33 get(environmentThreadDetails.threadAtom(ref))?.projection.thread.guestShare ?? null;
34 // The thread record is replaced on unrelated updates; only share changes matter here.
35 if (JSON.stringify(next) !== JSON.stringify(previous)) previous = next;
36 return previous;
37 });
39​
40export function useThreadGuestShare(threadRef: ScopedThreadRef | null): ThreadGuestShare | null {
41 const shareAtom = useMemo(
42 () => (threadRef === null ? NO_GUEST_SHARE_ATOM : guestShareAtom(threadRef)),
43 [threadRef],
44 );
45 return useAtomValue(shareAtom);
47​
48function alertOnFailure(failureTitle: string, result: AtomCommandResult<unknown, unknown>) {
49 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
50 const error = squashAtomCommandFailure(result);
51 Dialog.alert(failureTitle, error instanceof Error ? error.message : "Try again.");
52 }
54​
55/**
56 * Owner controls for a guest link, above the composer: who the link is for and a way to stop
57 * sharing. Links are created on web. Waiting messages show in the feed, see
58 * `ThreadGuestPendingMessage`.
59 */
60export function ThreadGuestShareBar(props: {
61 readonly threadRef: ScopedThreadRef | null;
62 readonly canOperateThread: boolean;
63}) {
64 const { threadRef } = props;
65 const share = useThreadGuestShare(threadRef);
66 const closeGuestShare = useAtomCommand(threadEnvironment.closeGuestShare, "stop guest sharing");
67 const [busy, setBusy] = useState(false);
68 // The server keeps an expired link so waiting messages stay resolvable; the label flips when
69 // it expires.
70 // Keyed by the expiry, so a replaced link (even one already expired) gets its own timer.
71 const [expiredAt, setExpiredAt] = useState<string | null>(null);
72 const expiresAt = share?.expiresAt;
73 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- arms a timer that flips the share label when the guest link expires
74 useEffect(() => {
75 if (expiresAt === undefined) return;
76 const timer = setTimeout(
77 () => setExpiredAt(expiresAt),
78 Math.max(0, Date.parse(expiresAt) - Date.now()),
79 );
80 return () => clearTimeout(timer);
81 }, [expiresAt]);
82 if (threadRef === null || !share) return null;
83​
84 const disabled = busy || !props.canOperateThread;
85 const stopSharing = () =>
86 Dialog.confirm({
87 title: "Stop sharing?",
88 message: "The guest loses access and the link stops working.",
89 confirmText: "Stop sharing",
90 destructive: true,
91 onConfirm: () => {
92 if (disabled) return;
93 setBusy(true);
94 void closeGuestShare({
95 environmentId: threadRef.environmentId,
96 input: { threadId: threadRef.threadId },
97 }).then((result) => {
98 setBusy(false);
99 alertOnFailure("Could not stop sharing", result);
100 });
101 },
102 });
103​
104 return (
105 <View className="flex-row items-center gap-2 px-4 pb-2">
106 <SymbolView name="person.2" size={12} tintColorClassName="accent-foreground-muted" />
107 <Text className="min-w-0 flex-1 text-xs text-foreground-muted" numberOfLines={1}>
108 {expiredAt === share.expiresAt
109 ? "Guest link expired"
110 : share.guestName === null
111 ? "Guest link active"
112 : `${share.guestName} is a guest`}
113 </Text>
114 <Button label="Stop sharing" tone="text" disabled={disabled} onPress={stopSharing} />
115 </View>
116 );
118​
119/**
120 * One guest message waiting at the end of the feed until the owner sends or dismisses it. Sent
121 * ones then show in the feed as user messages with the guest's name.
122 */
123export function ThreadGuestPendingMessage(props: {
124 readonly threadRef: ScopedThreadRef;
125 readonly guestName: string;
126 readonly message: ThreadGuestMessage;
127 readonly bubbleMaxWidth: number | `${number}%`;
128 /** The feed's own attachment rendering, so the owner sees the files before Send. */
129 readonly renderAttachments: (attachments: ReadonlyArray<ChatAttachment>) => ReactNode;
130}) {
131 const { threadRef, guestName, message } = props;
132 const canOperate = useEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope);
133 const resolveGuestMessage = useAtomCommand(
134 threadEnvironment.resolveGuestMessage,
135 "resolve guest message",
136 );
137 // One request at a time: a second tap while the first is in flight would double-send.
138 const [busy, setBusy] = useState(false);
139​
140 const resolve = async (decision: "send" | "dismiss") => {
141 if (busy) return;
142 setBusy(true);
143 const result = await resolveGuestMessage({
144 environmentId: threadRef.environmentId,
145 input: {
146 threadId: threadRef.threadId,
147 guestMessageId: message.id,
148 decision,
149 creationSource: "mobile",
150 },
151 });
152 setBusy(false);
153 alertOnFailure(
154 decision === "send" ? "Could not send guest message" : "Could not dismiss guest message",
155 result,
156 );
157 };
158​
159 return (
160 <View className="items-end pb-5">
161 <GuestMessageName name={guestName} />
162 <View className={GUEST_PENDING_BUBBLE_CLASS_NAME} style={{ maxWidth: props.bubbleMaxWidth }}>
163 {message.text ? (
164 <Text selectable className="text-base text-foreground">
165 {message.text}
166 </Text>
167 ) : null}
168 {props.renderAttachments(message.attachments ?? [])}
169 </View>
170 {canOperate ? (
171 <View className="mt-1.5 flex-row gap-2">
172 <Button
173 label="Dismiss"
174 tone="secondary"
175 accessibilityLabel={`Dismiss message from ${guestName}`}
176 disabled={busy}
177 onPress={() => void resolve("dismiss")}
178 />
179 <Button
180 label="Send"
181 tone="primary"
182 accessibilityLabel={`Send message from ${guestName}`}
183 disabled={busy}
184 onPress={() => void resolve("send")}
185 />
186 </View>
187 ) : null}
188 </View>
189 );