1import { useAtomRefresh } from "@effect/atom-react";
2import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
4import type { PullRequestThreadComment } from "@t3tools/contracts";
5import { useRef, useState } from "react";
6import { ActivityIndicator, Platform, ScrollView, View } from "react-native";
7import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";
8import { NativeScrollViewMarker as ScrollViewMarker } from "../../native/NativeScrollViewMarker";
9import { useSafeAreaInsets } from "react-native-safe-area-context";
10import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
11import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
12import { MaterialButton } from "../../components/MaterialButton";
13import { GlassSurface } from "../../components/GlassSurface";
14import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
15import { relativeTime } from "../../lib/time";
16import { composerPullRequests } from "../../state/pull-requests";
17import { useAtomCommand } from "../../state/use-atom-command";
18import { useEnvironmentQuery } from "../../state/query";
19import { PullRequestMarkdown, PullRequestMarkdownRepository } from "./PullRequestMarkdown";
20import { PullRequestAvatar } from "./PullRequestPresentation";
21import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff";
22import { usePullRequestPermissions } from "./usePullRequestPermissions";
23import { pullRequestReviewKey } from "./pullRequestReviewDrafts";
24
25export function PullRequestDiscussionScreen({
26 route,
27}: StaticScreenProps<PullRequestParams & { readonly discussionId: string }>) {
28 return (
29 <Discussion
30 key={`${pullRequestReviewKey(route.params)}:${route.params.discussionId}`}
31 params={route.params}
32 />
33 );
34}
35
36function Discussion({ params }: { params: PullRequestParams & { readonly discussionId: string } }) {
37 const target = pullRequestTarget(params);
38 const navigation = useNavigation();
39 const insets = useSafeAreaInsets();
40 const keyboardVisible = useKeyboardState((state) => state.isVisible);
41 const bottomInset = keyboardVisible ? 8 : Math.max(insets.bottom, 12);
42 const activity = useEnvironmentQuery(composerPullRequests.activity(target));
43 const detail = useEnvironmentQuery(composerPullRequests.detail(target));
44 const permissions = usePullRequestPermissions(params, detail.data);
45 const refresh = useAtomRefresh(composerPullRequests.activity(target));
46 const reply = useAtomCommand(composerPullRequests.replyToThread, { reportFailure: false });
47 const resolve = useAtomCommand(composerPullRequests.setThreadResolution, {
48 reportFailure: false,
49 });
50 const loadMore = useAtomCommand(composerPullRequests.threadComments, { reportFailure: false });
51 const thread = activity.data?.reviewThreads.find((thread) => thread.id === params.discussionId);
52 const [body, setBody] = useState("");
53 const [extra, setExtra] = useState<readonly PullRequestThreadComment[]>([]);
54 const [cursor, setCursor] = useState<string | null | undefined>();
55 const [busy, setBusy] = useState(false);
56 const pending = useRef(false);
57 const [error, setError] = useState<string | null>(null);
58 const [notice, setNotice] = useState<string | null>(null);
59 const [composerHeight, setComposerHeight] = useState(64);
60 const operation = async (run: () => Promise<void>) => {
61 if (pending.current) return;
62 pending.current = true;
63 setBusy(true);
64 setError(null);
65 setNotice(null);
66 try {
67 await run();
68 } catch (cause) {
69 setError(
70 cause instanceof Error ? cause.message : "The provider could not complete this action.",
71 );
72 } finally {
73 pending.current = false;
74 setBusy(false);
75 }
76 };
77 const post = () =>
78 operation(async () => {
79 if (!permissions.canReply || !body.trim() || !thread) return;
80 const submitted = body;
81 const response = await reply({
82 ...target,
83 input: { ...target.input, threadId: thread.id, body: submitted },
84 });
85 if (response._tag === "Failure") throw squashAtomCommandFailure(response);
86 setBody((current) => (current === submitted ? "" : current));
87 setNotice("Reply posted.");
88 refresh();
89 });
90 const toggleResolved = () =>
91 operation(async () => {
92 if (!thread || !permissions.canResolve) return;
93 const response = await resolve({
94 ...target,
95 input: { ...target.input, threadId: thread.id, resolved: !thread.isResolved },
96 });
97 if (response._tag === "Failure") throw squashAtomCommandFailure(response);
98 refresh();
99 });
100 const nextCursor = cursor === undefined ? thread?.nextCommentsCursor : cursor;
101 const more = () =>
102 operation(async () => {
103 if (!thread || !nextCursor) return;
104 const response = await loadMore({
105 ...target,
106 input: { ...target.input, threadId: thread.id, cursor: nextCursor },
107 });
108 if (response._tag === "Failure") throw squashAtomCommandFailure(response);
109 setExtra((comments) => [...comments, ...response.value.comments]);
110 setCursor(response.value.nextCursor);
111 });
112 const comments = [
113 ...new Map(
114 [...extra, ...(thread?.comments ?? [])].map((comment) => [comment.id, comment]),
115 ).values(),
116 ].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
117 const canReply = permissions.canReply && Boolean(thread);
118 const resolveLabel = thread?.isResolved ? "Unresolve" : "Resolve";
119 return (
120 <PullRequestMarkdownRepository
121 params={params}
122 value={detail.data?.provider === "github" ? detail.data.url : null}
123 >
124 <KeyboardAvoidingView automaticOffset behavior="height" className="flex-1 bg-screen">
125 <NativeStackScreenOptions
126 options={{ title: "Discussion", unstable_headerSubtitle: `#${params.number}` }}
127 />
128 {Platform.OS === "android" ? (
129 <AndroidScreenHeader
130 title="Discussion"
131 subtitle={`#${params.number}`}
132 onBack={() => navigation.goBack()}
133 actions={
134 permissions.canResolve && thread
135 ? [
136 {
137 accessibilityLabel: resolveLabel,
138 icon: thread.isResolved ? "arrow.uturn.backward" : "checkmark",
139 disabled: busy || activity.isPending,
140 onPress: () => void toggleResolved(),
141 },
142 ]
143 : []
144 }
145 />
146 ) : null}
147 {permissions.canResolve && thread ? (
148 <NativeHeaderToolbar placement="right">
149 <NativeHeaderToolbar.Button
150 label={resolveLabel}
151 disabled={busy || activity.isPending}
152 onPress={() => void toggleResolved()}
153 />
154 </NativeHeaderToolbar>
155 ) : null}
156 <ScrollViewMarker style={{ flex: 1 }}>
157 <ScrollView
158 className="flex-1"
159 contentInsetAdjustmentBehavior="automatic"
160 keyboardShouldPersistTaps="handled"
161 contentContainerStyle={{
162 padding: 20,
163 paddingBottom: canReply ? composerHeight + bottomInset + 24 : 24,
164 }}
165 >
166 {activity.isPending && !thread ? <ActivityIndicator /> : null}
167 {thread ? (
168 <View className="gap-2 pb-4">
169 <Text selectable className="font-mono text-xs text-foreground-muted">
170 {thread.path}
171 {thread.line ? `:${thread.line}` : ""}
172 </Text>
173 <Text className="text-xs text-foreground-muted">
174 {thread.isResolved ? "Resolved" : "Unresolved"}
175 {thread.isOutdated ? " · Outdated" : ""}
176 </Text>
177 <MaterialButton
178 label="View file diff"
179 onPress={() =>
180 navigation.navigate("PullRequestFile", { ...params, path: thread.path })
181 }
182 />
183 </View>
184 ) : !activity.isPending ? (
185 <Text>This discussion is no longer available.</Text>
186 ) : null}
187 {comments.map((comment) => (
188 <View key={comment.id} className="gap-3 border-b border-border-subtle py-4">
189 <View className="flex-row items-center gap-2">
190 <PullRequestAvatar actor={comment.author} size={28} />
191 <Text className="min-w-0 flex-1 text-sm font-t3-medium">
192 {comment.author?.login ?? "Unknown author"}
193 </Text>
194 <Text className="text-2xs text-foreground-muted">
195 {relativeTime(comment.createdAt)}
196 </Text>
197 </View>
198 <PullRequestMarkdown markdown={comment.body} />
199 </View>
200 ))}
201 {nextCursor ? (
202 <MaterialButton
203 label={busy ? "Loading…" : "Load more replies"}
204 disabled={busy}
205 onPress={() => void more()}
206 />
207 ) : null}
208 {error || activity.error ? (
209 <Text selectable className="py-4 text-sm text-danger-foreground">
210 {error ?? activity.error}
211 </Text>
212 ) : null}
213 {notice ? <Text className="py-4 text-sm text-foreground-muted">{notice}</Text> : null}
214 </ScrollView>
215 </ScrollViewMarker>
216 {canReply ? (
217 <View className="absolute inset-x-4 bottom-0" style={{ paddingBottom: bottomInset }}>
218 <GlassSurface
219 chrome="none"
220 onLayout={(event) => setComposerHeight(event.nativeEvent.layout.height)}
221 className="flex-row items-end gap-1 p-1"
222 >
223 <TextInput
224 accessibilityLabel="Discussion reply"
225 multiline
226 maxLength={65536}
227 placeholder="Reply…"
228 placeholderTextColorClassName="accent-foreground-muted"
229 value={body}
230 onChangeText={setBody}
231 editable={!busy}
232 className="max-h-36 min-h-12 min-w-0 flex-1 rounded-[24px] border-0 bg-transparent px-4 py-3 text-base text-foreground"
233 />
234 <View>
235 <MaterialButton
236 label={busy ? "Posting…" : "Reply"}
237 tone="contrast"
238 disabled={busy || !body.trim()}
239 onPress={() => void post()}
240 />
241 </View>
242 </GlassSurface>
243 </View>
244 ) : null}
245 </KeyboardAvoidingView>
246 </PullRequestMarkdownRepository>
247 );
248}