frontend client for gemstone. decentralised workplace app

feat: working lattice connection

serenity a69482a3 f26730af

Changed files
+139 -27
src
+2 -7
src/app/(protected)/index.tsx
···
import ChatComponentProfiled from "@/components/ChatComponentProfiled";
-
import { useLatticeSession } from "@/providers/authed/LatticeSessionsProvider";
import { useOAuthSession } from "@/providers/OAuthProvider";
import { Redirect } from "expo-router";
-
import { Text, View } from "react-native";
export default function Index() {
const oAuthSession = useOAuthSession();
-
const sessionsMap = useLatticeSession();
-
const sessions = sessionsMap.values().toArray();
return (
<View
style={{
···
) : (
<Redirect href={"/login"} />
)}
-
{sessions.map((session, idx) => (
-
<Text key={idx}>{session.latticeDid}</Text>
-
))}
</View>
);
}
···
import ChatComponentProfiled from "@/components/ChatComponentProfiled";
import { useOAuthSession } from "@/providers/OAuthProvider";
import { Redirect } from "expo-router";
+
import { View } from "react-native";
export default function Index() {
const oAuthSession = useOAuthSession();
+
return (
<View
style={{
···
) : (
<Redirect href={"/login"} />
)}
</View>
);
}
+1 -3
src/components/ChatComponentProfiled.tsx
···
did: DidPlc | DidWeb;
}) {
const [inputText, setInputText] = useState("");
-
const { messages, isConnected, sendMessage } = useWebSocket(
-
"ws://localhost:8080",
-
);
const handleSend = () => {
if (inputText.trim()) {
···
did: DidPlc | DidWeb;
}) {
const [inputText, setInputText] = useState("");
+
const { messages, isConnected, sendMessage } = useWebSocket();
const handleSend = () => {
if (inputText.trim()) {
+15 -15
src/lib/hooks/useWebSocket.ts
···
validateWsMessageString,
validateWsMessageType,
} from "@/lib/validators";
-
import { useEffect, useRef, useState } from "react";
-
export function useWebSocket(url: string) {
-
const [messages, setMessages] = useState<ShardMessage[]>([]);
const [isConnected, setIsConnected] = useState(false);
-
const ws = useRef<WebSocket | null>(null);
useEffect(() => {
-
// Connect to WebSocket
-
ws.current = new WebSocket(url);
-
-
ws.current.onopen = () => {
console.log("Connected to WebSocket");
setIsConnected(true);
};
-
ws.current.onmessage = (event) => {
const eventData = validateWsMessageString(event.data);
if (!eventData) return;
···
}
};
-
ws.current.onerror = (error) => {
console.error("WebSocket error:", error);
};
-
ws.current.onclose = () => {
console.log("Disconnected from WebSocket");
setIsConnected(false);
};
// Cleanup on unmount
return () => {
-
ws.current?.close();
};
-
}, [url]);
const sendMessage = ({ text, did }: SendMessageOpts) => {
-
if (ws.current?.readyState === WebSocket.OPEN) {
-
ws.current.send(
JSON.stringify({
type: "shard/message",
text,
···
validateWsMessageString,
validateWsMessageType,
} from "@/lib/validators";
+
import { useFirstSessionWsTemp } from "@/providers/authed/SessionsProvider";
+
import { useEffect, useState } from "react";
+
export function useWebSocket() {
+
const [messages, setMessages] = useState<Array<ShardMessage>>([]);
const [isConnected, setIsConnected] = useState(false);
+
const ws = useFirstSessionWsTemp();
useEffect(() => {
+
if(!ws) return;
+
ws.onopen = () => {
console.log("Connected to WebSocket");
setIsConnected(true);
};
+
ws.onmessage = (event) => {
const eventData = validateWsMessageString(event.data);
if (!eventData) return;
···
}
};
+
ws.onerror = (error) => {
console.error("WebSocket error:", error);
};
+
ws.onclose = () => {
console.log("Disconnected from WebSocket");
setIsConnected(false);
};
// Cleanup on unmount
return () => {
+
ws.close();
};
+
}, [ws]);
const sendMessage = ({ text, did }: SendMessageOpts) => {
+
if(!ws) return
+
if (ws.readyState === WebSocket.OPEN) {
+
ws.send(
JSON.stringify({
type: "shard/message",
text,
+15
src/lib/utils/domains.ts
···
···
+
/**
+
* Checks if a string is a domain
+
* does NOT do TLD-level checks. Any domain/NSID-like string will pass this check
+
*/
+
export const isDomain = (str: string) => {
+
try {
+
const url = new URL(str.includes("://") ? str : `https://${str}`);
+
return (
+
(url.hostname.includes(".") && !url.hostname.startsWith(".")) ||
+
url.hostname === "localhost"
+
);
+
} catch {
+
return false;
+
}
+
};
+1 -1
src/providers/authed/ChannelsProvider.tsx
···
const channelsQueries = useQueries({
queries: memberships.map((membershipObjects) => ({
-
enabled: membershipsInitialising,
queryKey: ["channel", membershipObjects.membership.channel.uri],
queryFn: () => channelQueryFn(membershipObjects.channelAtUri),
staleTime: DEFAULT_STALE_TIME,
···
const channelsQueries = useQueries({
queries: memberships.map((membershipObjects) => ({
+
enabled: !membershipsInitialising,
queryKey: ["channel", membershipObjects.membership.channel.uri],
queryFn: () => channelQueryFn(membershipObjects.channelAtUri),
staleTime: DEFAULT_STALE_TIME,
+99
src/providers/authed/SessionsProvider.tsx
···
···
+
import { DEFAULT_STALE_TIME } from "@/lib/consts";
+
import type { AtUri } from "@/lib/types/atproto";
+
import type { LatticeSessionInfo } from "@/lib/types/handshake";
+
import { isDomain } from "@/lib/utils/domains";
+
import { connectToLattice, getLatticeEndpointFromDid } from "@/lib/utils/gmstn";
+
import { useHandshakes } from "@/providers/authed/HandshakesProvider";
+
import { useQueries } from "@tanstack/react-query";
+
import type { ReactNode } from "react";
+
import { createContext, useContext } from "react";
+
+
type SessionsMap = Map<LatticeSessionInfo, WebSocket>;
+
+
interface SessionsContextValue {
+
sessionsMap: SessionsMap;
+
isInitialising: boolean;
+
error: Error | null;
+
getSession: (sessionInfo: LatticeSessionInfo) => WebSocket | undefined;
+
}
+
+
const SessionsContext = createContext<SessionsContextValue | null>(null);
+
+
export const useSessions = () => {
+
const sessionsValue = useContext(SessionsContext);
+
if (!sessionsValue)
+
throw new Error(
+
"Sessions context was null. Did you try to access this outside of the authed providers? SessionsProvider must be below HandshakesProvider.",
+
);
+
return sessionsValue;
+
};
+
+
// TODO: remove this. temp testing function
+
export const useFirstSessionWsTemp = () => {
+
const wss = useSessions().sessionsMap.values().toArray();
+
if(wss.length === 0) return;
+
return wss[0];
+
};
+
+
export const SessionsProvider = ({ children }: { children: ReactNode }) => {
+
const { handshakesMap } = useHandshakes();
+
const handshakes = handshakesMap.entries().toArray();
+
+
const endpointQueries = useQueries({
+
queries: handshakes.map((handshake) => ({
+
queryKey: ["lattice-endpoints", handshake[0].rKey],
+
queryFn: async () => {
+
return await endpointQueryFn(handshake);
+
},
+
staleTime: DEFAULT_STALE_TIME,
+
})),
+
});
+
+
const isInitialising = endpointQueries.some((q) => q.isLoading);
+
const error = endpointQueries.find((q) => q.error)?.error ?? null;
+
+
const sessionsMap = new Map<LatticeSessionInfo, WebSocket>();
+
+
endpointQueries.forEach((q) => {
+
const endpoint = q.data;
+
if (!endpoint) return;
+
const { sessionInfo, shardUrl } = endpoint;
+
const websocket = connectToLattice({
+
shardUrl,
+
sessionToken: sessionInfo.token,
+
});
+
sessionsMap.set(sessionInfo, websocket);
+
});
+
+
const value: SessionsContextValue = {
+
sessionsMap,
+
isInitialising,
+
error,
+
getSession: (sessionInfo: LatticeSessionInfo) =>
+
sessionsMap.get(sessionInfo),
+
};
+
+
return <SessionsContext value={value}>{children}</SessionsContext>;
+
};
+
+
const endpointQueryFn = async (handshake: [AtUri, LatticeSessionInfo]) => {
+
const atUri = handshake[0];
+
const sessionInfo = handshake[1];
+
const rkey = atUri.rKey ?? "";
+
const shardDid = isDomain(rkey)
+
? `did:web:${encodeURIComponent(rkey)}`
+
: `did:plc:${rkey}`;
+
+
// TODO: again, implement proper did -> endpoint parsing here too.
+
// for now, we just assume did:web and construce a URL based on that.
+
// @ts-expect-error trust me bro it's a string
+
const shardUrlResult = await getLatticeEndpointFromDid(shardDid);
+
+
if (!shardUrlResult.ok) return;
+
+
return {
+
// TODO: xrpc and lexicon this endpoint
+
shardUrl: `${shardUrlResult.data.origin}/connect`,
+
sessionInfo,
+
};
+
};
+6 -1
src/providers/authed/index.tsx
···
import { HandshakesProvider } from "@/providers/authed/HandshakesProvider";
import type { ReactNode } from "react";
export const AuthedProviders = ({ children }: { children: ReactNode }) => {
-
return <HandshakesProvider>{children}</HandshakesProvider>;
};
···
import { HandshakesProvider } from "@/providers/authed/HandshakesProvider";
+
import { SessionsProvider } from "@/providers/authed/SessionsProvider";
import type { ReactNode } from "react";
export const AuthedProviders = ({ children }: { children: ReactNode }) => {
+
return (
+
<HandshakesProvider>
+
<SessionsProvider>{children}</SessionsProvider>
+
</HandshakesProvider>
+
);
};