Skip to content
Source
Sign inSign up

PullRequestReactions.tsx

195 lines7.5 KB

1import { useNavigation } from "@react-navigation/native";
2import {
3 applyPendingPullRequestReactions,
4 PULL_REQUEST_REACTION_ORDER,
5 pullRequestReactionEmoji,
6 pullRequestReactionName,
7 pullRequestReactionTooltip,
8 pullRequestReactionsSignature,
9} from "@t3tools/client-runtime/pull-request-reactions";
10import type {
11 PullRequestComment,
12 PullRequestReaction,
13 PullRequestReactionContent,
14} from "@t3tools/contracts";
15import { AsyncResult } from "effect/reactivity";
16import { useState } from "react";
17import { Pressable, View } from "react-native";
18import { SymbolView } from "../../components/ui/AppSymbol";
19import { AppText as Text } from "../../components/ui/AppText";
20import * as Dialog from "../../components/ui/Dialog";
21import { Menu } from "../../components/ui/Menu";
22import { cn } from "../../lib/cn";
23import { composerPullRequests } from "../../state/pull-requests";
24import { useEnvironmentQuery } from "../../state/query";
25import { useAtomCommand } from "../../state/use-atom-command";
26import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff";
27import { usePullRequestPermissions } from "./usePullRequestPermissions";
28​
29const EMPTY_PENDING: ReadonlyMap<PullRequestReactionContent, boolean> = new Map();
30const PILL = "min-h-8 flex-row items-center gap-1 rounded-full border px-2.5 active:opacity-70";
31​
32function ReactionPills(props: {
33 readonly params: PullRequestParams;
34 readonly reactions: ReadonlyArray<PullRequestReaction>;
35 readonly canReact: boolean;
36 /** Absent reacts to the change request itself. */
37 readonly subjectId?: string;
38 readonly onChanged: () => void;
39}) {
40 const target = pullRequestTarget(props.params);
41 const setReaction = useAtomCommand(composerPullRequests.setReaction, { reportFailure: false });
42 // Keyed by what the host last reported, so a refresh that lands the change drops the overlay.
43 const [pending, setPending] = useState({ signature: "", values: EMPTY_PENDING });
44 const signature = pullRequestReactionsSignature(props.reactions);
45 const values = pending.signature === signature ? pending.values : EMPTY_PENDING;
46 const shown = applyPendingPullRequestReactions(props.reactions, values);
47 const toggle = async (content: PullRequestReactionContent, reacted: boolean) => {
48 setPending({ signature, values: new Map([...values, [content, reacted]]) });
49 const result = await setReaction({
50 ...target,
51 input: {
52 ...target.input,
53 ...(props.subjectId === undefined ? {} : { subjectId: props.subjectId }),
54 content,
55 reacted,
56 },
57 });
58 if (AsyncResult.isFailure(result)) {
59 setPending((current) => {
60 const next = new Map(current.values);
61 next.delete(content);
62 return { signature: current.signature, values: next };
63 });
64 Dialog.alert("Couldn't save the reaction", "Try again in a moment.");
65 return;
66 }
67 props.onChanged();
68 };
69 return (
70 <>
71 {shown.map((reaction) => (
72 <Pressable
73 key={reaction.content}
74 accessibilityRole="button"
75 accessibilityLabel={pullRequestReactionTooltip(reaction)}
76 accessibilityState={{ selected: reaction.viewerHasReacted, disabled: !props.canReact }}
77 disabled={!props.canReact}
78 onPress={() => void toggle(reaction.content, !reaction.viewerHasReacted)}
79 className={cn(
80 PILL,
81 reaction.viewerHasReacted
82 ? "border-primary bg-primary/10"
83 : "border-border-subtle bg-grouped-card",
84 )}
85 >
86 <Text className="text-sm">{pullRequestReactionEmoji(reaction.content)}</Text>
87 <Text className="text-xs font-t3-medium text-foreground-muted">{reaction.count}</Text>
88 </Pressable>
89 ))}
90 {props.canReact ? (
91 <Menu
92 accessibilityLabel="Add a reaction"
93 actions={PULL_REQUEST_REACTION_ORDER.map((content) => ({
94 id: content,
95 title: `${pullRequestReactionEmoji(content)} ${pullRequestReactionName(content)}`,
96 state: shown.some(
97 (reaction) => reaction.content === content && reaction.viewerHasReacted,
98 )
99 ? ("on" as const)
100 : ("off" as const),
101 }))}
102 onPressAction={({ nativeEvent }) => {
103 const content = PULL_REQUEST_REACTION_ORDER.find((id) => id === nativeEvent.event);
104 if (!content) return;
105 const reacted = shown.some(
106 (reaction) => reaction.content === content && reaction.viewerHasReacted,
107 );
108 void toggle(content, !reacted);
109 }}
110 >
111 <View className={cn(PILL, "border-border-subtle")}>
112 <SymbolView name="face.smiling" size={15} tintColorClassName="accent-icon-muted" />
113 </View>
114 </Menu>
115 ) : null}
116 </>
117 );
119​
120/** Reactions on the change request itself, which is where its description's reactions live. */
121export function PullRequestReactions(props: { readonly params: PullRequestParams }) {
122 const target = pullRequestTarget(props.params);
123 const detail = useEnvironmentQuery(composerPullRequests.detail(target));
124 const activity = useEnvironmentQuery(composerPullRequests.activity(target));
125 const { canReact } = usePullRequestPermissions(props.params, detail.data);
126 const reactions = activity.data?.reactions ?? [];
127 if (!activity.data || (reactions.length === 0 && !canReact)) return null;
128 return (
129 <View className="flex-row flex-wrap items-center gap-1.5">
130 <ReactionPills
131 params={props.params}
132 reactions={reactions}
133 canReact={canReact}
134 onChanged={activity.refresh}
135 />
136 </View>
137 );
139​
140export function PullRequestCommentFooter(props: {
141 readonly params: PullRequestParams;
142 readonly comment: Pick<
143 PullRequestComment,
144 "id" | "kind" | "body" | "author" | "canEdit" | "reactions"
145 >;
146 readonly permissions: Pick<
147 ReturnType<typeof usePullRequestPermissions>,
148 "canEditComment" | "canReact"
149 >;
150 readonly onChanged: () => void;
151 readonly onEdit?: () => void;
152}) {
153 const navigation = useNavigation();
154 const { comment } = props;
155 const reactions = comment.reactions ?? [];
156 const editableKind = comment.kind === "review" ? null : comment.kind;
157 const canEdit = editableKind !== null && props.permissions.canEditComment(comment);
158 const { canReact } = props.permissions;
159 if (reactions.length === 0 && !canReact && !canEdit) return null;
160 return (
161 <View className="flex-row flex-wrap items-center gap-1.5">
162 <ReactionPills
163 params={props.params}
164 reactions={reactions}
165 canReact={canReact}
166 subjectId={comment.id}
167 onChanged={props.onChanged}
168 />
169 {canEdit && editableKind !== null ? (
170 <Pressable
171 accessibilityRole="button"
172 accessibilityLabel="Edit comment"
173 onPress={() => {
174 props.onEdit?.();
175 navigation.navigate("PullRequestEdit", {
176 ...props.params,
177 field: "comment",
178 comment: {
179 id: comment.id,
180 kind: editableKind,
181 body: comment.body,
182 author: comment.author,
183 ...(comment.canEdit === undefined ? {} : { canEdit: comment.canEdit }),
184 },
185 });
186 }}
187 className={cn(PILL, "ml-auto border-transparent")}
188 >
189 <SymbolView name="pencil" size={13} tintColorClassName="accent-icon-muted" />
190 <Text className="text-xs font-t3-medium text-foreground-muted">Edit</Text>
191 </Pressable>
192 ) : null}
193 </View>
194 );