| 1 | import { useAtomRefresh } from "@effect/atom-react"; |
| 2 | import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 3 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 4 | import type { PullRequestThreadComment } from "@t3tools/contracts"; |
| 5 | import { useRef, useState } from "react"; |
| 6 | import { ActivityIndicator, Platform, ScrollView, View } from "react-native"; |
| 7 | import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller"; |
| 8 | import { NativeScrollViewMarker as ScrollViewMarker } from "../../native/NativeScrollViewMarker"; |
| 9 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 10 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 11 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 12 | import { MaterialButton } from "../../components/MaterialButton"; |
| 13 | import { GlassSurface } from "../../components/GlassSurface"; |
| 14 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 15 | import { relativeTime } from "../../lib/time"; |
| 16 | import { composerPullRequests } from "../../state/pull-requests"; |
| 17 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 18 | import { useEnvironmentQuery } from "../../state/query"; |
| 19 | import { PullRequestMarkdown, PullRequestMarkdownRepository } from "./PullRequestMarkdown"; |
| 20 | import { PullRequestAvatar } from "./PullRequestPresentation"; |
| 21 | import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; |
| 22 | import { usePullRequestPermissions } from "./usePullRequestPermissions"; |
| 23 | import { pullRequestReviewKey } from "./pullRequestReviewDrafts"; |
| 24 | |
| 25 | export 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 | |
| 36 | function 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 | } |