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 { lighten } from "@/lib/facet/src/lib/colors";
5import type { AtUri } from "@/lib/types/atproto";
6import { didSchema } from "@/lib/types/atproto";
7import type { SystemsGmstnDevelopmentChannelInvite } from "@/lib/types/lexicon/systems.gmstn.development.channel.invite";
8import { didDocResolver, stringToAtUri } from "@/lib/utils/atproto";
9import { inviteNewUser } from "@/lib/utils/gmstn";
10import {
11 useOAuthAgentGuaranteed,
12 useOAuthSessionGuaranteed,
13} from "@/providers/OAuthProvider";
14import { useCurrentPalette } from "@/providers/ThemeProvider";
15import { getInviteRecordsFromPds } from "@/queries/get-invites-from-pds";
16import { type OAuthSession } from "@atproto/oauth-client";
17import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
18import { format } from "date-fns";
19import { Plus } from "lucide-react-native";
20import { useState, type Dispatch, type SetStateAction } from "react";
21import { FlatList, Pressable, TextInput, View } from "react-native";
22
23export const InviteUserModalContent = ({
24 setShowInviteModal,
25 channelAtUri,
26 channelCid,
27}: {
28 setShowInviteModal: Dispatch<SetStateAction<boolean>>;
29 channelAtUri: string;
30 channelCid: string;
31}) => {
32 const { semantic } = useCurrentPalette();
33 const { atoms, typography } = useFacet();
34 const [inputText, setInputText] = useState("");
35 const session = useOAuthSessionGuaranteed();
36 const agent = useOAuthAgentGuaranteed();
37 const queryClient = useQueryClient();
38
39 const { isLoading, data: invites } = useQuery({
40 queryKey: ["invites", session.did],
41 queryFn: async () => {
42 return await invitesQueryFn({ session, channelAtUri });
43 },
44 });
45
46 const { mutate: inviteUserMutation, isPending: mutationPending } =
47 useMutation({
48 mutationFn: async () => {
49 const {
50 success,
51 error,
52 data: did,
53 } = didSchema.safeParse(inputText);
54 if (!success) throw new Error(error.message);
55 const inviteRes = await inviteNewUser({
56 agent,
57 did,
58 channel: {
59 uri: channelAtUri,
60 cid: channelCid,
61 $type: "com.atproto.repo.strongRef",
62 },
63 });
64 if (!inviteRes.ok) throw new Error(inviteRes.error);
65 },
66 onSuccess: async () => {
67 await queryClient.invalidateQueries({
68 queryKey: ["invites", session.did],
69 });
70 },
71 });
72
73 const disableSubmitButton = (() => {
74 const { success } = didSchema.safeParse(inputText);
75 if (!success) return true;
76 return !inputText.trim();
77 })();
78
79 return (
80 <View
81 style={{
82 backgroundColor: semantic.surface,
83 borderRadius: atoms.radii.lg,
84 display: "flex",
85 gap: 12,
86 padding: 16,
87 }}
88 >
89 <View style={{ gap: 4 }}>
90 <Text>User DID:</Text>
91 <View
92 style={{
93 flexDirection: "row",
94 alignItems: "center",
95 gap: 4,
96 }}
97 >
98 <TextInput
99 style={[
100 {
101 flex: 1,
102 borderWidth: 1,
103 borderColor: semantic.borderVariant,
104 borderRadius: atoms.radii.md,
105 padding: 10,
106 color: semantic.text,
107 outline: "0",
108 fontFamily: typography.families.primary,
109 minWidth: 256,
110 },
111 typography.weights.byName.extralight,
112 typography.sizes.sm,
113 ]}
114 value={inputText}
115 onChangeText={(text) => {
116 setInputText(text);
117 }}
118 placeholder="did:plc:... or did:web:..."
119 placeholderTextColor={semantic.textPlaceholder}
120 />
121 <Pressable
122 onPress={() => {
123 console.log("mutating");
124 inviteUserMutation();
125 }}
126 disabled={disableSubmitButton}
127 >
128 {({ hovered }) =>
129 mutationPending ? (
130 <Loading size="small" />
131 ) : (
132 <Plus
133 height={20}
134 width={20}
135 style={{
136 backgroundColor: disableSubmitButton
137 ? semantic.textPlaceholder
138 : hovered
139 ? lighten(semantic.primary, 7)
140 : semantic.primary,
141 alignSelf: "flex-start",
142 padding: 10,
143 borderRadius: atoms.radii.md,
144 borderColor: semantic.borderVariant,
145 }}
146 />
147 )
148 }
149 </Pressable>
150 </View>
151 </View>
152 <View style={{ gap: 4 }}>
153 <Text>Invited users:</Text>
154 {isLoading ? (
155 <Loading size="small" />
156 ) : (
157 invites && (
158 <FlatList
159 inverted
160 data={invites.toReversed()}
161 renderItem={({ item }) => (
162 <InvitedUser invite={item} />
163 )}
164 keyExtractor={(_, index) => index.toString()}
165 contentContainerStyle={{
166 flex: 1,
167 gap: 2,
168 }}
169 showsVerticalScrollIndicator={false}
170 />
171 )
172 )}
173 </View>
174 </View>
175 );
176};
177
178const invitesQueryFn = async ({
179 session,
180 channelAtUri,
181}: {
182 session: OAuthSession;
183 channelAtUri: string;
184}) => {
185 const invites = await getInviteRecordsFromPds({
186 pdsEndpoint: session.serverMetadata.issuer,
187 did: session.did,
188 });
189
190 if (!invites.ok) {
191 console.error("invitesQueryFn error.", invites.error);
192 throw new Error(
193 `Something went wrong while getting the user's channel records.}`,
194 );
195 }
196
197 const results = invites.data
198 .map((record) => {
199 const convertResult = stringToAtUri(record.uri);
200 if (!convertResult.ok) {
201 console.error(
202 "Could not convert",
203 record,
204 "into at:// URI object.",
205 convertResult.error,
206 );
207 return;
208 }
209 if (!convertResult.data.collection || !convertResult.data.rKey) {
210 console.error(
211 record,
212 "did not convert to a full at:// URI with collection and rkey.",
213 );
214 return;
215 }
216 const uri: Required<AtUri> = {
217 authority: convertResult.data.authority,
218 collection: convertResult.data.collection,
219 rKey: convertResult.data.rKey,
220 };
221 return { uri, value: record.value };
222 })
223 .filter((atUri) => atUri !== undefined)
224 .filter((atUri) => atUri.value.channel.uri === channelAtUri);
225
226 return results;
227};
228
229const InvitedUser = ({
230 invite,
231}: {
232 invite: {
233 value: SystemsGmstnDevelopmentChannelInvite;
234 uri: Required<AtUri>;
235 };
236}) => {
237 const { isLoading, data: handle } = useQuery({
238 queryKey: ["handle", invite.value.recipient],
239 queryFn: async () => {
240 const didDoc = await didDocResolver.resolve(invite.value.recipient);
241 if (!didDoc.alsoKnownAs)
242 throw new Error("DID did not resolve to handle");
243 if (didDoc.alsoKnownAs.length === 0)
244 throw new Error(
245 "No alsoKnownAs in DID document. It might be malformed.",
246 );
247 return didDoc.alsoKnownAs[0].slice(5);
248 },
249 });
250
251 return (
252 <View>
253 {isLoading ? (
254 <Loading size="small" />
255 ) : (
256 handle && (
257 <View
258 style={{
259 flexDirection: "row",
260 justifyContent: "space-between",
261 gap: 32,
262 }}
263 >
264 <Text>@{handle}</Text>
265 <Text>
266 since{" "}
267 {format(
268 invite.value.createdAt,
269 "do MMM y, h:mmaaa",
270 )}
271 </Text>
272 </View>
273 )
274 )}
275 </View>
276 );
277};