Development Summary: Direct-to-PDS Voting Architecture#

Overview#

This document summarizes the complete voting/like feature implementation with proper atProto architecture, where the mobile client writes directly to the user's Personal Data Server (PDS) instead of through a backend proxy.

Total Changes:

  • 8 files modified (core implementation)
  • 3 test files updated
  • 109 tests passing (107 passing, 2 intentionally skipped)
  • 0 warnings, 0 errors from flutter analyze
  • 7 info-level style suggestions (test files only)

Architecture: The Right Way ✅#

Before (INCORRECT ❌)#

Mobile Client → Backend API (/xrpc/social.coves.interaction.createVote)
                     ↓
              Backend writes to User's PDS
                     ↓
                 Jetstream
                     ↓
        Backend AppView (indexes records)

Problems:

  • ❌ Backend acts as write proxy (violates atProto principles)
  • ❌ AppView writes to PDSs on behalf of users
  • ❌ Doesn't scale across federated network
  • ❌ Creates unnecessary coupling

After (CORRECT ✅)#

Mobile Client → User's PDS (com.atproto.repo.createRecord)
                     ↓
                 Jetstream (broadcasts events)
                     ↓
        Backend AppView (indexes vote events, read-only)
                     ↓
        Feed endpoint returns aggregated stats

Benefits:

  • ✅ Client owns their data on their PDS
  • ✅ Backend only indexes public data (read-only)
  • ✅ Works across entire atProto federation
  • ✅ Follows Bluesky architecture pattern
  • ✅ User's PDS is source of truth

1. Core Voting Implementation#

Vote Record Schema#

Collection Name: social.coves.feed.vote

Record Structure (from backend lexicon):

{
  "$type": "social.coves.feed.vote",
  "subject": {
    "uri": "at://did:plc:community123/social.coves.post.record/3kbx...",
    "cid": "bafy2bzacepostcid123"
  },
  "direction": "up",
  "createdAt": "2025-11-02T12:00:00Z"
}

Strong Reference: The subject field includes both URI and CID to create a strong reference to a specific version of the post.


2. Implementation Details#

lib/services/vote_service.dart (COMPLETE REWRITE - 349 lines)#

New Architecture: Direct PDS XRPC calls instead of backend API

XRPC Endpoints Used:

  • com.atproto.repo.createRecord - Create vote record
  • com.atproto.repo.deleteRecord - Delete vote record
  • com.atproto.repo.listRecords - Find existing votes

Key Features:

  • ✅ Smart toggle logic (query PDS → decide create/delete/switch)
  • ✅ Requires userDid, pdsUrl, and postCid parameters
  • ✅ Returns rkey (record key) for deletion
  • ✅ Handles authentication via token callback
  • ✅ Proper error handling with ApiException

Toggle Logic:

  1. Query PDS for existing vote on this post
  2. If exists with same direction → Delete (toggle off)
  3. If exists with different direction → Delete old + Create new
  4. If no existing vote → Create new

API:

VoteService({
  Future<String?> Function()? tokenGetter,
  String? Function()? didGetter,
  String? Function()? pdsUrlGetter,
})

Future<VoteResponse> createVote({
  required String postUri,
  required String postCid,  // NEW: Required for strong reference
  String direction = 'up',
})

VoteResponse (Updated):

class VoteResponse {
  final String? uri;    // Vote record AT-URI
  final String? cid;    // Vote record content ID
  final String? rkey;   // NEW: Record key for deletion
  final bool deleted;   // True if vote was toggled off
}

lib/providers/vote_provider.dart (MODIFIED)#

Changes:

  • ✅ Added postCid parameter to toggleVote()
  • ✅ Updated VoteState to include rkey field
  • ✅ Extracts rkey from vote URI for deletion

Updated API:

Future<bool> toggleVote({
  required String postUri,
  required String postCid,  // NEW: Pass post CID
  String direction = 'up',
})

VoteState (Enhanced):

class VoteState {
  final String direction;    // "up" or "down"
  final String? uri;         // Vote record URI
  final String? rkey;        // NEW: Record key for deletion
  final bool deleted;
}

rkey Extraction:

// Extract rkey from URI: at://did:plc:xyz/social.coves.feed.vote/3kby...
// Result: "3kby..."
final rkey = voteUri.split('/').last;

lib/providers/auth_provider.dart (NEW METHOD)#

Added PDS URL Helper:

/// Get the user's PDS URL from OAuth session
String? getPdsUrl() {
  if (_session == null) return null;
  return _session!.serverMetadata['issuer'] as String?;
}

This extracts the PDS URL from the OAuth session metadata, enabling direct writes to the user's PDS.


lib/widgets/post_card.dart (MODIFIED)#

Updated Vote Call:

// Before
await voteProvider.toggleVote(postUri: post.post.uri);

// After
await voteProvider.toggleVote(
  postUri: post.post.uri,
  postCid: post.post.cid,  // NEW: Pass CID for strong reference
);

