frontend client for gemstone. decentralised workplace app
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: invites

serenity 84f0b4d8 bcc05f7a

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