frontend client for gemstone. decentralised workplace app
1
fork

Configure Feed

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

feat: channel invitations

serenity f943d117 aeaed26f

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