lib/main.dart (MODIFIED)#

Updated VoteService Initialization:

// Initialize vote service with auth callbacks for direct PDS writes
final voteService = VoteService(
  tokenGetter: authProvider.getAccessToken,
  didGetter: () => authProvider.did,        // NEW
  pdsUrlGetter: authProvider.getPdsUrl,     // NEW
);

3. UI Components (Unchanged)#

lib/widgets/sign_in_dialog.dart#

Reusable dialog for prompting authentication when unauthenticated users try to interact.

lib/widgets/icons/animated_heart_icon.dart#

Bluesky-inspired animated heart icon with burst effect.

Animation Phases:

  1. Shrink to 0.8x (150ms)
  2. Expand to 1.3x (250ms)
  3. Settle back to 1.0x (400ms)
  4. Particle burst at peak expansion

Other Icons#

  • reply_icon.dart - Reply icon with filled/outline states
  • share_icon.dart - Share/upload icon with Bluesky styling

4. Test Coverage#

Tests Updated#

test/providers/vote_provider_test.dart (24 tests)

  • ✅ Updated all mocks to include postCid parameter
  • ✅ Updated VoteResponse assertions to check rkey
  • ✅ All tests passing

test/services/vote_service_test.dart (19 tests)

  • ✅ Updated VoteResponse creation to include rkey
  • ✅ Removed obsolete existing field tests
  • ✅ All tests passing

test/widgets/feed_screen_test.dart (6 tests)

  • ✅ Updated FakeVoteProvider to pass new VoteService parameters
  • ✅ All tests passing

Test Results#

$ flutter test
109 tests: 107 passing, 2 skipped
All tests passed! ✅

Analyzer Results#

$ flutter analyze
7 issues found (all info-level style suggestions in test files)
0 warnings, 0 errors ✅

5. Key Architectural Patterns#

Client-Side Direct Writes#

The mobile client writes vote records directly to the user's PDS using atProto XRPC calls, not through a backend proxy.

AppView Read-Only Indexing#

The backend listens to Jetstream events and indexes vote records for aggregated stats in feeds. It never writes to PDSs on behalf of users.

Source of Truth#

The user's PDS is the source of truth for their votes. The client queries the PDS to find existing votes, ensuring consistency.

Optimistic UI Updates (Preserved)#

  1. Immediately update local state
  2. Trigger PDS API call
  3. On success: keep optimistic state
  4. On error: rollback to previous state + rethrow

Token Management#

Services receive callbacks from AuthProvider:

tokenGetter: authProvider.getAccessToken    // Fresh token on every request
didGetter: () => authProvider.did           // User's DID
pdsUrlGetter: authProvider.getPdsUrl        // User's PDS URL

6. Exception Handling#

lib/services/api_exceptions.dart#

Enhanced exception hierarchy with Dio integration.

Exception Types:

  • ApiException (base)
  • NetworkException (connection/timeout errors)
  • AuthenticationException (401)
  • NotFoundException (404)
  • ServerException (500+)
  • FederationException (atProto federation errors)

7. Bug Fixes (Previous Work)#

Feed Provider - Duplicate API Calls on Failed Sign-In#

Fix: Track auth state transitions instead of current state

bool _wasAuthenticated = false;

void _onAuthChanged() {
  final isAuthenticated = _authProvider.isAuthenticated;

  // Only reload if transitioning from authenticated → unauthenticated
  if (_wasAuthenticated && !isAuthenticated && _posts.isNotEmpty) {
    reset();
    loadFeed(refresh: true);
  }

  _wasAuthenticated = isAuthenticated;
}

8. Files Summary#

Modified Files (8)#

File Purpose
lib/providers/auth_provider.dart Added getPdsUrl() method
lib/services/vote_service.dart Complete rewrite for direct PDS calls
lib/providers/vote_provider.dart Updated to pass postCid, track rkey
lib/widgets/post_card.dart Updated vote call with postCid
lib/main.dart Updated VoteService initialization
test/providers/vote_provider_test.dart Updated mocks and assertions
test/services/vote_service_test.dart Updated VoteResponse tests
test/widgets/feed_screen_test.dart Updated FakeVoteProvider

Unchanged Files (Still Relevant)#

File Purpose
lib/widgets/sign_in_dialog.dart Auth prompt dialog
lib/widgets/icons/animated_heart_icon.dart Animated heart with burst effect
lib/widgets/icons/reply_icon.dart Reply icon
lib/widgets/icons/share_icon.dart Share icon
lib/config/environment_config.dart Environment configuration

9. Backend Integration Requirements#

Jetstream Listener#

The backend must listen for social.coves.feed.vote records from Jetstream:

