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 { 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};