perlsky is a Perl 5 implementation of an AT Protocol Personal Data Server.
13
fork

Configure Feed

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

Fix local profile follow state

alice eca10ae9 2b0558a4

+203 -4
+3
lib/ATProto/PDS/ServiceProxy.pm
··· 31 31 use ATProto::PDS::ServiceProxy::Profile qw( 32 32 _blob_cid 33 33 _blob_url 34 + _follow_index 34 35 _get_local_profile 35 36 _profile_associated 36 37 _profile_record_value 37 38 _profile_view_basic 38 39 _profile_view_detailed 40 + _profile_viewer 39 41 ); 40 42 use ATProto::PDS::ServiceProxy::Threads qw( 41 43 _get_author_feed ··· 58 60 ); 59 61 60 62 has settings => sub { {} }; 63 + has local_follow_index_cache => sub { undef }; 61 64 has local_post_index_cache => sub { undef }; 62 65 has ua => sub { 63 66 my $ua = Mojo::UserAgent->new(max_redirects => 0);
+68 -3
lib/ATProto/PDS/ServiceProxy/Profile.pm
··· 14 14 our @EXPORT_OK = qw( 15 15 _blob_cid 16 16 _blob_url 17 + _follow_index 17 18 _get_local_profile 18 19 _profile_associated 19 20 _profile_record_value 20 21 _profile_view_basic 21 22 _profile_view_detailed 23 + _profile_viewer 22 24 ); 23 25 24 26 sub _get_local_profile ($self, $c) { ··· 30 32 31 33 my $account = resolve_repo($c, $actor) or return undef; 32 34 my $profile_value = $self->_profile_record_value($c, $account); 35 + my $viewer = $self->_optional_auth_account($c); 33 36 my $result = { 34 37 %{ $self->_profile_view_detailed($c, $account, $profile_value) }, 35 38 associated => { ··· 42 45 viewer => { 43 46 muted => JSON::PP::false, 44 47 blockedBy => JSON::PP::false, 48 + %{ $self->_profile_viewer($c, $account, $viewer) }, 45 49 }, 46 50 }; 47 51 ··· 72 76 return $value; 73 77 } 74 78 75 - sub _profile_view_basic ($self, $c, $account, $profile_value = undef) { 79 + sub _profile_view_basic ($self, $c, $account, $profile_value = undef, $viewer = undef) { 76 80 $profile_value //= $self->_profile_record_value($c, $account); 77 81 my $view = { 78 82 did => $account->{did}, ··· 81 85 labels => [], 82 86 createdAt => iso8601($account->{created_at}), 83 87 }; 88 + if ($viewer) { 89 + $view->{viewer} = { 90 + muted => JSON::PP::false, 91 + blockedBy => JSON::PP::false, 92 + %{ $self->_profile_viewer($c, $account, $viewer) }, 93 + }; 94 + } 84 95 $view->{displayName} = $profile_value->{displayName} 85 96 if defined($profile_value->{displayName}) && length($profile_value->{displayName}); 86 97 $view->{pronouns} = $profile_value->{pronouns} ··· 93 104 94 105 sub _profile_view_detailed ($self, $c, $account, $profile_value = undef) { 95 106 $profile_value //= $self->_profile_record_value($c, $account); 107 + my $follow_index = $self->_follow_index($c); 96 108 my $view = { 97 109 %{ $self->_profile_view_basic($c, $account, $profile_value) }, 98 110 createdAt => iso8601($account->{created_at}), 99 111 indexedAt => iso8601($account->{created_at}), 100 - followersCount => 0, 101 - followsCount => 0, 112 + followersCount => 0 + ($follow_index->{followers_by_subject}{ $account->{did} } // 0), 113 + followsCount => 0 + ($follow_index->{follows_by_actor}{ $account->{did} } // 0), 102 114 postsCount => 0 + $c->store->count_records_by_collection($account->{did}, 'app.bsky.feed.post'), 103 115 }; 104 116 $view->{description} = $profile_value->{description} ··· 109 121 $view->{banner} = $self->_blob_url($c, $account->{did}, $banner_cid); 110 122 } 111 123 return $view; 124 + } 125 + 126 + sub _profile_viewer ($self, $c, $account, $viewer = undef) { 127 + return {} unless $viewer && defined($viewer->{did}) && length($viewer->{did}); 128 + 129 + my $follow_index = $self->_follow_index($c); 130 + my %viewer; 131 + if (my $following = $follow_index->{follow_uris}{ $viewer->{did} }{ $account->{did} }) { 132 + $viewer{following} = $following; 133 + } 134 + if (my $followed_by = $follow_index->{follow_uris}{ $account->{did} }{ $viewer->{did} }) { 135 + $viewer{followedBy} = $followed_by; 136 + } 137 + return \%viewer; 138 + } 139 + 140 + sub _follow_index ($self, $c) { 141 + my $index = $c->stash('local_follow_index'); 142 + return $index if $index; 143 + 144 + my $event_seq = $c->store->latest_event_seq; 145 + my $cache = $self->local_follow_index_cache; 146 + if ($cache && (($cache->{event_seq} // -1) == $event_seq)) { 147 + $c->stash(local_follow_index => $cache->{index}); 148 + return $cache->{index}; 149 + } 150 + 151 + my $rows = $c->store->list_records_by_collections(['app.bsky.graph.follow']); 152 + $index = { 153 + follow_uris => {}, 154 + followers_by_subject => {}, 155 + follows_by_actor => {}, 156 + }; 157 + 158 + for my $row (@$rows) { 159 + next unless ref($row) eq 'HASH'; 160 + my $actor_did = $row->{did} // q(); 161 + next unless length $actor_did; 162 + my $subject_did = (ref($row->{value}) eq 'HASH') ? ($row->{value}{subject} // q()) : q(); 163 + next unless length $subject_did; 164 + 165 + $index->{follows_by_actor}{$actor_did}++; 166 + $index->{followers_by_subject}{$subject_did}++; 167 + $index->{follow_uris}{$actor_did}{$subject_did} //= 168 + 'at://' . $actor_did . '/app.bsky.graph.follow/' . $row->{rkey}; 169 + } 170 + 171 + $self->local_follow_index_cache({ 172 + event_seq => $event_seq, 173 + index => $index, 174 + }); 175 + $c->stash(local_follow_index => $index); 176 + return $index; 112 177 } 113 178 114 179 sub _profile_associated ($self) {
+1 -1
lib/ATProto/PDS/ServiceProxy/Threads.pm
··· 113 113 my $post = { 114 114 uri => $uri, 115 115 cid => $row->{cid}, 116 - author => $self->_profile_view_basic($c, $account, $profile_value), 116 + author => $self->_profile_view_basic($c, $account, $profile_value, $viewer), 117 117 record => $row->{value}, 118 118 bookmarkCount => 0, 119 119 replyCount => $counts->{replyCount},
+55
t/service-proxy-local.t
··· 270 270 is($cache_store->{get_accounts_by_dids_calls}, 2, 'new events trigger a fresh account batch lookup'); 271 271 isnt($third_index, $first_index, 'new events rebuild the cached index'); 272 272 273 + my $follow_store = LocalTestStore->new( 274 + latest_event_seq => 10, 275 + list_records_by_collections => [ 276 + { 277 + did => $did, 278 + collection => 'app.bsky.graph.follow', 279 + rkey => 'follow-bob', 280 + cid => 'bafyreifollow1', 281 + value => { subject => 'did:plc:bob' }, 282 + }, 283 + { 284 + did => 'did:plc:bob', 285 + collection => 'app.bsky.graph.follow', 286 + rkey => 'follow-alice', 287 + cid => 'bafyreifollow2', 288 + value => { subject => $did }, 289 + }, 290 + ], 291 + ); 292 + 293 + my $follow_proxy = ATProto::PDS::ServiceProxy->new; 294 + my $follow_context = LocalTestContext->new($follow_store); 295 + my $follow_index = $follow_proxy->_follow_index($follow_context); 296 + is($follow_store->{list_records_by_collections_calls}, 1, 'first follow index build scans follow records once'); 297 + is_deeply( 298 + $follow_store->{list_records_by_collections_args}, 299 + ['app.bsky.graph.follow'], 300 + 'follow index only requests follow records', 301 + ); 302 + is($follow_index->{follows_by_actor}{$did}, 1, 'follow index counts outgoing follows'); 303 + is($follow_index->{followers_by_subject}{$did}, 1, 'follow index counts inbound followers'); 304 + is( 305 + $follow_index->{follow_uris}{$did}{'did:plc:bob'}, 306 + "at://$did/app.bsky.graph.follow/follow-bob", 307 + 'follow index records follow URIs for viewer state', 308 + ); 309 + 310 + my $follow_context_again = LocalTestContext->new($follow_store); 311 + my $cached_follow_index = $follow_proxy->_follow_index($follow_context_again); 312 + is($follow_store->{latest_event_seq_calls}, 2, 'follow index still checks the latest event seq on reuse'); 313 + is($follow_store->{list_records_by_collections_calls}, 1, 'unchanged event seq reuses the cached follow index'); 314 + is($cached_follow_index, $follow_index, 'unchanged event seq returns the cached follow index reference'); 315 + 316 + my $viewer = $follow_proxy->_profile_viewer($follow_context_again, { did => 'did:plc:bob' }, { did => $did }); 317 + is( 318 + $viewer->{following}, 319 + "at://$did/app.bsky.graph.follow/follow-bob", 320 + 'profile viewer includes the outgoing follow URI', 321 + ); 322 + is( 323 + $viewer->{followedBy}, 324 + "at://did:plc:bob/app.bsky.graph.follow/follow-alice", 325 + 'profile viewer includes the reciprocal follow URI', 326 + ); 327 + 273 328 done_testing;
+76
t/service-proxy.t
··· 123 123 my $account = $app->store->get_account_by_did($did); 124 124 my $handle = $created->{handle}; 125 125 126 + $t->post_ok('/xrpc/com.atproto.server.createAccount' => json => { 127 + handle => 'bob', 128 + email => 'bob@example.com', 129 + password => 'password123', 130 + })->status_is(200) 131 + ->json_has('/accessJwt') 132 + ->json_has('/did'); 133 + 134 + my $created_bob = $t->tx->res->json; 135 + my $bob_access = $created_bob->{accessJwt}; 136 + my $bob_did = $created_bob->{did}; 137 + 126 138 $t->get_ok('/xrpc/app.bsky.ageassurance.getState?countryCode=GB&regionCode=ENG') 127 139 ->status_is(200) 128 140 ->json_is('/nsid' => 'app.bsky.ageassurance.getState') ··· 203 215 ->json_is('/labels' => []) 204 216 ->json_has('/createdAt') 205 217 ->json_has('/indexedAt') 218 + ->json_is('/followersCount' => 0) 219 + ->json_is('/followsCount' => 0) 206 220 ->json_is('/postsCount' => 0); 221 + 222 + $t->post_ok('/xrpc/com.atproto.repo.createRecord' => { 223 + Authorization => "Bearer $access", 224 + } => json => { 225 + repo => $did, 226 + collection => 'app.bsky.graph.follow', 227 + rkey => 'follow-bob', 228 + record => { 229 + '$type' => 'app.bsky.graph.follow', 230 + subject => $bob_did, 231 + createdAt => '2026-03-10T18:00:00Z', 232 + }, 233 + })->status_is(200) 234 + ->json_is('/uri' => "at://$did/app.bsky.graph.follow/follow-bob"); 235 + 236 + $t->post_ok('/xrpc/com.atproto.repo.createRecord' => { 237 + Authorization => "Bearer $bob_access", 238 + } => json => { 239 + repo => $bob_did, 240 + collection => 'app.bsky.graph.follow', 241 + rkey => 'follow-alice', 242 + record => { 243 + '$type' => 'app.bsky.graph.follow', 244 + subject => $did, 245 + createdAt => '2026-03-10T18:01:00Z', 246 + }, 247 + })->status_is(200) 248 + ->json_is('/uri' => "at://$bob_did/app.bsky.graph.follow/follow-alice"); 249 + 250 + $t->get_ok("/xrpc/app.bsky.actor.getProfile?actor=$did" => { 251 + Authorization => "Bearer $access", 252 + })->status_is(200) 253 + ->json_is('/followersCount' => 1) 254 + ->json_is('/followsCount' => 1); 255 + 256 + $t->get_ok("/xrpc/app.bsky.actor.getProfile?actor=$bob_did" => { 257 + Authorization => "Bearer $access", 258 + })->status_is(200) 259 + ->json_is('/followersCount' => 1) 260 + ->json_is('/followsCount' => 1) 261 + ->json_is('/viewer/following' => "at://$did/app.bsky.graph.follow/follow-bob") 262 + ->json_is('/viewer/followedBy' => "at://$bob_did/app.bsky.graph.follow/follow-alice"); 263 + 264 + $t->post_ok('/xrpc/com.atproto.repo.createRecord' => { 265 + Authorization => "Bearer $bob_access", 266 + } => json => { 267 + repo => $bob_did, 268 + collection => 'app.bsky.feed.post', 269 + rkey => 'bob-post', 270 + record => { 271 + '$type' => 'app.bsky.feed.post', 272 + text => 'hello from bob', 273 + createdAt => '2026-03-10T18:02:00Z', 274 + }, 275 + })->status_is(200) 276 + ->json_is('/uri' => "at://$bob_did/app.bsky.feed.post/bob-post"); 277 + 278 + $t->get_ok("/xrpc/app.bsky.feed.getAuthorFeed?actor=$bob_did&limit=10" => { 279 + Authorization => "Bearer $access", 280 + })->status_is(200) 281 + ->json_is('/feed/0/post/author/viewer/following' => "at://$did/app.bsky.graph.follow/follow-bob") 282 + ->json_is('/feed/0/post/author/viewer/followedBy' => "at://$bob_did/app.bsky.graph.follow/follow-alice"); 207 283 208 284 $t->post_ok('/xrpc/com.atproto.repo.createRecord' => { 209 285 Authorization => "Bearer $access",