frontend client for gemstone. decentralised workplace app
1import { Loading } from "@/components/primitives/Loading";
2import { Text } from "@/components/primitives/Text";
3import { useFacet } from "@/lib/facet";
4import type { AtUri, DidPlc, DidWeb } from "@/lib/types/atproto";
5import { systemsGmstnDevelopmentChannelInviteRecordSchema } from "@/lib/types/lexicon/systems.gmstn.development.channel.invite";
6import { partition } from "@/lib/utils/arrays";
7import { getCommitFromFullAtUri, stringToAtUri } from "@/lib/utils/atproto";
8import { addMembership } from "@/lib/utils/gmstn";
9import { useMemberships } from "@/providers/authed/MembershipsProvider";
10import {
11 useOAuthAgentGuaranteed,
12 useOAuthSessionGuaranteed,
13} from "@/providers/OAuthProvider";
14import { useCurrentPalette } from "@/providers/ThemeProvider";
15import { useConstellationInvitesQuery } from "@/queries/hooks/useConstellationInvitesQuery";
16import { useMutation, useQueryClient } from "@tanstack/react-query";
17import { Check, Mail, MailOpen, X } from "lucide-react-native";
18import { FlatList, Pressable, View } from "react-native";
19import { z } from "zod";
20
21export const Invites = () => {
22 const { semantic } = useCurrentPalette();
23 const { atoms, typography } = useFacet();
24 const { memberships } = useMemberships();
25 const session = useOAuthSessionGuaranteed();
26 const { useQuery } = useConstellationInvitesQuery(session);
27
28 const { data: invites, isLoading } = useQuery();
29
30 console.log(invites);
31
32 const membershipAtUris: Array<Required<AtUri>> = memberships
33 .map((membershipRecord) => {
34 const res = stringToAtUri(membershipRecord.membership.invite.uri);
35 if (!res.ok) return;
36 return res.data as Required<AtUri>;
37 })
38 .filter((membership) => membership !== undefined);
39
40 const [existingInvites, pendingInvites] = partition(
41 invites?.invites ?? [],
42 (invite) =>
43 membershipAtUris.some(
44 (membership) => invite.rkey === membership.rKey,
45 ),
46 );
47
48 console.log({ existingInvites, pendingInvites });
49
50 return (
51 <View
52 style={{
53 flex: 1,
54 flexDirection: "column",
55 padding: 32,
56 gap: 16,
57 alignItems: "center",
58 }}
59 >
60 {isLoading ? (
61 <Loading />
62 ) : (
63 <>
64 <View
65 style={{
66 borderWidth: 1,
67 borderColor: semantic.borderVariant,
68 borderRadius: atoms.radii.lg,
69 padding: 12,
70 paddingVertical: 16,
71 gap: 16,
72 width: "50%",
73 }}
74 >
75 <View
76 style={{
77 flexDirection: "row",
78 alignItems: "center",
79 marginLeft: 6,
80 gap: 6,
81 }}
82 >
83 <Mail
84 height={20}
85 width={20}
86 color={semantic.text}
87 />
88 <Text
89 style={[
90 typography.weights.byName.medium,
91 typography.sizes.xl,
92 ]}
93 >
94 Pending Invites
95 </Text>
96 </View>
97 <FlatList
98 contentContainerStyle={{ gap: 4 }}
99 data={pendingInvites}
100 renderItem={({ item: invite }) => (
101 <PendingInvite
102 inviteAtUri={{
103 authority: invite.did as
104 | DidPlc
105 | DidWeb,
106 collection: invite.collection,
107 rKey: invite.rkey,
108 }}
109 />
110 )}
111 />
112 </View>
113 <View
114 style={{
115 borderWidth: 1,
116 borderColor: semantic.borderVariant,
117 borderRadius: atoms.radii.lg,
118 padding: 12,
119 paddingVertical: 16,
120 gap: 16,
121 width: "50%",
122 }}
123 >
124 <View
125 style={{
126 flexDirection: "row",
127 alignItems: "center",
128 marginLeft: 6,
129 gap: 6,
130 }}
131 >
132 <MailOpen
133 height={20}
134 width={20}
135 color={semantic.text}
136 />
137 <Text
138 style={[
139 typography.weights.byName.medium,
140 typography.sizes.xl,
141 ]}
142 >
143 Existing Invites
144 </Text>
145 </View>
146 <FlatList
147 data={existingInvites}
148 renderItem={({ item: invite }) => (
149 <View>
150 <Text>{invite.rkey}</Text>
151 </View>
152 )}
153 />
154 </View>
155 </>
156 )}
157 </View>
158 );
159};
160
161const PendingInvite = ({ inviteAtUri }: { inviteAtUri: Required<AtUri> }) => {
162 const { semantic } = useCurrentPalette();
163 const { atoms } = useFacet();
164 const session = useOAuthSessionGuaranteed();
165 const agent = useOAuthAgentGuaranteed();
166 const { queryKey: constellationInvitesQueryKey } =
167 useConstellationInvitesQuery(session);
168 const queryClient = useQueryClient();
169
170 const { mutate: mutateInvites, error: inviteMutationError } = useMutation({
171 mutationFn: async (state: "accepted" | "rejected") => {
172 const inviteCommitRes = await getCommitFromFullAtUri(inviteAtUri);
173 if (!inviteCommitRes.ok)
174 throw new Error(
175 "Could not resolve invite record from user's PDS.",
176 );
177 const { data: inviteCommit } = inviteCommitRes;
178
179 const {
180 success: parseSuccess,
181 error: parseError,
182 data: inviteRecordParsed,
183 } = systemsGmstnDevelopmentChannelInviteRecordSchema.safeParse(
184 inviteCommit.value,
185 );
186 if (!parseSuccess)
187 throw new Error(
188 `Could not validate invite record schema. ${z.prettifyError(parseError)}`,
189 );
190
191 const { uri, cid } = inviteCommit;
192 if (!cid)
193 throw new Error(
194 "Invite commit record did not have a cid somehow. Ensure that the data on PDS is not malformed.",
195 );
196
197 const creationResult = await addMembership({
198 agent,
199 membershipInfo: {
200 channel: inviteRecordParsed.channel,
201 invite: {
202 cid,
203 uri,
204 },
205 state,
206 },
207 });
208
209 if (!creationResult.ok)
210 throw new Error(
211 `Error when submitting data. Check the inputs. ${creationResult.error}`,
212 );
213 },
214 onSuccess: async () => {
215 await queryClient.invalidateQueries({
216 queryKey: ["membership", session.did],
217 });
218 await queryClient.invalidateQueries({
219 queryKey: constellationInvitesQueryKey,
220 });
221 },
222 onError: () => {
223 // TODO: handle error
224 },
225 });
226
227 return (
228 <View style={{ flexDirection: "row", alignItems: "center", gap: 2 }}>
229 <Text>{inviteAtUri.rKey}</Text>
230 <Pressable
231 style={{ marginLeft: 2 }}
232 onPress={() => {
233 mutateInvites("accepted");
234 }}
235 >
236 {({ hovered }) => (
237 <Check
238 height={16}
239 width={16}
240 color={semantic.positive}
241 style={{
242 backgroundColor: hovered
243 ? semantic.surfaceVariant
244 : semantic.surface,
245 padding: 4,
246 borderRadius: atoms.radii.sm,
247 }}
248 />
249 )}
250 </Pressable>
251 <Pressable
252 style={{ marginLeft: 2 }}
253 onPress={() => {
254 mutateInvites("rejected");
255 }}
256 >
257 {({ hovered }) => (
258 <X
259 height={16}
260 width={16}
261 color={semantic.negative}
262 style={{
263 backgroundColor: hovered
264 ? semantic.surfaceVariant
265 : semantic.surface,
266 padding: 4,
267 borderRadius: atoms.radii.sm,
268 }}
269 />
270 )}
271 </Pressable>
272 </View>
273 );
274};