1import {
2 parsePullRequestAnchor,
3 pullRequestAnchorMatchesUrl,
4 resolvePullRequestReviewAnchor,
5} from "@t3tools/shared/pullRequestAnchor";
6import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
7import { useAtomRefresh } from "@effect/atom-react";
8import { useFocusEffect, useNavigation, type StaticScreenProps } from "@react-navigation/native";
9import {
10 type AtomCommandResult,
11 squashAtomCommandFailure,
12} from "@t3tools/client-runtime/state/runtime";
13import { AsyncResult } from "effect/reactivity";
14import { pullRequestConversationThreads, type PullRequestThreadComment } from "@t3tools/contracts";
15import { useRef, useState } from "react";
16import { ScrollView, View, type ScrollViewInstance } from "react-native";
17import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";
18import { NativeScrollViewMarker as ScrollViewMarker } from "../../native/NativeScrollViewMarker";
19import { useSafeAreaInsets } from "react-native-safe-area-context";
20import { AppText as Text, AppTextInput as TextInput } from "../../components/ui/AppText";
21import { AppBar } from "../../components/ui/AppBar";
22import { Button } from "../../components/ui/Button";
23import { GlassSurface } from "../../components/ui/GlassSurface";
24import { relativeTime } from "../../lib/time";
25import { composerPullRequests } from "../../state/pull-requests";
26import { useAtomCommand } from "../../state/use-atom-command";
27import { useEnvironmentQuery } from "../../state/query";
28import { PullRequestMarkdown, PullRequestMarkdownRepository } from "./PullRequestMarkdown";
29import { PullRequestAvatar } from "./PullRequestPresentation";
30import { PullRequestCommentFooter } from "./PullRequestReactions";
31import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff";
32import { usePullRequestPermissions } from "./usePullRequestPermissions";
33import { pullRequestReviewKey } from "./pullRequestReviewDrafts";
34import { PullRequestCompletionSuggestions } from "./PullRequestCompletionSuggestions";
35import { usePullRequestCompletions } from "./usePullRequestCompletions";
36
37export function PullRequestDiscussionScreen({
38 route,
39}: StaticScreenProps<PullRequestParams & { readonly discussionId: string }>) {
40 return (
41 <Discussion
42 key={`${pullRequestReviewKey(route.params)}:${route.params.discussionId}`}
43 params={route.params}
44 />
45 );
46}
47
48function Discussion({ params }: { params: PullRequestParams & { readonly discussionId: string } }) {
49 const target = pullRequestTarget(params);
50 const navigation = useNavigation();
51 const insets = useSafeAreaInsets();
52 const keyboardVisible = useKeyboardState((state) => state.isVisible);
53 const bottomInset = keyboardVisible ? 8 : Math.max(insets.bottom, 12);
54 const activity = useEnvironmentQuery(composerPullRequests.activity(target));
55 const detail = useEnvironmentQuery(composerPullRequests.detail(target));
56 const permissions = usePullRequestPermissions(params, detail.data);
57 const refresh = useAtomRefresh(composerPullRequests.activity(target));
58 const reply = useAtomCommand(composerPullRequests.replyToThread, { reportFailure: false });
59 const resolve = useAtomCommand(composerPullRequests.setThreadResolution, {
60 reportFailure: false,
61 });
62 const loadMore = useAtomCommand(composerPullRequests.threadComments, { reportFailure: false });
63 const thread =
64 activity.data &&
65 pullRequestConversationThreads(activity.data).find(
66 (thread) => thread.id === params.discussionId,
67 );
68 const filePath = thread?.path ?? null;
69 const [body, setBody] = useState("");
70 const completions = usePullRequestCompletions({
71 environmentId: params.environmentId,
72 projectId: params.projectId,
73 value: body,
74 onChangeValue: setBody,
75 support: detail.data?.capabilities ?? null,
76 });
77 const [extra, setExtra] = useState<readonly PullRequestThreadComment[]>([]);
78 const [cursor, setCursor] = useState<string | null | undefined>();
79 const [busy, setBusy] = useState(false);
80 const pending = useRef(false);
81 const [error, setError] = useState<string | null>(null);
82 const [notice, setNotice] = useState<string | null>(null);
83 const [composerHeight, setComposerHeight] = useState(64);
84 // Replies from "Load more" live only here, outside the activity an edit refreshes. After one
85 // is edited, drop the loaded pages so the next load reads the new body instead of the old one.
86 const extraEdited = useRef(false);
87 useFocusEffect(() => {
88 if (!extraEdited.current) return;
89 extraEdited.current = false;
90 setExtra([]);
91 setCursor(undefined);
92 });
93 const operation = async (run: () => Promise<AtomCommandResult<unknown, unknown> | undefined>) => {
94 if (pending.current) return;
95 pending.current = true;
96 setBusy(true);
97 setError(null);
98 setNotice(null);
99 const result = await run();
100 pending.current = false;
101 setBusy(false);
102 if (result && AsyncResult.isFailure(result)) {
103 const cause = squashAtomCommandFailure(result);
104 setError(
105 cause instanceof Error ? cause.message : "The provider could not complete this action.",
106 );
107 }
108 };
109 const post = () =>
110 operation(async () => {
111 if (!permissions.canReply || !body.trim() || !thread) return undefined;
112 const submitted = body;
113 const response = await reply({
114 ...target,
115 input: { ...target.input, threadId: thread.id, body: submitted },
116 });
117 if (AsyncResult.isFailure(response)) return response;
118 setBody((current) => (current === submitted ? "" : current));
119 setNotice("Reply posted.");
120 refresh();
121 return response;
122 });
123 const toggleResolved = () =>
124 operation(async () => {
125 if (!thread || !permissions.canResolve) return undefined;
126 const response = await resolve({
127 ...target,
128 input: { ...target.input, threadId: thread.id, resolved: !thread.isResolved },
129 });
130 if (AsyncResult.isSuccess(response)) refresh();
131 return response;
132 });
133 const nextCursor = cursor === undefined ? thread?.nextCommentsCursor : cursor;
134 const more = () =>
135 operation(async () => {
136 if (!thread || !nextCursor) return undefined;
137 const response = await loadMore({
138 ...target,
139 input: { ...target.input, threadId: thread.id, cursor: nextCursor },
140 });
141 if (AsyncResult.isFailure(response)) return response;
142 setExtra((comments) => [...comments, ...response.value.comments]);
143 setCursor(response.value.nextCursor);
144 return response;
145 });
146 const comments = [
147 ...new Map(
148 [...extra, ...(thread?.comments ?? [])].map((comment) => [comment.id, comment]),
149 ).values(),
150 ].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
151 const scrollRef = useRef<ScrollViewInstance>(null);
152 const pendingTop = useRef<number | null>(null);
153 const positionedComment = useRef<string | null>(null);
154 const location = parsePullRequestAnchor(params.navigationUrl);
155 const anchor =
156 location?.kind === "timeline"
157 ? resolvePullRequestReviewAnchor(location.anchor, [], [{ comments }])
158 : null;
159 const canReply = permissions.canReply && Boolean(thread);
160 const resolveLabel = thread?.isResolved ? "Unresolve" : "Resolve";
161 return (
162 <PullRequestMarkdownRepository params={params} changeRequest={detail.data}>
163 <KeyboardAvoidingView
164 automaticOffset
165 behavior="height"
166 className="flex-1 bg-screen"
167 // Any touch, such as replying or loading replies, ends the wait to reposition the link.
168 onTouchStart={() => {
169 pendingTop.current = null;
170 }}
171 >
172 <AppBar
173 title="Discussion"
174 subtitle={`#${params.number}`}
175 onBack={() => navigation.goBack()}
176 actions={
177 permissions.canResolve && thread
178 ? [
179 {
180 accessibilityLabel: resolveLabel,
181 icon: thread.isResolved ? "arrow.uturn.backward" : "checkmark",
182 disabled: busy || activity.isPending,
183 onPress: () => void toggleResolved(),
184 },
185 ]
186 : []
187 }
188 />
189 <ScrollViewMarker style={{ flex: 1 }}>
190 <ScrollView
191 ref={scrollRef}
192 onContentSizeChange={() => {
193 if (pendingTop.current === null) return;
194 scrollRef.current?.scrollTo({ y: pendingTop.current, animated: false });
195 pendingTop.current = null;
196 }}
197 className="flex-1"
198 contentInsetAdjustmentBehavior="automatic"
199 keyboardShouldPersistTaps="handled"
200 contentContainerStyle={{
201 padding: 20,
202 paddingBottom: canReply ? composerHeight + bottomInset + 24 : 24,
203 }}
204 >
205 {activity.isPending && !thread ? <AppActivityIndicator /> : null}
206 {thread ? (
207 <View className="gap-2 pb-4">
208 {thread.path === null ? null : (
209 <Text selectable className="font-mono text-xs text-foreground-muted">
210 {thread.path}
211 {thread.line ? `:${thread.line}` : ""}
212 </Text>
213 )}
214 <Text className="text-xs text-foreground-muted">
215 {thread.isResolved ? "Resolved" : "Unresolved"}
216 {thread.isOutdated ? " · Outdated" : ""}
217 </Text>
218 {filePath === null ? null : (
219 <Button
220 label="View file diff"
221 onPress={() =>
222 navigation.navigate("PullRequestFile", { ...params, path: filePath })
223 }
224 />
225 )}
226 </View>
227 ) : !activity.isPending ? (
228 <Text>This discussion is no longer available.</Text>
229 ) : null}
230 {comments.map((comment) => (
231 <View
232 key={comment.id}
233 onLayout={(event) => {
234 if (
235 anchor === null ||
236 !pullRequestAnchorMatchesUrl(anchor, comment.url) ||
237 positionedComment.current === comment.id
238 )
239 return;
240 positionedComment.current = comment.id;
241 pendingTop.current = event.nativeEvent.layout.y;
242 scrollRef.current?.scrollTo({ y: pendingTop.current, animated: false });
243 }}
244 className={
245 anchor !== null && pullRequestAnchorMatchesUrl(anchor, comment.url)
246 ? "-mx-3 gap-3 rounded-2xl border border-primary bg-primary/10 px-3 py-4"
247 : "gap-3 border-b border-border-subtle py-4"
248 }
249 >
250 <View className="flex-row items-center gap-2">
251 <PullRequestAvatar actor={comment.author} size={28} />
252 <Text className="min-w-0 flex-1 text-sm font-t3-medium">
253 {comment.author?.login ?? "Unknown author"}
254 </Text>
255 <Text className="text-2xs text-foreground-muted">
256 {relativeTime(comment.createdAt)}
257 </Text>
258 </View>
259 <PullRequestMarkdown markdown={comment.body} />
260 <PullRequestCommentFooter
261 params={params}
262 comment={{ ...comment, kind: "review-comment" }}
263 permissions={permissions}
264 onChanged={refresh}
265 onEdit={() => {
266 if (extra.some((loaded) => loaded.id === comment.id))
267 extraEdited.current = true;
268 }}
269 />
270 </View>
271 ))}
272 {nextCursor ? (
273 <Button
274 label={busy ? "Loading…" : "Load more replies"}
275 disabled={busy}
276 onPress={() => void more()}
277 />
278 ) : null}
279 {error || activity.error ? (
280 <Text selectable className="py-4 text-sm text-danger-foreground">
281 {error ?? activity.error}
282 </Text>
283 ) : null}
284 {notice ? <Text className="py-4 text-sm text-foreground-muted">{notice}</Text> : null}
285 </ScrollView>
286 </ScrollViewMarker>
287 {canReply ? (
288 <View
289 className="absolute inset-x-4 bottom-0 gap-2"
290 style={{ paddingBottom: bottomInset }}
291 >
292 <PullRequestCompletionSuggestions completions={completions} />
293 <GlassSurface
294 chrome="none"
295 onLayout={(event) => setComposerHeight(event.nativeEvent.layout.height)}
296 className="flex-row items-end gap-1 p-1"
297 >
298 <TextInput
299 accessibilityLabel="Discussion reply"
300 selection={completions.selection}
301 onSelectionChange={completions.onSelectionChange}
302 onBlur={completions.onBlur}
303 multiline
304 maxLength={65536}
305 placeholder="Reply…"
306 placeholderTextColorClassName="accent-foreground-muted"
307 value={body}
308 onChangeText={setBody}
309 editable={!busy}
310 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"
311 />
312 <View>
313 <Button
314 label={busy ? "Posting…" : "Reply"}
315 tone="contrast"
316 disabled={busy || !body.trim()}
317 onPress={() => void post()}
318 />
319 </View>
320 </GlassSurface>
321 </View>
322 ) : null}
323 </KeyboardAvoidingView>
324 </PullRequestMarkdownRepository>
325 );
326}