Testing implementation for private data in ATProto with ATPKeyserver and ATCute tools
2
fork

Configure Feed

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

basic profile follow UI

+146 -33
+106
packages/client/app/lib/follow.server.ts
··· 1 + import { getServiceAgent, getSessionAgent } from '@www/lib/xrpcClient' 2 + import { env } from './env.server' 3 + import { ok } from '@atcute/client' 4 + import type { Did } from '@atcute/lexicons' 5 + import type { OAuthSession } from '@atproto/oauth-client-node' 6 + import * as TID from '@atcute/tid' 7 + 8 + const serviceDid = () => 9 + `did:web:${encodeURIComponent(new URL(env.API_URL).host)}` as const 10 + 11 + /** 12 + * Get the bidirectional follow relationship between two users 13 + */ 14 + export async function getRelationship( 15 + session: OAuthSession, 16 + actorDid: Did, 17 + viewerDid: Did 18 + ) { 19 + const serverClient = await getServiceAgent(session, serviceDid()) 20 + const result = await ok( 21 + serverClient.get('app.wafrn.graph.getRelationship', { 22 + params: { 23 + actor: actorDid, 24 + viewer: viewerDid 25 + } 26 + }) 27 + ) 28 + return result 29 + } 30 + 31 + /** 32 + * Follow a user 33 + * Creates follow record on user's PDS and caches it on the server 34 + */ 35 + export async function followUser( 36 + session: OAuthSession, 37 + followeeDid: Did 38 + ): Promise<void> { 39 + const repoClient = getSessionAgent(session) 40 + const serverClient = await getServiceAgent(session, serviceDid()) 41 + 42 + // Create follow record on user's PDS 43 + const record = { 44 + $type: 'app.wafrn.graph.follow', 45 + subject: followeeDid, 46 + createdAt: new Date().toISOString() 47 + } 48 + 49 + const { uri } = await ok( 50 + repoClient.post('com.atproto.repo.putRecord', { 51 + input: { 52 + collection: 'app.wafrn.graph.follow', 53 + record, 54 + repo: session.did, 55 + rkey: TID.now() 56 + } 57 + }) 58 + ) 59 + 60 + // Cache follow on server 61 + await ok( 62 + serverClient.post('app.wafrn.graph.cacheFollow', { 63 + input: { 64 + follower: session.did, 65 + followee: followeeDid, 66 + uri, 67 + createdAt: record.createdAt 68 + } 69 + }) 70 + ) 71 + } 72 + 73 + /** 74 + * Unfollow a user 75 + * Deletes from server cache and removes the follow record from PDS 76 + */ 77 + export async function unfollowUser( 78 + session: OAuthSession, 79 + followeeDid: Did 80 + ): Promise<void> { 81 + const repoClient = getSessionAgent(session) 82 + const serverClient = await getServiceAgent(session, serviceDid()) 83 + 84 + // Delete from server cache and get the follow record URI 85 + const result = await ok( 86 + serverClient.post('app.wafrn.graph.deleteFollow', { 87 + input: { 88 + follower: session.did, 89 + followee: followeeDid 90 + } 91 + }) 92 + ) 93 + 94 + // Delete the follow record from PDS if we got a URI 95 + if (result.uri) { 96 + await ok( 97 + repoClient.post('com.atproto.repo.deleteRecord', { 98 + input: { 99 + repo: session.did, 100 + collection: 'app.wafrn.graph.follow', 101 + rkey: result.uri.split('/').pop()! // Extract rkey from URI 102 + } 103 + }) 104 + ) 105 + } 106 + }
+40 -33
packages/client/app/routes/profile.$handle.tsx
··· 4 4 import { Form, useLoaderData, useNavigation } from 'react-router' 5 5 import PostFeed from '@www/components/PostFeed' 6 6 import asyncWrap from '@www/lib/asyncWrap' 7 - import { useRootData } from '@www/lib/useRootData' 8 7 import { getOAuthSession } from '@www/lib/oauth.server' 9 8 import { getSessionAgent, getServiceAgent } from '@www/lib/xrpcClient' 10 - import { followUser, unfollowUser, getRelationship } from '@www/lib/follow.server' 9 + import { 10 + followUser, 11 + unfollowUser, 12 + getRelationship 13 + } from '@www/lib/follow.server' 11 14 import { idResolver } from '@www/lib/idResolver.server' 12 15 import { env } from '@www/lib/env.server' 13 16 import { ok } from '@atcute/client' ··· 15 18 export async function loader({ request, params }: Route.LoaderArgs) { 16 19 const session = await getOAuthSession(request) 17 20 const sessionAgent = getSessionAgent(session) 18 - const serviceDid = `did:web:${encodeURIComponent(new URL(env.API_URL).host)}` as const 21 + const serviceDid = 22 + `did:web:${encodeURIComponent(new URL(env.API_URL).host)}` as const 19 23 const serverClient = await getServiceAgent(session, serviceDid) 20 24 21 25 // Resolve handle to DID 22 - const profileDid = await idResolver.handle.resolve(params.handle as Handle) as Did 26 + const profileDid = (await idResolver.handle.resolve( 27 + params.handle as Handle 28 + )) as Did 23 29 24 30 if (!profileDid) { 25 31 throw new Response('Profile not found', { status: 404 }) 26 32 } 27 33 28 34 // Fetch all data in parallel 29 - const [feedResult, standardProfile, wafrnProfiles, relationship] = await Promise.all([ 30 - asyncWrap(() => getFeed(request, params.handle as Handle)), 31 - asyncWrap(() => 32 - ok( 33 - sessionAgent.get('app.bsky.actor.getProfile', { 34 - params: { actor: profileDid } 35 - }) 36 - ) 37 - ), 38 - asyncWrap(() => 39 - ok( 40 - serverClient.get('app.wafrn.actor.getProfiles', { 41 - params: { actors: [profileDid], includeCounts: true } 42 - }) 43 - ) 44 - ), 45 - asyncWrap(() => getRelationship(session, profileDid, session.did)) 46 - ]) 35 + const [feedResult, profileResult, wafrnProfilesResult, relationshipResult] = 36 + await Promise.all([ 37 + asyncWrap(() => getFeed(request, params.handle as Handle)), 38 + asyncWrap(() => 39 + ok( 40 + sessionAgent.get('app.bsky.actor.getProfile', { 41 + params: { actor: profileDid } 42 + }) 43 + ) 44 + ), 45 + asyncWrap(() => 46 + ok( 47 + serverClient.get('app.wafrn.actor.getProfiles', { 48 + params: { actors: [profileDid], includeCounts: true } 49 + }) 50 + ) 51 + ), 52 + asyncWrap(() => getRelationship(session, profileDid, session.did)) 53 + ]) 47 54 48 55 const [feed, feedError] = feedResult 49 - const [profile] = standardProfile 50 - const [wafrnData] = wafrnProfiles 51 - const [relationshipData] = relationship 56 + const [profile, profileError] = profileResult 57 + const [wafrnProfile, wafrnProfileError] = wafrnProfilesResult 58 + const [relationship, relationshipError] = relationshipResult 59 + const error = 60 + feedError || profileError || wafrnProfileError || relationshipError 52 61 53 62 return { 54 63 handle: params.handle, 55 64 feed: feed ?? [], 56 - feedError, 65 + error, 57 66 profile: profile ?? null, 58 - wafrnProfile: wafrnData?.profiles?.[0] ?? null, 59 - relationship: relationshipData ?? { following: false, followedBy: false }, 67 + wafrnProfile: wafrnProfile?.profiles?.[0] ?? null, 68 + relationship: relationship ?? { following: false, followedBy: false }, 60 69 profileDid, 61 70 isMe: session.did === profileDid 62 71 } ··· 84 93 export default function ProfilePage() { 85 94 const { 86 95 feed, 87 - feedError, 96 + error, 88 97 handle, 89 98 profile, 90 99 wafrnProfile, ··· 96 105 const isSubmitting = navigation.state === 'submitting' 97 106 98 107 // Check for auth errors 99 - const isAuthError = feedError?.status === 401 || feedError?.status === 403 108 + const isAuthError = error?.status === 401 || error?.status === 403 100 109 101 110 if (isAuthError) { 102 111 return ( ··· 200 209 </div> 201 210 202 211 {/* Posts Feed */} 203 - {feedError && ( 204 - <p className="text-center py-4 text-error">{feedError.message}</p> 205 - )} 212 + {error && <p className="text-center py-4 text-error">{error.message}</p>} 206 213 <PostFeed feed={feed} /> 207 214 </div> 208 215 )