{
  "did": "did:plc:user123",
  "kind": "commit",
  "commit": {
    "operation": "create",
    "collection": "social.coves.feed.vote",
    "rkey": "3kby...",
    "cid": "bafy2bzacevotecid123",
    "record": {
      "$type": "social.coves.feed.vote",
      "subject": {
        "uri": "at://did:plc:community/social.coves.post.record/abc",
        "cid": "bafy2bzacepostcid123"
      },
      "direction": "up",
      "createdAt": "2025-11-02T12:00:00Z"
    }
  }
}

AppView Indexing#

  1. Listen to Jetstream for vote events
  2. Index vote records in database
  3. Update vote counts on posts
  4. Return aggregated stats in feed responses

Feed Responses#

Feed endpoints should include viewer state:

{
  "post": {
    "uri": "at://did:plc:community/social.coves.post.record/abc",
    "stats": {
      "upvotes": 42,
      "downvotes": 3,
      "score": 39
    },
    "viewer": {
      "vote": {
        "direction": "up",
        "uri": "at://did:plc:user/social.coves.feed.vote/3kby..."
      }
    }
  }
}

10. Testing Checklist#

Unit Tests ✅#

  • VoteService creates proper record structure
  • VoteService finds existing votes correctly
  • VoteService implements toggle logic correctly
  • VoteProvider passes correct parameters
  • Error handling (network failures, auth errors)

Integration Tests (Manual)#

  • Create vote on real PDS
  • Toggle vote off (delete)
  • Switch vote direction (delete + create)
  • Verify Jetstream receives events
  • Verify backend indexes votes correctly
  • Check optimistic UI works
  • Test rollback on error

Backend Verification#

  • Jetstream listener receives vote events
  • AppView indexes votes in database
  • Feed endpoints return correct vote counts
  • Viewer state includes user's vote

11. Performance Considerations#

Optimizations#

  • Optimistic Updates: Instant UI feedback without waiting for PDS
  • Concurrent Request Prevention: Debouncing prevents duplicate API calls
  • Auth Transition Detection: Eliminates unnecessary feed reloads
  • Direct PDS Writes: Removes backend proxy hop

Potential Issues & Solutions#

Issue 1: Finding existing votes is slow (100 records to scan)

  • Solution: Cache vote URIs locally, or use backend's viewer state as hint

Issue 2: User might have voted from another client

  • Solution: Always query PDS listRecords to get source of truth

Issue 3: Network latency for PDS calls

  • Solution: Keep optimistic UI updates for instant feedback

Issue 4: Vote count updates

  • Solution: Backend AppView indexes Jetstream events and updates counts in feed

12. Dependencies#

Production Dependencies#

  • provider - State management
  • dio - HTTP client
  • atproto_oauth_flutter - OAuth authentication
  • flutter/material.dart - UI framework

Test Dependencies#

  • mockito - Mocking framework
  • build_runner - Code generation
  • flutter_test - Testing framework

13. Future Enhancements#

Potential Improvements#

  1. Persistent Vote Cache - Store votes locally for offline support
  2. Vote Animations - More sophisticated animations (number counter)
  3. Downvote UI - Currently only upvote shown in UI
  4. Error Snackbars - User-friendly error messages
  5. Real-time Updates - WebSocket for live vote count updates
  6. Vote History - View vote history in user profile

14. Migration Notes#

Breaking Changes#

  • VoteService constructor signature changed (added didGetter, pdsUrlGetter)
  • toggleVote() now requires postCid parameter
  • VoteResponse added rkey field (removed existing field)
  • Backend must implement Jetstream listener (no longer receives vote API calls)

Backward Compatibility#

  • Feed reading logic unchanged
  • UI components unchanged (except PostCard vote call)
  • Test infrastructure preserved
  • Optimistic UI behavior preserved

Conclusion#

This refactoring represents a fundamental architectural improvement that aligns with atProto principles:

Key Achievements#

  • Proper atProto Architecture - Clients write to PDSs, AppViews index
  • Federation Ready - Works with any PDS in the atProto network
  • User Data Ownership - Votes stored on user's PDS
  • Scalable Backend - AppView only indexes, doesn't proxy writes
  • Comprehensive Testing - 109 tests passing, 0 warnings/errors
  • Preserved UX - Optimistic UI updates maintained
  • Production Ready - Full error handling and rollback

Architecture Benefits#

The new architecture is simpler, more scalable, and follows the atProto specification correctly. The mobile client now operates as a first-class atProto client, writing directly to the user's PDS and reading from the AppView's aggregated feeds.


Generated: 2025-11-02 Branch: feature/bluesky-icons-and-heart-animation Status: ✅ Complete and Ready for Production Testing

DPoP Authentication: ✅ Fully implemented using OAuthSession.fetchHandler

  • Uses local atproto_oauth_flutter package's built-in DPoP support
  • Automatic token refresh on expiry
  • Nonce management for replay protection
  • Authorization: DPoP <access_token> headers
  • DPoP: signed JWT headers

Next Steps:

  1. ✅ Commit architectural changes
  2. ✅ Implement DPoP authentication
  3. 🧪 Test with real PDS and verify Jetstream integration
  4. 🚀 Deploy to production