frontend client for gemstone. decentralised workplace app

feat: lattice registration

serenity c81d9c4a 0e7d5ba2

+56
src/components/Settings/LatticeInfo.tsx
···
···
+
import { Loading } from "@/components/primitives/Loading";
+
import { Text } from "@/components/primitives/Text";
+
import type { AtUri } from "@/lib/types/atproto";
+
import type { SystemsGmstnDevelopmentLattice } from "@/lib/types/lexicon/systems.gmstn.development.lattice";
+
import { useCurrentPalette } from "@/providers/ThemeProvider";
+
import { getOwnerInfoFromLattice } from "@/queries/get-owner-info-from-lattice";
+
import { useQuery } from "@tanstack/react-query";
+
import { BadgeCheck, X } from "lucide-react-native";
+
import { View } from "react-native";
+
+
export const LatticeInfo = ({
+
shard,
+
}: {
+
shard: {
+
uri: Required<AtUri>;
+
value: SystemsGmstnDevelopmentLattice;
+
};
+
}) => {
+
const latticeDomain = shard.uri.rKey;
+
const { isLoading, data: latticeInfo } = useQuery({
+
queryKey: ["shardInfo", latticeDomain],
+
queryFn: async () => {
+
return await getOwnerInfoFromLattice(latticeDomain);
+
},
+
});
+
const { semantic } = useCurrentPalette();
+
+
return (
+
<View style={{ flexDirection: "row", gap: 6, alignItems: "center" }}>
+
{isLoading ? (
+
<Loading size="small" />
+
) : (
+
<>
+
<Text>{latticeDomain}</Text>
+
{latticeInfo ? (
+
latticeInfo.registered ? (
+
<BadgeCheck
+
height={16}
+
width={16}
+
color={semantic.positive}
+
/>
+
) : (
+
<X
+
height={16}
+
width={16}
+
color={semantic.negative}
+
/>
+
)
+
) : (
+
<X height={16} width={16} color={semantic.negative} />
+
)}
+
</>
+
)}
+
</View>
+
);
+
};
+183 -13
src/components/Settings/LatticeSettings.tsx
···
import { Text } from "@/components/primitives/Text";
import { useFacet } from "@/lib/facet";
import { useCurrentPalette } from "@/providers/ThemeProvider";
-
import { View } from "react-native";
export const LatticeSettings = () => {
const { semantic } = useCurrentPalette();
const { atoms, typography } = useFacet();
-
return (
<View
style={{
borderWidth: 1,
borderColor: semantic.borderVariant,
borderRadius: atoms.radii.lg,
-
padding: 8,
}}
>
-
<Text
-
style={[
-
typography.weights.byName.medium,
-
typography.sizes.lg,
-
{
-
paddingLeft: 8,
-
},
-
]}
>
-
Lattices
-
</Text>
</View>
);
};
···
+
import { Loading } from "@/components/primitives/Loading";
import { Text } from "@/components/primitives/Text";
+
import { LatticeInfo } from "@/components/Settings/LatticeInfo";
+
import { RegisterLatticeModalContent } from "@/components/Settings/RegisterLatticeModalContent";
import { useFacet } from "@/lib/facet";
+
import { fade } from "@/lib/facet/src/lib/colors";
+
import type { AtUri } from "@/lib/types/atproto";
+
import { stringToAtUri } from "@/lib/utils/atproto";
+
import { useOAuthSessionGuaranteed } from "@/providers/OAuthProvider";
import { useCurrentPalette } from "@/providers/ThemeProvider";
+
import { getUserLattices } from "@/queries/get-lattices-from-pds";
+
import type { OAuthSession } from "@atproto/oauth-client";
+
import { useQuery } from "@tanstack/react-query";
+
import { Gem, Plus, Waypoints } from "lucide-react-native";
+
import { useState } from "react";
+
import { Modal, Pressable, View } from "react-native";
export const LatticeSettings = () => {
const { semantic } = useCurrentPalette();
const { atoms, typography } = useFacet();
+
const session = useOAuthSessionGuaranteed();
+
const [showRegisterModal, setShowRegisterModal] = useState(false);
+
const { data: lattices, isLoading } = useQuery({
+
queryKey: ["lattice", session.did],
+
queryFn: async () => {
+
return await latticeQueryFn(session);
+
},
+
});
+
+
return isLoading ? (
+
<Loading />
+
) : (
<View
style={{
borderWidth: 1,
borderColor: semantic.borderVariant,
borderRadius: atoms.radii.lg,
+
padding: 12,
+
paddingVertical: 16,
+
gap: 16,
+
width: "50%",
}}
>
+
<View
+
style={{
+
flexDirection: "row",
+
alignItems: "center",
+
marginLeft: 6,
+
gap: 6,
+
}}
>
+
<Waypoints height={20} width={20} color={semantic.text} />
+
<Text
+
style={[
+
typography.weights.byName.medium,
+
typography.sizes.xl,
+
]}
+
>
+
Lattices
+
</Text>
+
</View>
+
{lattices && lattices.length > 0 && (
+
<View style={{ marginLeft: 10, gap: 8 }}>
+
<View
+
style={{
+
flexDirection: "row",
+
alignItems: "center",
+
gap: 4,
+
}}
+
>
+
<Gem height={16} width={16} color={semantic.text} />
+
<Text style={[typography.weights.byName.normal]}>
+
Your Lattices
+
</Text>
+
</View>
+
<View
+
style={{
+
gap: 4,
+
marginLeft: 8,
+
}}
+
>
+
{lattices.map((shard, idx) => (
+
<LatticeInfo key={idx} shard={shard} />
+
))}
+
</View>
+
</View>
+
)}
+
<View>
+
<Pressable
+
style={{
+
flexDirection: "row",
+
alignItems: "center",
+
marginLeft: 10,
+
gap: 4,
+
backgroundColor: semantic.primary,
+
alignSelf: "flex-start",
+
padding: 8,
+
paddingRight: 12,
+
borderRadius: atoms.radii.md,
+
}}
+
onPress={() => {
+
setShowRegisterModal(true);
+
}}
+
>
+
<Plus height={16} width={16} color={semantic.textInverse} />
+
<Text
+
style={[
+
typography.weights.byName.normal,
+
{ color: semantic.textInverse },
+
]}
+
>
+
Register a Lattice
+
</Text>
+
</Pressable>
+
<Modal
+
visible={showRegisterModal}
+
onRequestClose={() => {
+
setShowRegisterModal(!showRegisterModal);
+
}}
+
animationType="fade"
+
transparent={true}
+
>
+
<Pressable
+
style={{
+
flex: 1,
+
cursor: "auto",
+
alignItems: "center",
+
justifyContent: "center",
+
backgroundColor: fade(
+
semantic.backgroundDarker,
+
60,
+
),
+
}}
+
onPress={() => {
+
setShowRegisterModal(false);
+
}}
+
>
+
<Pressable
+
style={{
+
flex: 0,
+
cursor: "auto",
+
alignItems: "center",
+
}}
+
onPress={(e) => {
+
e.stopPropagation();
+
}}
+
>
+
<RegisterLatticeModalContent
+
setShowRegisterModal={setShowRegisterModal}
+
/>
+
</Pressable>
+
</Pressable>
+
</Modal>
+
</View>
</View>
);
};
+
+
const latticeQueryFn = async (session: OAuthSession) => {
+
const shards = await getUserLattices({
+
pdsEndpoint: session.serverMetadata.issuer,
+
did: session.did,
+
});
+
+
if (!shards.ok) {
+
console.error("shardQueryFn error.", shards.error);
+
throw new Error(
+
`Something went wrong while getting the user's membership records.}`,
+
);
+
}
+
+
const results = shards.data
+
.map((record) => {
+
const convertResult = stringToAtUri(record.uri);
+
if (!convertResult.ok) {
+
console.error(
+
"Could not convert",
+
record,
+
"into at:// URI object.",
+
convertResult.error,
+
);
+
return;
+
}
+
if (!convertResult.data.collection || !convertResult.data.rKey) {
+
console.error(
+
record,
+
"did not convert to a full at:// URI with collection and rkey.",
+
);
+
return;
+
}
+
const uri: Required<AtUri> = {
+
authority: convertResult.data.authority,
+
collection: convertResult.data.collection,
+
rKey: convertResult.data.rKey,
+
};
+
return { uri, value: record.value };
+
})
+
.filter((atUri) => atUri !== undefined);
+
+
return results;
+
};
+127
src/components/Settings/RegisterLatticeModalContent.tsx
···
···
+
import { Loading } from "@/components/primitives/Loading";
+
import { Text } from "@/components/primitives/Text";
+
import { useFacet } from "@/lib/facet";
+
import { registerNewLattice } from "@/lib/utils/gmstn";
+
import {
+
useOAuthAgentGuaranteed,
+
useOAuthSessionGuaranteed,
+
} from "@/providers/OAuthProvider";
+
import { useCurrentPalette } from "@/providers/ThemeProvider";
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
+
import type { Dispatch, SetStateAction } from "react";
+
import { useState } from "react";
+
import { Pressable, TextInput, View } from "react-native";
+
+
export const RegisterLatticeModalContent = ({
+
setShowRegisterModal,
+
}: {
+
setShowRegisterModal: Dispatch<SetStateAction<boolean>>;
+
}) => {
+
const { semantic } = useCurrentPalette();
+
const { atoms, typography } = useFacet();
+
const [inputText, setInputText] = useState("");
+
const [registerError, setRegisterError] = useState<string | undefined>(
+
undefined,
+
);
+
const agent = useOAuthAgentGuaranteed();
+
const session = useOAuthSessionGuaranteed();
+
const queryClient = useQueryClient();
+
const { mutate: newShardMutation, isPending: mutationPending } =
+
useMutation({
+
mutationFn: async () => {
+
const registerResult = await registerNewLattice({
+
latticeDomain: inputText,
+
agent,
+
});
+
if (!registerResult.ok) {
+
console.error(
+
"Something went wrong when registering the lattice.",
+
registerResult.error,
+
);
+
throw new Error(
+
`Something went wrong when registering the lattice. ${registerResult.error}`,
+
);
+
}
+
setShowRegisterModal(false);
+
},
+
onSuccess: async () => {
+
await queryClient.invalidateQueries({
+
queryKey: ["lattice", session.did],
+
});
+
setShowRegisterModal(false);
+
},
+
onError: (err) => {
+
console.error(
+
"Something went wrong when registering the lattice.",
+
err,
+
);
+
setRegisterError(err.message);
+
},
+
});
+
+
return (
+
<View
+
style={{
+
backgroundColor: semantic.surface,
+
borderRadius: atoms.radii.lg,
+
display: "flex",
+
gap: 12,
+
padding: 16,
+
}}
+
>
+
<View style={{ gap: 4 }}>
+
<Text>Lattice domain:</Text>
+
<TextInput
+
style={[
+
{
+
flex: 1,
+
borderWidth: 1,
+
borderColor: semantic.borderVariant,
+
borderRadius: 8,
+
paddingHorizontal: 10,
+
paddingVertical: 10,
+
color: semantic.text,
+
outline: "0",
+
fontFamily: typography.families.primary,
+
width: 256,
+
},
+
typography.weights.byName.extralight,
+
typography.sizes.sm,
+
]}
+
value={inputText}
+
onChangeText={setInputText}
+
placeholder="lattice.gmstn.systems"
+
placeholderTextColor={semantic.textPlaceholder}
+
/>
+
</View>
+
<Pressable
+
style={{
+
backgroundColor: inputText.trim()
+
? semantic.primary
+
: registerError
+
? semantic.error
+
: semantic.border,
+
borderRadius: atoms.radii.lg,
+
alignItems: "center",
+
paddingVertical: 10,
+
}}
+
onPress={() => {
+
newShardMutation();
+
}}
+
>
+
{mutationPending ? (
+
<Loading size="small" />
+
) : (
+
<Text
+
style={[
+
typography.weights.byName.normal,
+
{ color: semantic.textInverse },
+
]}
+
>
+
Register
+
</Text>
+
)}
+
</Pressable>
+
</View>
+
);
+
};
+39
src/lib/utils/gmstn.ts
···
return { ok: true };
};
···
return { ok: true };
};
+
export const registerNewLattice = async ({
+
latticeDomain: latticeDomain,
+
agent,
+
}: {
+
latticeDomain: string;
+
agent: Agent;
+
}): Promise<Result<undefined, string>> => {
+
if (!isDomain(latticeDomain))
+
return { ok: false, error: "Input was not a valid domain." };
+
+
const now = new Date().toISOString();
+
+
const record: Omit<SystemsGmstnDevelopmentShard, "$type"> = {
+
// @ts-expect-error we want to explicitly use the ISO string variant
+
createdAt: now,
+
// TODO: actually figure out how to support the description
+
description: "A Gemstone Systems Lattice.",
+
};
+
console.log(record);
+
+
const { success } = await agent.call(
+
"com.atproto.repo.createRecord",
+
{},
+
{
+
repo: agent.did,
+
collection: "systems.gmstn.development.lattice",
+
rkey: latticeDomain,
+
record,
+
},
+
);
+
+
if (!success)
+
return {
+
ok: false,
+
error: "Attempted to create lattice record failed. Check the domain inputs.",
+
};
+
+
return { ok: true };
+
};
+27 -4
src/queries/get-lattices-from-pds.ts
···
}: {
pdsEndpoint: string;
did: Did;
-
}): Promise<Result<Array<SystemsGmstnDevelopmentLattice>, unknown>> => {
const handler = simpleFetchHandler({ service: pdsEndpoint });
const client = new Client({ handler });
const shardRecordsResult = await fetchRecords({
···
}: {
client: Client;
did: Did;
-
}): Promise<Result<Array<SystemsGmstnDevelopmentLattice>, unknown>> => {
-
const allRecords: Array<SystemsGmstnDevelopmentLattice> = [];
let cursor: string | undefined;
let continueLoop = true;
···
if (!success) return { ok: false, error: z.treeifyError(error) };
-
allRecords.push(...responses.map((data) => data.value));
if (records.length < 100) continueLoop = false;
cursor = nextCursor;
···
}: {
pdsEndpoint: string;
did: Did;
+
}): Promise<
+
Result<
+
Array<{
+
uri: string;
+
value: SystemsGmstnDevelopmentLattice;
+
}>,
+
unknown
+
>
+
> => {
const handler = simpleFetchHandler({ service: pdsEndpoint });
const client = new Client({ handler });
const shardRecordsResult = await fetchRecords({
···
}: {
client: Client;
did: Did;
+
}): Promise<
+
Result<
+
Array<{
+
uri: string;
+
value: SystemsGmstnDevelopmentLattice;
+
}>,
+
unknown
+
>
+
> => {
+
const allRecords: Array<{
+
uri: string;
+
value: SystemsGmstnDevelopmentLattice;
+
}> = [];
let cursor: string | undefined;
let continueLoop = true;
···
if (!success) return { ok: false, error: z.treeifyError(error) };
+
allRecords.push(
+
...responses.map((data) => {
+
return { uri: data.uri, value: data.value };
+
}),
+
);
if (records.length < 100) continueLoop = false;
cursor = nextCursor;
+45
src/queries/get-owner-info-from-lattice.ts
···
···
+
import {
+
getOwnerDidResponseSchema,
+
httpSuccessResponseSchema,
+
} from "@/lib/types/http/responses";
+
import { z } from "zod";
+
+
export const getOwnerInfoFromLattice = async (latticeDomain: string) => {
+
const reqUrl = new URL(
+
(latticeDomain.startsWith("localhost")
+
? `http://${latticeDomain}`
+
: `https://${latticeDomain}`) +
+
"/xrpc/systems.gmstn.development.lattice.getOwner",
+
);
+
const req = new Request(reqUrl);
+
const res = await fetch(req);
+
const data: unknown = await res.json();
+
+
const {
+
success: httpResponseParseSuccess,
+
error: httpResponseParseError,
+
data: httpResponse,
+
} = httpSuccessResponseSchema.safeParse(data);
+
if (!httpResponseParseSuccess) {
+
console.error(
+
"Could not get lattice's owner info.",
+
z.treeifyError(httpResponseParseError),
+
);
+
return;
+
}
+
+
const {
+
success,
+
error,
+
data: result,
+
} = getOwnerDidResponseSchema.safeParse(httpResponse.data);
+
+
if (!success) {
+
console.error(
+
"Could not get lattice's owner info.",
+
z.treeifyError(error),
+
);
+
return;
+
}
+
return result;
+
};