···11# SkeetLonger
22+33+Client-side web app that helps create longer Bluesky posts, while maintaining the ATPro values of credible exit and allowing users to own their own data.
44+55+## Notes
66+77+This is mostly a project built to further my own understanding of the ATProtocol. Expect some jank.
88+99+## Acknowledgements
1010+1111+[whey.party](https://tangled.org/@whey.party) - for their UnifiedAuthProvider so I wouldn't have to think much on OAuth\
···11+import { StrictMode } from 'react'
22+import { createRoot } from 'react-dom/client'
33+import './index.css'
44+import App from './App.tsx'
55+66+createRoot(document.getElementById('root')!).render(
77+ <StrictMode>
88+ <App />
99+ </StrictMode>,
1010+)
+207
src/providers/UnifiedAuthProvider.tsx
···11+// src/providers/UnifiedAuthProvider.tsx
22+// Import both Agent and the (soon to be deprecated) AtpAgent
33+import { Agent, AtpAgent, type AtpSessionData } from "@atproto/api";
44+import {
55+ type OAuthSession,
66+ TokenInvalidError,
77+ TokenRefreshError,
88+ TokenRevokedError,
99+} from "@atproto/oauth-client-browser";
1010+import React, {
1111+ createContext,
1212+ use,
1313+ useCallback,
1414+ useEffect,
1515+ useState,
1616+} from "react";
1717+1818+import { oauthClient } from "../utils/oauthClient"; // Adjust path if needed
1919+2020+// Define the unified status and authentication method
2121+type AuthStatus = "loading" | "signedIn" | "signedOut";
2222+type AuthMethod = "password" | "oauth" | null;
2323+2424+interface AuthContextValue {
2525+ agent: Agent | null; // The agent is typed as the base class `Agent`
2626+ status: AuthStatus;
2727+ authMethod: AuthMethod;
2828+ loginWithPassword: (
2929+ user: string,
3030+ password: string,
3131+ service?: string,
3232+ ) => Promise<void>;
3333+ loginWithOAuth: (handleOrPdsUrl: string) => Promise<void>;
3434+ logout: () => Promise<void>;
3535+}
3636+3737+const AuthContext = createContext<AuthContextValue>({} as AuthContextValue);
3838+3939+export const UnifiedAuthProvider = ({
4040+ children,
4141+}: {
4242+ children: React.ReactNode;
4343+}) => {
4444+ // The state is typed as the base class `Agent`, which accepts both `Agent` and `AtpAgent` instances.
4545+ const [agent, setAgent] = useState<Agent | null>(null);
4646+ const [status, setStatus] = useState<AuthStatus>("loading");
4747+ const [authMethod, setAuthMethod] = useState<AuthMethod>(null);
4848+ const [oauthSession, setOauthSession] = useState<OAuthSession | null>(null);
4949+5050+ // Unified Initialization Logic
5151+ const initialize = useCallback(async () => {
5252+ // --- 1. Try OAuth initialization first ---
5353+ try {
5454+ console.log("Initializing OAuth.");
5555+ const oauthResult = await oauthClient.init();
5656+ if (oauthResult) {
5757+ console.log("OAuth session restored.");
5858+ const apiAgent = new Agent(oauthResult.session); // Standard Agent
5959+ setAgent(apiAgent);
6060+ setOauthSession(oauthResult.session);
6161+ setAuthMethod("oauth");
6262+ setStatus("signedIn");
6363+ return; // Success
6464+ }
6565+ } catch (e) {
6666+ console.error("OAuth init failed, checking password session.", e);
6767+ }
6868+6969+ // --- 2. If no OAuth, try password-based session using AtpAgent ---
7070+ try {
7171+ const service = localStorage.getItem("service");
7272+ const sessionString = localStorage.getItem("sess");
7373+7474+ if (service && sessionString) {
7575+ // /*mass comment*/ console.log("Resuming password-based session using AtpAgent...");
7676+ // Use the original, working AtpAgent logic
7777+ const apiAgent = new AtpAgent({ service });
7878+ const session: AtpSessionData = JSON.parse(sessionString);
7979+ await apiAgent.resumeSession(session);
8080+8181+ // /*mass comment*/ console.log("Password-based session resumed successfully.");
8282+ setAgent(apiAgent); // This works because AtpAgent is a subclass of Agent
8383+ setAuthMethod("password");
8484+ setStatus("signedIn");
8585+ return; // Success
8686+ }
8787+ } catch (e) {
8888+ console.error("Failed to resume password-based session.", e);
8989+ localStorage.removeItem("sess");
9090+ localStorage.removeItem("service");
9191+ }
9292+9393+ // --- 3. If neither worked, user is signed out ---
9494+ // /*mass comment*/ console.log("No active session found.");
9595+ setStatus("signedOut");
9696+ setAgent(null);
9797+ setAuthMethod(null);
9898+ }, []);
9999+100100+ useEffect(() => {
101101+ const handleOAuthSessionDeleted = (
102102+ event: CustomEvent<{ sub: string; cause: TokenRefreshError | TokenRevokedError | TokenInvalidError }>,
103103+ ) => {
104104+ console.error(`OAuth Session for ${event.detail.sub} was deleted.`, event.detail.cause);
105105+ setAgent(null);
106106+ setOauthSession(null);
107107+ setAuthMethod(null);
108108+ setStatus("signedOut");
109109+ };
110110+111111+ oauthClient.addEventListener("deleted", handleOAuthSessionDeleted as EventListener);
112112+ initialize();
113113+114114+ return () => {
115115+ oauthClient.removeEventListener("deleted", handleOAuthSessionDeleted as EventListener);
116116+ };
117117+ }, [initialize]);
118118+119119+ // --- Login Methods ---
120120+ const loginWithPassword = async (
121121+ user: string,
122122+ password: string,
123123+ service: string = "https://pds.minito.dev",
124124+ ) => {
125125+ if (status !== "signedOut") return;
126126+ setStatus("loading");
127127+ try {
128128+ let sessionData: AtpSessionData | undefined;
129129+ // Use the AtpAgent for its simple login and session persistence
130130+ const apiAgent = new AtpAgent({
131131+ service,
132132+ persistSession: (_evt, sess) => {
133133+ sessionData = sess;
134134+ },
135135+ });
136136+ await apiAgent.login({ identifier: user, password });
137137+138138+ if (sessionData) {
139139+ localStorage.setItem("service", service);
140140+ localStorage.setItem("sess", JSON.stringify(sessionData));
141141+ setAgent(apiAgent); // Store the AtpAgent instance in our state
142142+ setAuthMethod("password");
143143+ setStatus("signedIn");
144144+ console.log("Successfully logged in with password.");
145145+ } else {
146146+ throw new Error("Session data not persisted after login.");
147147+ }
148148+ } catch (e) {
149149+ console.error("Password login failed:", e);
150150+ setStatus("signedOut");
151151+ throw e;
152152+ }
153153+ };
154154+155155+ const loginWithOAuth = useCallback(async (handleOrPdsUrl: string) => {
156156+ if (status !== "signedOut") return;
157157+ try {
158158+ sessionStorage.setItem("postLoginRedirect", window.location.pathname + window.location.search);
159159+ await oauthClient.signIn(handleOrPdsUrl);
160160+ } catch (err) {
161161+ console.error("OAuth sign-in aborted or failed:", err);
162162+ }
163163+ }, [status]);
164164+165165+ // --- Unified Logout ---
166166+ const logout = useCallback(async () => {
167167+ if (status !== "signedIn" || !agent) return;
168168+ setStatus("loading");
169169+170170+ try {
171171+ if (authMethod === "oauth" && oauthSession) {
172172+ await oauthClient.revoke(oauthSession.sub);
173173+ // /*mass comment*/ console.log("OAuth session revoked.");
174174+ } else if (authMethod === "password") {
175175+ localStorage.removeItem("service");
176176+ localStorage.removeItem("sess");
177177+ // AtpAgent has its own logout methods
178178+ await (agent as AtpAgent).com.atproto.server.deleteSession();
179179+ // /*mass comment*/ console.log("Password-based session deleted.");
180180+ }
181181+ } catch (e) {
182182+ console.error("Logout failed:", e);
183183+ } finally {
184184+ setAgent(null);
185185+ setAuthMethod(null);
186186+ setOauthSession(null);
187187+ setStatus("signedOut");
188188+ }
189189+ }, [status, authMethod, agent, oauthSession]);
190190+191191+ return (
192192+ <AuthContext
193193+ value={{
194194+ agent,
195195+ status,
196196+ authMethod,
197197+ loginWithPassword,
198198+ loginWithOAuth,
199199+ logout,
200200+ }}
201201+ >
202202+ {children}
203203+ </AuthContext>
204204+ );
205205+};
206206+207207+export const useAuth = () => use(AuthContext);
+35
src/routeTree.gen.ts
···11+/* eslint-disable */
22+33+// @ts-nocheck
44+55+// noinspection JSUnusedGlobalSymbols
66+77+// This file was automatically generated by TanStack Router.
88+// You should NOT make any changes in this file as it will be overwritten.
99+// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
1010+1111+import { Route as rootRouteImport } from './routes/__root'
1212+1313+export interface FileRoutesByFullPath {}
1414+export interface FileRoutesByTo {}
1515+export interface FileRoutesById {
1616+ __root__: typeof rootRouteImport
1717+}
1818+export interface FileRouteTypes {
1919+ fileRoutesByFullPath: FileRoutesByFullPath
2020+ fullPaths: never
2121+ fileRoutesByTo: FileRoutesByTo
2222+ to: never
2323+ id: '__root__'
2424+ fileRoutesById: FileRoutesById
2525+}
2626+export interface RootRouteChildren {}
2727+2828+declare module '@tanstack/react-router' {
2929+ interface FileRoutesByPath {}
3030+}
3131+3232+const rootRouteChildren: RootRouteChildren = {}
3333+export const routeTree = rootRouteImport
3434+ ._addFileChildren(rootRouteChildren)
3535+ ._addFileTypes<FileRouteTypes>()
···11+import { BrowserOAuthClient, type ClientMetadata } from '@atproto/oauth-client-browser';
22+33+// i tried making this https://pds-nd.whey.party but cors is annoying as fuck
44+const handleResolverPDS = 'https://bsky.social';
55+66+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
77+// @ts-ignore this should be fine ? the vite plugin should generate this before errors
88+import clientMetadata from '../../public/client-metadata.json' with { type: 'json' };
99+1010+export const oauthClient = new BrowserOAuthClient({
1111+ // The type assertion is needed because the static import isn't strictly typed
1212+ clientMetadata: clientMetadata as ClientMetadata,
1313+ handleResolver: handleResolverPDS,
1414+});