[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

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

auth with pds etc

+1021 -80
+124
api/pdsAuth.ts
··· 1 + import { PROD_PDS } from "@/constants/atproto"; 2 + import { createSimpleDIDResolver } from "./simpleDIDResolver"; 3 + import sessionManager from "./sessionManager"; 4 + import { Agent, CredentialSession } from "@atproto/api"; 5 + 6 + // Create the resolver once at module level 7 + const resolver = createSimpleDIDResolver(); 8 + 9 + /** 10 + * Login to an AT Protocol PDS (Personal Data Server) using a handle and password 11 + * 12 + * @param handle - The user handle (e.g., username.bsky.social) 13 + * @param password - The user's password 14 + * @returns The session data object or null if login failed 15 + */ 16 + export const pdsLogin = async (handle: string, password: string) => { 17 + try { 18 + const resolverAgent = new Agent(new URL(PROD_PDS)); 19 + const handleRes = await resolverAgent.resolveHandle({ 20 + handle: handle, 21 + }); 22 + 23 + if (!handleRes.success) { 24 + throw new Error("Handle not found"); 25 + } 26 + 27 + // Use the new resolver to get the DID doc 28 + const didDoc = await resolver.resolveHandleToDidDoc(handle); 29 + 30 + // Get the PDS URL from the DID document service endpoints 31 + const pdsUrl = didDoc.pds; 32 + 33 + if (!pdsUrl) { 34 + throw new Error("Could not find PDS URL for this account"); 35 + } 36 + 37 + const pdsCredentialSession = new CredentialSession(new URL(pdsUrl)); 38 + 39 + const agent = new Agent(pdsCredentialSession); 40 + 41 + const { success, data } = await agent.com.atproto.server.createSession({ 42 + identifier: handle, 43 + password, 44 + }); 45 + 46 + if (!success) { 47 + throw new Error("Login was not successful"); 48 + } 49 + 50 + const session = { 51 + ...data, 52 + active: data.active ?? false, 53 + }; 54 + 55 + sessionManager.setSession(agent, session); 56 + 57 + return data; 58 + } catch (error) { 59 + console.error("Login failed:", error); 60 + throw error; 61 + } 62 + }; 63 + 64 + export const pdsRegister = async ( 65 + email: string, 66 + handle: string, 67 + password: string, 68 + inviteCode?: string 69 + ) => { 70 + try { 71 + const cred = new CredentialSession(new URL(PROD_PDS)); 72 + 73 + const agent = new Agent(cred); 74 + 75 + const { success, data } = await agent.com.atproto.server.createAccount({ 76 + email, 77 + handle, 78 + password, 79 + inviteCode, 80 + }); 81 + 82 + if (!success) { 83 + throw new Error("Register was not successful"); 84 + } 85 + 86 + const session = { 87 + ...data, 88 + active: true, 89 + }; 90 + 91 + sessionManager.setSession(agent, session); 92 + } catch (error) { 93 + console.error("Register failed:", error); 94 + throw error; 95 + } 96 + }; 97 + 98 + /** 99 + * Logout the current user 100 + */ 101 + export const pdsLogout = () => { 102 + sessionManager.clearSession(); 103 + }; 104 + 105 + /** 106 + * Get the current session if available 107 + */ 108 + export const getCurrentSession = () => { 109 + return sessionManager.getSession(); 110 + }; 111 + 112 + /** 113 + * Get the current agent if logged in 114 + */ 115 + export const getAgent = () => { 116 + return sessionManager.getAgent(); 117 + }; 118 + 119 + /** 120 + * Check if user is currently logged in 121 + */ 122 + export const isLoggedIn = () => { 123 + return sessionManager.isLoggedIn(); 124 + };
+196
api/sessionManager.ts
··· 1 + import EventEmitter from "events"; 2 + import { Agent, AtpSessionData, CredentialSession } from "@atproto/api"; 3 + 4 + export enum SessionEventType { 5 + CREATE = "session:create", 6 + REFRESH = "session:refresh", 7 + EXPIRED = "session:expired", 8 + DELETE = "session:delete", 9 + } 10 + 11 + /** 12 + * SessionManager handles storing and managing AT Protocol sessions 13 + * It maintains the session data globally and emits events when the session changes 14 + */ 15 + class SessionManager extends EventEmitter { 16 + private agent: Agent | null = null; 17 + private sessionData: AtpSessionData | null = null; 18 + private refreshTimeout: NodeJS.Timeout | null = null; 19 + private persistKey: string = "atproto_session"; 20 + private pdsUrl: string = ""; 21 + 22 + constructor() { 23 + super(); 24 + this.loadSession(); 25 + } 26 + 27 + /** 28 + * Get the current Agent instance 29 + */ 30 + getAgent() { 31 + return this.agent; 32 + } 33 + 34 + /** 35 + * Get the current session data 36 + */ 37 + getSession() { 38 + return this.sessionData; 39 + } 40 + 41 + /** 42 + * Store a new session 43 + */ 44 + setSession(agent: Agent, session: AtpSessionData): void { 45 + this.agent = agent; 46 + this.sessionData = session; 47 + this.pdsUrl = session.accessJwt 48 + ? JSON.parse(atob(session.accessJwt.split(".")[1])).aud.replace( 49 + "did:web:", 50 + "" 51 + ) 52 + : ""; 53 + this.persistSession(); 54 + // this.scheduleRefresh(); 55 + this.emit(SessionEventType.CREATE, session); 56 + } 57 + 58 + // /** 59 + // * Refresh the current session 60 + // */ 61 + // async refreshSession(): Promise<any> { 62 + // if (!this.agent || !this.sessionData) { 63 + // return null; 64 + // } 65 + 66 + // try { 67 + // // For refreshing with the Agent class 68 + // if (this.sessionData.refreshJwt) { 69 + // // Use the refresh method if available 70 + // if (typeof this.agent.refreshSession === "function") { 71 + // const result = await this.agent.refreshSession(); 72 + // if (result && result.data) { 73 + // this.sessionData = result.data; 74 + // this.persistSession(); 75 + // this.scheduleRefresh(); 76 + // this.emit(SessionEventType.REFRESH, result.data); 77 + // return result.data; 78 + // } 79 + // } else { 80 + // const cred = new CredentialSession(new URL(this.sessionData.pds)); 81 + // } 82 + // } 83 + 84 + // // If refresh failed or isn't available 85 + // this.clearSession(); 86 + // this.emit(SessionEventType.EXPIRED); 87 + // return null; 88 + // } catch (error) { 89 + // console.error("Failed to refresh session:", error); 90 + // this.clearSession(); 91 + // this.emit(SessionEventType.EXPIRED); 92 + // return null; 93 + // } 94 + // } 95 + 96 + /** 97 + * Clear the current session (logout) 98 + */ 99 + clearSession(): void { 100 + if (this.refreshTimeout) { 101 + clearTimeout(this.refreshTimeout); 102 + this.refreshTimeout = null; 103 + } 104 + 105 + this.agent = null; 106 + this.sessionData = null; 107 + this.pdsUrl = ""; 108 + this.removePersistedSession(); 109 + this.emit(SessionEventType.DELETE); 110 + } 111 + 112 + /** 113 + * Check if a user is currently logged in 114 + */ 115 + isLoggedIn(): boolean { 116 + return !!this.sessionData && !!this.agent; 117 + } 118 + 119 + /** 120 + * Schedule a session refresh before it expires 121 + */ 122 + // private scheduleRefresh(): void { 123 + // if (this.refreshTimeout) { 124 + // clearTimeout(this.refreshTimeout); 125 + // } 126 + 127 + // // If no session, don't schedule refresh 128 + // if (!this.sessionData) return; 129 + 130 + // // Default refresh time - 30 minutes before expiry 131 + // // Since we don't have a specific expiry time from the session, 132 + // // we'll use a conservative 23.5 hour refresh interval by default 133 + // const refreshInterval = 23.5 * 60 * 60 * 1000; // 23.5 hours 134 + 135 + // // Schedule refresh 136 + // this.refreshTimeout = setTimeout( 137 + // () => this.refreshSession(), 138 + // refreshInterval 139 + // ); 140 + // } 141 + 142 + /** 143 + * Persist session to storage 144 + */ 145 + private persistSession(): void { 146 + if (typeof localStorage !== "undefined" && this.sessionData) { 147 + localStorage.setItem(this.persistKey, JSON.stringify(this.sessionData)); 148 + } 149 + } 150 + 151 + /** 152 + * Remove persisted session from storage 153 + */ 154 + private removePersistedSession(): void { 155 + if (typeof localStorage !== "undefined") { 156 + localStorage.removeItem(this.persistKey); 157 + } 158 + } 159 + 160 + /** 161 + * Load session from storage on startup 162 + */ 163 + private loadSession(): void { 164 + if (typeof localStorage !== "undefined") { 165 + try { 166 + const savedSession = localStorage.getItem(this.persistKey); 167 + if (savedSession) { 168 + this.sessionData = JSON.parse(savedSession); 169 + 170 + // Create a new agent with the saved data 171 + if (this.sessionData && this.sessionData.did) { 172 + if (!this.pdsUrl) { 173 + throw new Error("Could not find PDS URL for this account"); 174 + } 175 + const cred = new CredentialSession(new URL(this.pdsUrl)); 176 + 177 + cred.resumeSession(this.sessionData); 178 + 179 + const agent = new Agent(cred); 180 + 181 + // Restore the session 182 + this.agent = agent; 183 + // this.scheduleRefresh(); 184 + } 185 + } 186 + } catch (error) { 187 + console.error("Failed to load persisted session:", error); 188 + this.removePersistedSession(); 189 + } 190 + } 191 + } 192 + } 193 + 194 + // Create a singleton instance 195 + export const sessionManager = new SessionManager(); 196 + export default sessionManager;
+178
api/simpleDIDResolver.ts
··· 1 + import { Agent } from "@atproto/api"; 2 + import { AtprotoData } from "@atproto/identity"; 3 + import sessionManager from "./sessionManager"; 4 + import { PROD_PDS } from "@/constants/atproto"; 5 + 6 + // Interface for BidirectionalResolver to avoid circular imports 7 + export interface DIDResolver { 8 + resolveDidToHandle(did: string): Promise<string>; 9 + resolveHandleToDidDoc(handle: string): Promise<AtprotoData>; 10 + resolveDidsToHandles(dids: string[]): Promise<Record<string, string>>; 11 + } 12 + 13 + // Cache durations 14 + const ONE_HOUR = 60 * 60 * 1000; 15 + const ONE_DAY = ONE_HOUR * 24; 16 + 17 + // Simple implementation of caches with expiration 18 + class SimpleCache<T> { 19 + private cache = new Map<string, { value: T; expiry: number }>(); 20 + private ttl: number; 21 + 22 + constructor(ttlMs: number) { 23 + this.ttl = ttlMs; 24 + } 25 + 26 + get(key: string): T | undefined { 27 + const item = this.cache.get(key); 28 + if (!item) return undefined; 29 + 30 + if (Date.now() > item.expiry) { 31 + this.cache.delete(key); 32 + return undefined; 33 + } 34 + 35 + return item.value; 36 + } 37 + 38 + set(key: string, value: T): void { 39 + this.cache.set(key, { 40 + value, 41 + expiry: Date.now() + this.ttl, 42 + }); 43 + } 44 + 45 + delete(key: string): void { 46 + this.cache.delete(key); 47 + } 48 + } 49 + 50 + /** 51 + * Create a simple DID resolver with caching that implements DIDResolver 52 + */ 53 + export function createSimpleDIDResolver(): DIDResolver { 54 + // Create caches 55 + const didDocCache = new SimpleCache<AtprotoData>(ONE_HOUR); 56 + const handleToDIDCache = new SimpleCache<string>(ONE_DAY); 57 + const didToHandleCache = new SimpleCache<string>(ONE_DAY); 58 + 59 + // Helper function to get agent or create a new one 60 + const getAgent = () => { 61 + const agent = sessionManager.getAgent(); 62 + if (agent) return agent; 63 + return new Agent(new URL(PROD_PDS)); 64 + }; 65 + 66 + // Helper function to resolve DID document (not exposed in interface) 67 + const resolveDidDocHelper = async (did: string): Promise<AtprotoData> => { 68 + // Check cache first 69 + const cachedDoc = didDocCache.get(did); 70 + if (cachedDoc) return cachedDoc; 71 + 72 + try { 73 + // Get agent 74 + const agent = getAgent(); 75 + 76 + // Use plc.directory as fallback 77 + const didResponse = await fetch(`https://plc.directory/${did}`); 78 + 79 + if (!didResponse.ok) { 80 + throw new Error(`Failed to resolve DID document for ${did}`); 81 + } 82 + 83 + const didData = await didResponse.json(); 84 + const atprotoData: AtprotoData = { 85 + did: didData.did, 86 + handle: didData.alsoKnownAs?.[0]?.replace('at://', '') || did, 87 + pds: didData.service?.find((s: any) => s.id === '#atproto_pds')?.serviceEndpoint || '', 88 + signingKey: didData.verificationMethod?.[0]?.publicKeyMultibase || '', 89 + }; 90 + 91 + // Cache the document 92 + didDocCache.set(did, atprotoData); 93 + 94 + return atprotoData; 95 + } catch (error) { 96 + console.error("Error resolving DID document:", error); 97 + throw error; 98 + } 99 + }; 100 + 101 + // The object that implements DIDResolver 102 + return { 103 + /** 104 + * Resolve a DID to a handle with caching 105 + */ 106 + async resolveDidToHandle(did: string): Promise<string> { 107 + // Check cache first 108 + const cachedHandle = didToHandleCache.get(did); 109 + if (cachedHandle) return cachedHandle; 110 + 111 + try { 112 + // Resolve DID document using helper function 113 + const didDoc = await resolveDidDocHelper(did); 114 + 115 + // Cache the handle 116 + if (didDoc.handle) { 117 + didToHandleCache.set(did, didDoc.handle); 118 + return didDoc.handle; 119 + } 120 + 121 + return did; // Fallback to DID if no handle 122 + } catch (error) { 123 + console.error("Error resolving DID to handle:", error); 124 + return did; // Return the DID as fallback 125 + } 126 + }, 127 + 128 + /** 129 + * Resolve a handle to a DID document with caching 130 + */ 131 + async resolveHandleToDidDoc(handle: string): Promise<AtprotoData> { 132 + try { 133 + // Check if we already have the DID in cache 134 + const cachedDid = handleToDIDCache.get(handle); 135 + if (cachedDid) { 136 + // Check if we have the DID doc cached 137 + const cachedDoc = didDocCache.get(cachedDid); 138 + if (cachedDoc) return cachedDoc; 139 + } 140 + 141 + // Get agent 142 + const agent = getAgent(); 143 + 144 + // Resolve handle to DID 145 + const didResult = await agent.resolveHandle({ handle }); 146 + const did = didResult.data.did; 147 + 148 + // Cache the handle to DID mapping 149 + handleToDIDCache.set(handle, did); 150 + 151 + // Then resolve the DID document 152 + return await resolveDidDocHelper(did); 153 + } catch (error) { 154 + console.error("Error resolving handle to DID doc:", error); 155 + throw new Error(`Failed to resolve handle: ${handle}`); 156 + } 157 + }, 158 + 159 + /** 160 + * Resolve multiple DIDs to handles 161 + */ 162 + async resolveDidsToHandles(dids: string[]): Promise<Record<string, string>> { 163 + const didHandleMap: Record<string, string> = {}; 164 + 165 + // Use Promise.all to resolve all DIDs in parallel 166 + const resolves = await Promise.all( 167 + dids.map((did) => this.resolveDidToHandle(did).catch(() => did)) 168 + ); 169 + 170 + // Map results to DIDs 171 + for (let i = 0; i < dids.length; i++) { 172 + didHandleMap[dids[i]] = resolves[i]; 173 + } 174 + 175 + return didHandleMap; 176 + } 177 + }; 178 + }
+53 -13
app/(auth)/Login.tsx
··· 3 3 import Logo from '@/components/global/Logo'; 4 4 import { ThemedText } from '@/components/ThemedText'; 5 5 import { Colors } from '@/constants/Colors'; 6 + import { pdsLogin } from '@/api/pdsAuth'; 6 7 import { Ionicons } from '@expo/vector-icons'; 7 8 import { router } from 'expo-router'; 8 9 import React, { useState } from 'react'; 9 - import { View, StyleSheet, useColorScheme, TouchableOpacity, SafeAreaView } from 'react-native'; 10 + import { View, StyleSheet, useColorScheme, TouchableOpacity, SafeAreaView, Alert, ActivityIndicator } from 'react-native'; 10 11 11 12 export default function Login() { 12 13 ··· 25 26 fontWeight: 'bold', 26 27 color: '#333', 27 28 }, 29 + errorText: { 30 + color: 'red', 31 + marginBottom: 10, 32 + textAlign: 'center', 33 + }, 28 34 }); 29 35 30 - const [email, setEmail] = useState(''); 36 + const [handle, setHandle] = useState(''); 37 + const [password, setPassword] = useState(''); 38 + const [loading, setLoading] = useState(false); 39 + const [error, setError] = useState(''); 40 + 41 + const handleLogin = async () => { 42 + if (!handle || !password) { 43 + setError('Please enter both handle and password'); 44 + return; 45 + } 31 46 32 - const [password, setPassword] = useState(''); 47 + try { 48 + setLoading(true); 49 + setError(''); 50 + 51 + // Call the pdsLogin function from our API 52 + await pdsLogin(handle, password); 53 + 54 + // If successful, navigate to the app's main screen 55 + router.replace('/(tabs)'); 56 + } catch (err) { 57 + console.error('Login error:', err); 58 + setError(err instanceof Error ? err.message : 'Failed to login. Please try again.'); 59 + } finally { 60 + setLoading(false); 61 + } 62 + }; 33 63 34 64 return ( 35 65 <SafeAreaView style={styles.container}> ··· 40 70 </TouchableOpacity> 41 71 <Logo size={14} color={Colors[colorScheme ?? 'light'].selectedIcon} style={{ marginBottom: 50 }} /> 42 72 <View style={{ width: '80%' }}> 73 + {error ? <ThemedText style={styles.errorText}>{error}</ThemedText> : null} 74 + 43 75 <InputArea 44 - label='Email' 45 - placeholder='Enter your email' 46 - icon='mail' 47 - type='email' 76 + label='Handle' 77 + placeholder='username.bsky.social' 78 + icon='at' 79 + type='text' 48 80 inputStyle={{ width: '100%' }} 49 - value={email} 50 - onChangeText={setEmail} 81 + value={handle} 82 + onChangeText={setHandle} 51 83 style={{ width: "100%" }} 52 84 /> 53 85 <InputArea ··· 62 94 /> 63 95 64 96 <ActionButton 65 - title="Login" 66 - onPress={() => { 67 - 68 - }} 97 + title={loading ? "Logging in..." : "Login"} 98 + onPress={handleLogin} 99 + disabled={loading} 69 100 /> 101 + 102 + {loading && <ActivityIndicator size="large" color={Colors[colorScheme ?? 'light'].tint} style={{ marginTop: 20 }} />} 103 + 104 + <TouchableOpacity 105 + onPress={() => router.push('/(auth)/Register')} 106 + style={{ marginTop: 20, alignItems: 'center' }} 107 + > 108 + <ThemedText>Don't have an account? Register</ThemedText> 109 + </TouchableOpacity> 70 110 </View> 71 111 </SafeAreaView> 72 112 );
+88 -20
app/(auth)/Register.tsx
··· 3 3 import Logo from '@/components/global/Logo'; 4 4 import { ThemedText } from '@/components/ThemedText'; 5 5 import { Colors } from '@/constants/Colors'; 6 + import { pdsRegister } from '@/api/pdsAuth'; 6 7 import { Ionicons } from '@expo/vector-icons'; 7 8 import { router } from 'expo-router'; 8 9 import React, { useState } from 'react'; 9 - import { View, StyleSheet, useColorScheme, TouchableOpacity, SafeAreaView, Dimensions } from 'react-native'; 10 + import { View, StyleSheet, useColorScheme, TouchableOpacity, SafeAreaView, Dimensions, ActivityIndicator } from 'react-native'; 10 11 11 12 export default function Register() { 12 13 ··· 19 20 alignItems: 'center', 20 21 justifyContent: 'center', 21 22 backgroundColor: Colors[colorScheme ?? 'light'].background, 22 - // backgroundColor: 'blue', 23 23 width: '100%', 24 24 }, 25 25 text: { 26 26 fontSize: 18, 27 27 fontWeight: 'bold', 28 28 }, 29 + errorText: { 30 + color: 'red', 31 + marginBottom: 10, 32 + textAlign: 'center', 33 + }, 29 34 }); 30 35 31 36 const [email, setEmail] = useState(''); 37 + const [handle, setHandle] = useState(''); 38 + const [password, setPassword] = useState(''); 39 + const [inviteCode, setInviteCode] = useState(''); 40 + const [loading, setLoading] = useState(false); 41 + const [error, setError] = useState(''); 32 42 33 - const [password, setPassword] = useState(''); 43 + const handleRegister = async () => { 44 + if (!email || !handle || !password) { 45 + setError('Please fill in all required fields'); 46 + return; 47 + } 34 48 35 - const [birthDate, setBirthDate] = useState(''); 49 + // Simple email validation 50 + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 51 + if (!emailRegex.test(email)) { 52 + setError('Please enter a valid email address'); 53 + return; 54 + } 55 + 56 + // Simple handle validation 57 + if (!handle.includes('.')) { 58 + setError('Handle must be in format username.bsky.social'); 59 + return; 60 + } 61 + 62 + // Password length check 63 + if (password.length < 6) { 64 + setError('Password must be at least 6 characters'); 65 + return; 66 + } 67 + 68 + try { 69 + setLoading(true); 70 + setError(''); 71 + 72 + // Call the pdsRegister function from our API 73 + await pdsRegister(email, handle, password, inviteCode || undefined); 74 + 75 + // If successful, navigate to the app's main screen 76 + router.replace('/(tabs)'); 77 + } catch (err) { 78 + console.error('Registration error:', err); 79 + setError(err instanceof Error ? err.message : 'Failed to register. Please try again.'); 80 + } finally { 81 + setLoading(false); 82 + } 83 + }; 36 84 37 85 return ( 38 86 <SafeAreaView style={styles.container}> ··· 43 91 </TouchableOpacity> 44 92 <Logo size={14} color={Colors[colorScheme ?? 'light'].selectedIcon} style={{ marginBottom: 50 }} /> 45 93 <View style={{ width: '100%', alignItems: 'center' }}> 94 + {error ? <ThemedText style={styles.errorText}>{error}</ThemedText> : null} 95 + 46 96 <InputArea 47 97 label='Email' 48 98 placeholder='Enter your email' ··· 53 103 onChangeText={setEmail} 54 104 style={{ width: "90%" }} 55 105 /> 106 + 107 + <InputArea 108 + label='Handle' 109 + placeholder='username.sprk.so' 110 + icon='at' 111 + type='text' 112 + inputStyle={{ width: '100%' }} 113 + value={handle} 114 + onChangeText={setHandle} 115 + style={{ width: "90%" }} 116 + /> 117 + 56 118 <InputArea 57 119 label='Password' 58 120 placeholder='Enter your password' ··· 61 123 inputStyle={{ width: '90%' }} 62 124 value={password} 63 125 onChangeText={setPassword} 64 - style={{ width: "90%", marginBottom: 25 }} 126 + style={{ width: "90%", marginBottom: 15 }} 65 127 /> 128 + 66 129 <InputArea 67 - label='Birth Date' 68 - placeholder='26/11/2002' 69 - icon='calendar' 70 - type='birthDate' 71 - format='mmddyyyy' 130 + label='Invite Code (Optional)' 131 + placeholder='Enter invite code if you have one' 132 + icon='key' 133 + type='text' 72 134 inputStyle={{ width: '90%' }} 73 - value={birthDate} 74 - onChangeText={setBirthDate} 75 - style={{ width: "90%", marginBottom: 55 }} 135 + value={inviteCode} 136 + onChangeText={setInviteCode} 137 + style={{ width: "90%", marginBottom: 30 }} 76 138 /> 139 + 77 140 <ActionButton 78 - title="Next" 79 - onPress={() => { 80 - console.log(password) 81 - console.log(birthDate) 82 - console.log(email) 83 - 84 - }} 141 + title={loading ? "Registering..." : "Register"} 142 + onPress={handleRegister} 143 + disabled={loading} 85 144 width={'90%'} 86 145 /> 146 + 147 + {loading && <ActivityIndicator size="large" color={Colors[colorScheme ?? 'light'].tint} style={{ marginTop: 20 }} />} 148 + 149 + <TouchableOpacity 150 + onPress={() => router.push('/(auth)/Login')} 151 + style={{ marginTop: 20, alignItems: 'center' }} 152 + > 153 + <ThemedText>Already have an account? Login</ThemedText> 154 + </TouchableOpacity> 87 155 </View> 88 156 </SafeAreaView> 89 157 );
+95 -42
app/(tabs)/ProfileScreen.tsx
··· 20 20 import { UserProps, PostProps } from '@/types/Interfaces'; 21 21 import { router, useRouter } from 'expo-router'; 22 22 import { getProfile, getProfileMedia } from '@/api/profileServices'; 23 + import useAtProto from '@/hooks/useAtProto'; 23 24 24 25 function padVideosWithPlaceholders( 25 26 videos: (PostProps & { isPlaceholder?: boolean })[] ··· 63 64 const colorScheme = useColorScheme(); 64 65 const route = useRouter(); 65 66 66 - const isLoggedIn = true; 67 - const isMine = !true; 67 + // Get actual login status and user data from useAtProto hook 68 + const { isLoggedIn, session, agent, logout } = useAtProto(); 69 + 70 + // This is the user's own profile if logged in 71 + const isMine = true; 72 + 73 + // User's DID comes from session when logged in, otherwise use mock DID 74 + const userDid = isLoggedIn && session ? session.did : did; 68 75 69 76 const [userData, setUserData] = useState<UserProps | null>(null); 70 77 const [videoPosts, setVideoPosts] = useState<PostProps[]>([]); 71 - 78 + 72 79 const loadVideoPosts = async () => { 73 80 try { 74 - const mediaPosts = await getProfileMedia(did, 'video'); 81 + // Use the actual user DID when logged in 82 + const mediaPosts = await getProfileMedia(userDid, 'video'); 75 83 const posts = mediaPosts.map((item: any) => item.post); 76 84 setVideoPosts(posts); 77 85 } catch (error) { ··· 84 92 if (isLoggedIn || (!isLoggedIn && !isMine)) { 85 93 const loadProfileData = async () => { 86 94 try { 87 - const profileData = await getProfile(did); 95 + // Use the actual user DID when logged in 96 + const profileData = await getProfile(userDid); 88 97 if (profileData) { 89 98 setUserData({ 90 99 id: profileData.did, 91 100 did: profileData.did, 92 - displayName: profileData.displayName, 93 - handle: profileData.handle, 94 - description: profileData.description, 101 + displayName: profileData.displayName || (session?.handle || ''), 102 + handle: profileData.handle || (session?.handle || ''), 103 + description: profileData.description || '', 95 104 avatar: profileData.avatar || '', 96 105 banner: profileData.banner || '', 97 - followersCount: profileData.followersCount, 98 - followsCount: profileData.followsCount, 99 - postsCount: profileData.postsCount, 106 + followersCount: profileData.followersCount || 0, 107 + followsCount: profileData.followsCount || 0, 108 + postsCount: profileData.postsCount || 0, 100 109 associated: profileData.associated, 101 110 joinedViaStarterPack: profileData.joinedViaStarterPack, 102 - indexedAt: profileData.indexedAt, 103 - createdAt: profileData.createdAt, 111 + indexedAt: profileData.indexedAt || '', 112 + createdAt: profileData.createdAt || '', 104 113 viewer: profileData.viewer, 105 - labels: profileData.labels, 114 + labels: profileData.labels || [], 106 115 pinnedPost: profileData.pinnedPost, 107 116 }); 108 117 } 109 118 } catch (error) { 110 119 console.error('Error loading profile:', error); 120 + 121 + // If there's an error loading the profile but we're logged in, 122 + // at least display some basic info from the session 123 + if (isLoggedIn && session) { 124 + setUserData({ 125 + id: session.did, 126 + did: session.did, 127 + displayName: session.handle || 'My Profile', 128 + handle: session.handle || '', 129 + description: '', 130 + avatar: '', 131 + banner: '', 132 + followersCount: 0, 133 + followsCount: 0, 134 + postsCount: 0, 135 + indexedAt: '', 136 + createdAt: '', 137 + labels: [], 138 + }); 139 + } 111 140 } 112 141 }; 113 - 142 + 114 143 loadProfileData(); 115 144 loadVideoPosts(); 116 145 } else if (!isLoggedIn && isMine) { ··· 131 160 labels: [], 132 161 }); 133 162 } 134 - }, [isLoggedIn, isMine]); 163 + }, [isLoggedIn, isMine, session, userDid]); 135 164 136 165 const paddedVideoData = padVideosWithPlaceholders(videoPosts); 137 166 ··· 154 183 router.push('/(auth)/Login', { relativeToDirectory: true }); 155 184 } 156 185 } 186 + 187 + // Handle logout 188 + const handleLogout = () => { 189 + logout(); 190 + // You might want to navigate to a different screen or refresh the UI 191 + console.log('User logged out'); 192 + }; 157 193 158 194 const styles = StyleSheet.create({ 159 195 container: { ··· 222 258 gap: 10, 223 259 width: '100%', 224 260 }, 261 + loginStatusContainer: { 262 + flexDirection: 'row', 263 + alignItems: 'center', 264 + marginTop: 10, 265 + }, 266 + loginStatusIndicator: { 267 + width: 10, 268 + height: 10, 269 + borderRadius: 5, 270 + backgroundColor: Colors[colorScheme ?? 'light'].selectedIcon, 271 + marginRight: 5, 272 + }, 273 + loginStatusText: { 274 + color: Colors[colorScheme ?? 'light'].text, 275 + }, 225 276 }); 226 277 227 278 return ( ··· 242 293 </TouchableOpacity> 243 294 244 295 <ThemedText style={styles.profileTopText}> 245 - {userData?.displayName ?? ''} 296 + {isLoggedIn ? 'My Profile' : userData?.displayName ?? ''} 246 297 </ThemedText> 247 298 248 299 <TouchableOpacity onPress={() => {}}> 249 - {/* No seu caso, pode ser "ellipsis-horizontal" ou vazio */} 250 - <Ionicons 251 - name="ellipsis-horizontal" 252 - size={24} 253 - color={Colors[colorScheme ?? 'light'].text} 254 - /> 300 + {isLoggedIn ? ( 301 + <Ionicons 302 + name="settings-outline" 303 + size={24} 304 + color={Colors[colorScheme ?? 'light'].text} 305 + /> 306 + ) : ( 307 + <Ionicons 308 + name="ellipsis-horizontal" 309 + size={24} 310 + color={Colors[colorScheme ?? 'light'].text} 311 + /> 312 + )} 255 313 </TouchableOpacity> 256 314 </View> 257 315 ··· 260 318 {userData && <ProfilePicture userData={userData} />} 261 319 {userData && <ProfileInfo userData={userData} />} 262 320 263 - 321 + {/* Login status indicator */} 322 + {isLoggedIn && ( 323 + <View style={styles.loginStatusContainer}> 324 + <View style={styles.loginStatusIndicator} /> 325 + <ThemedText style={styles.loginStatusText}>Logged in as {session?.handle}</ThemedText> 326 + </View> 327 + )} 328 + 264 329 { 265 330 !isLoggedIn && isMine && ( 266 331 // Not logged in and viewing own profile => show Login / Register buttons ··· 307 372 } 308 373 { 309 374 isLoggedIn && isMine && ( 310 - // Logged in and viewing own profile => show edit and share buttons 311 - <View style={styles.profileActionButtons}> 312 - <ActionButton 313 - type="secondary" 314 - title="Edit Profile" 315 - onPress={() => { 316 - console.log("Editar perfil"); 317 - }} 318 - width={140} 319 - icon="create" 320 - /> 375 + // Logged in and viewing own profile => Show logout button 376 + <View style={styles.profileActionButtonsVertical}> 321 377 <ActionButton 322 - type="secondary" 323 - title="" 324 - onPress={() => { 325 - console.log("Compartilhar perfil"); 326 - }} 327 - width={70} 328 - icon="share-social" 378 + type="outline" 379 + title="Logout" 380 + onPress={handleLogout} 381 + width="60%" 329 382 /> 330 383 </View> 331 384 )
+175
components/AtProtoLogin.tsx
··· 1 + import React, { useState } from 'react' 2 + import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator, Alert } from 'react-native' 3 + import useAtProto from '../hooks/useAtProto' 4 + 5 + /** 6 + * A simple login component for AT Protocol authentication 7 + */ 8 + export const AtProtoLogin: React.FC = () => { 9 + const [identifier, setIdentifier] = useState('') 10 + const [password, setPassword] = useState('') 11 + const [isEmailLogin, setIsEmailLogin] = useState(false) 12 + const { login, loginWithEmail, loading, error, isLoggedIn, session, logout } = useAtProto() 13 + 14 + const handleLogin = async () => { 15 + if (!identifier || !password) { 16 + Alert.alert('Error', 'Please enter both username/email and password') 17 + return 18 + } 19 + 20 + try { 21 + if (isEmailLogin) { 22 + await loginWithEmail(identifier, password) 23 + } else { 24 + await login(identifier, password) 25 + } 26 + // Clear inputs on success 27 + setIdentifier('') 28 + setPassword('') 29 + } catch (err) { 30 + // Error is already handled by the hook 31 + console.error('Login error:', err) 32 + } 33 + } 34 + 35 + const handleLogout = () => { 36 + logout() 37 + } 38 + 39 + const toggleLoginMethod = () => { 40 + setIsEmailLogin(!isEmailLogin) 41 + setIdentifier('') 42 + } 43 + 44 + if (isLoggedIn && session) { 45 + return ( 46 + <View style={styles.container}> 47 + <Text style={styles.title}>Logged In</Text> 48 + <Text style={styles.userInfo}>Handle: {session.handle}</Text> 49 + <Text style={styles.userInfo}>DID: {session.did}</Text> 50 + {session.email && <Text style={styles.userInfo}>Email: {session.email}</Text>} 51 + 52 + <TouchableOpacity style={styles.button} onPress={handleLogout}> 53 + <Text style={styles.buttonText}>Logout</Text> 54 + </TouchableOpacity> 55 + </View> 56 + ) 57 + } 58 + 59 + return ( 60 + <View style={styles.container}> 61 + <Text style={styles.title}> 62 + {isEmailLogin ? 'Login with Email' : 'Login with Handle'} 63 + </Text> 64 + 65 + <TouchableOpacity 66 + style={styles.toggleButton} 67 + onPress={toggleLoginMethod} 68 + > 69 + <Text style={styles.toggleText}> 70 + {isEmailLogin 71 + ? 'Switch to handle login' 72 + : 'Switch to email login'} 73 + </Text> 74 + </TouchableOpacity> 75 + 76 + <TextInput 77 + style={styles.input} 78 + placeholder={isEmailLogin ? "Email" : "Handle (e.g., user.bsky.social)"} 79 + value={identifier} 80 + onChangeText={setIdentifier} 81 + autoCapitalize="none" 82 + keyboardType={isEmailLogin ? "email-address" : "default"} 83 + /> 84 + 85 + <TextInput 86 + style={styles.input} 87 + placeholder="Password" 88 + value={password} 89 + onChangeText={setPassword} 90 + secureTextEntry 91 + /> 92 + 93 + {error && ( 94 + <Text style={styles.errorText}> 95 + {error.message || 'Login failed. Please try again.'} 96 + </Text> 97 + )} 98 + 99 + <TouchableOpacity 100 + style={[styles.button, loading && styles.buttonDisabled]} 101 + onPress={handleLogin} 102 + disabled={loading} 103 + > 104 + {loading ? ( 105 + <ActivityIndicator color="#fff" /> 106 + ) : ( 107 + <Text style={styles.buttonText}>Login</Text> 108 + )} 109 + </TouchableOpacity> 110 + </View> 111 + ) 112 + } 113 + 114 + const styles = StyleSheet.create({ 115 + container: { 116 + padding: 20, 117 + backgroundColor: '#fff', 118 + borderRadius: 8, 119 + shadowColor: '#000', 120 + shadowOffset: { width: 0, height: 2 }, 121 + shadowOpacity: 0.1, 122 + shadowRadius: 4, 123 + elevation: 2, 124 + width: '100%', 125 + maxWidth: 400, 126 + }, 127 + title: { 128 + fontSize: 24, 129 + fontWeight: 'bold', 130 + marginBottom: 20, 131 + textAlign: 'center', 132 + }, 133 + input: { 134 + borderWidth: 1, 135 + borderColor: '#ddd', 136 + borderRadius: 8, 137 + padding: 12, 138 + marginBottom: 16, 139 + fontSize: 16, 140 + }, 141 + button: { 142 + backgroundColor: '#3498db', 143 + padding: 15, 144 + borderRadius: 8, 145 + alignItems: 'center', 146 + marginTop: 10, 147 + }, 148 + buttonDisabled: { 149 + backgroundColor: '#95a5a6', 150 + }, 151 + buttonText: { 152 + color: '#fff', 153 + fontSize: 16, 154 + fontWeight: 'bold', 155 + }, 156 + errorText: { 157 + color: '#e74c3c', 158 + marginBottom: 10, 159 + }, 160 + toggleButton: { 161 + marginBottom: 15, 162 + alignSelf: 'center', 163 + }, 164 + toggleText: { 165 + color: '#3498db', 166 + fontSize: 14, 167 + }, 168 + userInfo: { 169 + fontSize: 16, 170 + marginBottom: 10, 171 + fontFamily: 'monospace', 172 + }, 173 + }) 174 + 175 + export default AtProtoLogin
+9 -5
components/global/ActionButton.tsx
··· 3 3 import React from 'react'; 4 4 import { TouchableOpacity, Text, StyleSheet, ActivityIndicator, useColorScheme, DimensionValue } from 'react-native'; 5 5 6 - interface ActionButtonProps { 6 + export interface ActionButtonProps { 7 7 title: string; 8 8 onPress: () => void; 9 9 type?: 'primary' | 'secondary' | 'outline' | 'disabled'; 10 10 icon?: string; 11 11 isLoading?: boolean; 12 12 width?: string | number; 13 + disabled?: boolean; 13 14 } 14 15 15 16 const ActionButton: React.FC<ActionButtonProps> = ({ ··· 19 20 isLoading = false, 20 21 width, 21 22 icon, 23 + disabled = false, 22 24 }) => { 23 25 24 26 const colorScheme = useColorScheme(); ··· 55 57 color: Colors[colorScheme ?? 'light'].text, 56 58 }, 57 59 }); 58 - 60 + 61 + const buttonType = disabled ? 'disabled' : type; 62 + 59 63 return ( 60 64 <TouchableOpacity 61 65 style={[ 62 66 styles.button, 63 - styles[type], 64 - { width: width as DimensionValue }, 67 + styles[buttonType], 68 + { width: width as DimensionValue }, 65 69 ]} 66 70 onPress={onPress} 67 - disabled={type === 'disabled' || isLoading} 71 + disabled={disabled || type === 'disabled' || isLoading} 68 72 > 69 73 {icon && <Ionicons name={icon as any} size={25} color={Colors.dark.text} />} 70 74 {isLoading ? (
+9
constants/atproto.ts
··· 1 + /** 2 + * AT Protocol service endpoints 3 + */ 4 + 5 + // Production endpoints 6 + export const PROD_PDS = 'https://pds.sprk.so' 7 + 8 + // Default session timeout in milliseconds (24 hours) 9 + export const SESSION_EXPIRY = 24 * 60 * 60 * 1000
+94
hooks/useAtProto.ts
··· 1 + import { useState, useEffect, useCallback } from 'react' 2 + import { 3 + pdsLogin, 4 + pdsLogout, 5 + getCurrentSession, 6 + getAgent, 7 + isLoggedIn 8 + } from '../api/pdsAuth' 9 + import sessionManager, { SessionEventType } from '../api/sessionManager' 10 + import { Agent } from '@atproto/api' 11 + 12 + /** 13 + * A React hook for accessing AT Protocol authentication 14 + * Provides login, logout, and session state 15 + */ 16 + export const useAtProto = () => { 17 + const [session, setSession] = useState<any>(getCurrentSession()) 18 + const [agent, setAgent] = useState<Agent | null>(getAgent()) 19 + const [loading, setLoading] = useState<boolean>(false) 20 + const [error, setError] = useState<Error | null>(null) 21 + 22 + // Update state when session changes 23 + useEffect(() => { 24 + // Set initial state 25 + setSession(getCurrentSession()) 26 + setAgent(getAgent()) 27 + 28 + // Listen for session events 29 + const handleSessionCreate = (data: any) => { 30 + setSession(data) 31 + setAgent(getAgent()) 32 + setError(null) 33 + } 34 + 35 + const handleSessionRefresh = (data: any) => { 36 + setSession(data) 37 + } 38 + 39 + const handleSessionExpiredOrDeleted = () => { 40 + setSession(null) 41 + setAgent(null) 42 + } 43 + 44 + // Add event listeners 45 + sessionManager.on(SessionEventType.CREATE, handleSessionCreate) 46 + sessionManager.on(SessionEventType.REFRESH, handleSessionRefresh) 47 + sessionManager.on(SessionEventType.EXPIRED, handleSessionExpiredOrDeleted) 48 + sessionManager.on(SessionEventType.DELETE, handleSessionExpiredOrDeleted) 49 + 50 + // Cleanup 51 + return () => { 52 + sessionManager.off(SessionEventType.CREATE, handleSessionCreate) 53 + sessionManager.off(SessionEventType.REFRESH, handleSessionRefresh) 54 + sessionManager.off(SessionEventType.EXPIRED, handleSessionExpiredOrDeleted) 55 + sessionManager.off(SessionEventType.DELETE, handleSessionExpiredOrDeleted) 56 + } 57 + }, []) 58 + 59 + // Login with handle 60 + const login = useCallback(async (handle: string, password: string) => { 61 + setLoading(true) 62 + setError(null) 63 + 64 + try { 65 + const result = await pdsLogin(handle, password) 66 + return result 67 + } catch (err) { 68 + setError(err instanceof Error ? err : new Error('Login failed')) 69 + throw err 70 + } finally { 71 + setLoading(false) 72 + } 73 + }, []) 74 + 75 + // Logout 76 + const logout = useCallback(() => { 77 + pdsLogout() 78 + }, []) 79 + 80 + return { 81 + // State 82 + session, 83 + agent, 84 + loading, 85 + error, 86 + isLoggedIn: !!session, 87 + 88 + // Methods 89 + login, 90 + logout, 91 + } 92 + } 93 + 94 + export default useAtProto