frontend client for gemstone. decentralised workplace app
at main 10 kB view raw
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};