Monorepo for Tangled tangled.org
1

Configure Feed

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

bobbin/edge-index,bobbin/xrpc: add viewer_source edge lookup + graph.getFollow, feed.getStar

Signed-off-by: dawn <dawn@tangled.org>

author
dawn
date (Jul 23, 2026, 6:50 PM +0300) commit 97cc2c7a parent a487b015 change-id vpomzsqq
+305 -2
+25
bobbin/crates/edge-index/src/lib.rs
··· 650 650 }) 651 651 .unwrap_or(0) 652 652 } 653 + /// answers "did the viewer star/follow/etc. this subject, and with what rkey" 654 + pub fn viewer_source(&self, key: &EdgeKey, viewer: &str) -> Option<AtUri<DefaultStr>> { 655 + let author_spur = self.did_interner.get(viewer)?; 656 + let author_id = AuthorId::from_spur(author_spur); 657 + let key_id = self.lookup_key(key)?; 658 + 659 + self.forward 660 + .read_sync(&key_id, |_, sources| { 661 + // large buckets track authors, so a missing author fast-fails the scan 662 + if let Sources::Large(big) = sources 663 + && !big.authors.contains_key(&author_id) 664 + { 665 + return None; 666 + } 667 + sources 668 + .directed(PageCursor::Start, SortDir::Desc) 669 + .find_map(|bucket| { 670 + let spur = bucket.source.to_spur()?; 671 + let stored = self.source_interner.try_resolve(&spur)?; 672 + let uri = AtUri::new_owned(self.decode_source(stored)?).ok()?; 673 + (source_authority_did(&uri) == Some(viewer)).then_some(uri) 674 + }) 675 + }) 676 + .flatten() 677 + } 653 678 654 679 pub fn sources_for(&self, key: &EdgeKey) -> Vec<AtUri<DefaultStr>> { 655 680 self.lookup_key(key)
-2
bobbin/crates/resolver/src/legacy_upgrade.rs
··· 448 448 push_options: None, 449 449 r#ref: l.r#ref, 450 450 repo: l.repo_did, 451 - changed_files: None, 452 - push_options: None, 453 451 extra_data: l.extra_data, 454 452 } 455 453 }
+42
bobbin/crates/xrpc/src/lib.rs
··· 172 172 .route("/xrpc/sh.tangled.repo.getPulls", get(get_pulls)) 173 173 .route("/xrpc/sh.tangled.feed.listStars", get(list_stars)) 174 174 .route("/xrpc/sh.tangled.feed.countStars", get(count_stars)) 175 + .route("/xrpc/sh.tangled.feed.getStar", get(get_star)) 175 176 .route("/xrpc/sh.tangled.graph.listFollows", get(list_follows)) 176 177 .route("/xrpc/sh.tangled.graph.countFollows", get(count_follows)) 178 + .route("/xrpc/sh.tangled.graph.getFollow", get(get_follow)) 177 179 .route("/xrpc/sh.tangled.repo.listIssues", get(list_issues)) 178 180 .route("/xrpc/sh.tangled.repo.countIssues", get(count_issues)) 179 181 .route("/xrpc/sh.tangled.repo.listPulls", get(list_pulls)) ··· 634 636 } 635 637 636 638 #[derive(Debug, Deserialize)] 639 + struct GetEdgeQuery { 640 + actor: Did<DefaultStr>, 641 + subject: SubjectQuery, 642 + } 643 + 644 + #[derive(Debug, Deserialize)] 637 645 struct SearchQueryParams { 638 646 q: String, 639 647 nsid: Option<Nsid<DefaultStr>>, ··· 850 858 struct CountResponse { 851 859 count: u64, 852 860 distinct_authors: u64, 861 + } 862 + 863 + #[derive(Serialize)] 864 + #[serde(rename_all = "camelCase")] 865 + struct EdgeUriResponse { 866 + uri: AtUri<DefaultStr>, 853 867 } 854 868 855 869 #[derive(Serialize)] ··· 1811 1825 }) 1812 1826 } 1813 1827 1828 + // does `actor` have an edge of this kind pointing at `subject`, returns its own uri 1829 + fn get_for<R: XrpcResp + HasSubject>( 1830 + state: &AppState, 1831 + q: GetEdgeQuery, 1832 + ) -> Result<EdgeUriResponse, XrpcError> { 1833 + let subject = parse_subject(&q.subject, R::SHAPE)?; 1834 + let key = EdgeKey::new(nsid_static(R::NSID), subject); 1835 + let uri = state 1836 + .edges 1837 + .viewer_source(&key, q.actor.as_str()) 1838 + .ok_or(XrpcError::NotFound)?; 1839 + Ok(EdgeUriResponse { uri }) 1840 + } 1841 + 1814 1842 fn mirror_edge_page<M, F>( 1815 1843 state: &AppState, 1816 1844 q: &TypedListQuery<F>, ··· 1876 1904 count_for::<StarRecord>(&state, q).map(Json) 1877 1905 } 1878 1906 1907 + async fn get_star( 1908 + State(state): State<AppState>, 1909 + XrpcQuery(q): XrpcQuery<GetEdgeQuery>, 1910 + ) -> Result<Json<EdgeUriResponse>, XrpcError> { 1911 + get_for::<StarRecord>(&state, q).map(Json) 1912 + } 1913 + 1879 1914 async fn list_follows( 1880 1915 State(state): State<AppState>, 1881 1916 XrpcQuery(q): XrpcQuery<TypedListQuery<NoFilter>>, ··· 1888 1923 XrpcQuery(q): XrpcQuery<CountQuery>, 1889 1924 ) -> Result<Json<CountResponse>, XrpcError> { 1890 1925 count_for::<FollowRecord>(&state, q).map(Json) 1926 + } 1927 + 1928 + async fn get_follow( 1929 + State(state): State<AppState>, 1930 + XrpcQuery(q): XrpcQuery<GetEdgeQuery>, 1931 + ) -> Result<Json<EdgeUriResponse>, XrpcError> { 1932 + get_for::<FollowRecord>(&state, q).map(Json) 1891 1933 } 1892 1934 1893 1935 async fn list_issues(
+79
bobbin/crates/xrpc/tests/aggregation.rs
··· 606 606 } 607 607 608 608 #[tokio::test] 609 + async fn get_follow_returns_uri_when_present_and_404_otherwise() { 610 + let h = Harness::new().await; 611 + let followee = did("did:plc:bailey"); 612 + let subject = at(&format!("at://{}", followee.as_ref())); 613 + h.add_edge( 614 + &nsid("sh.tangled.graph.follow"), 615 + &subject, 616 + &at("at://did:plc:nel/sh.tangled.graph.follow/f1"), 617 + ); 618 + 619 + let app = router(h.state.clone()); 620 + let (status, body) = json_response( 621 + app.clone() 622 + .oneshot(list_request( 623 + "sh.tangled.graph.getFollow", 624 + followee.as_ref(), 625 + &[("actor", "did:plc:nel")], 626 + )) 627 + .await 628 + .unwrap(), 629 + ) 630 + .await; 631 + assert_eq!(status, StatusCode::OK); 632 + assert_eq!(body["uri"], "at://did:plc:nel/sh.tangled.graph.follow/f1"); 633 + 634 + // different actor never followed them, so this is 404 not a zero-ish success 635 + let (status, _) = json_response( 636 + app.oneshot(list_request( 637 + "sh.tangled.graph.getFollow", 638 + followee.as_ref(), 639 + &[("actor", "did:plc:someoneelse")], 640 + )) 641 + .await 642 + .unwrap(), 643 + ) 644 + .await; 645 + assert_eq!(status, StatusCode::NOT_FOUND); 646 + } 647 + 648 + #[tokio::test] 649 + async fn get_star_returns_uri_when_present_and_404_otherwise() { 650 + let h = Harness::new().await; 651 + let repo_did = did("did:plc:limpet"); 652 + let subject = at(&format!("at://{}", repo_did.as_ref())); 653 + h.add_edge( 654 + &nsid("sh.tangled.feed.star"), 655 + &subject, 656 + &at("at://did:plc:nel/sh.tangled.feed.star/s1"), 657 + ); 658 + 659 + let app = router(h.state.clone()); 660 + let (status, body) = json_response( 661 + app.clone() 662 + .oneshot(list_request( 663 + "sh.tangled.feed.getStar", 664 + repo_did.as_ref(), 665 + &[("actor", "did:plc:nel")], 666 + )) 667 + .await 668 + .unwrap(), 669 + ) 670 + .await; 671 + assert_eq!(status, StatusCode::OK); 672 + assert_eq!(body["uri"], "at://did:plc:nel/sh.tangled.feed.star/s1"); 673 + 674 + let (status, _) = json_response( 675 + app.oneshot(list_request( 676 + "sh.tangled.feed.getStar", 677 + repo_did.as_ref(), 678 + &[("actor", "did:plc:someoneelse")], 679 + )) 680 + .await 681 + .unwrap(), 682 + ) 683 + .await; 684 + assert_eq!(status, StatusCode::NOT_FOUND); 685 + } 686 + 687 + #[tokio::test] 609 688 async fn upstream_failure_during_hydration_drops_only_that_item() { 610 689 let h = Harness::new().await; 611 690 let subject = at("at://did:plc:squid");
+38
lexicons/feed/getStar.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.feed.getStar", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["actor", "subject"], 10 + "properties": { 11 + "actor": { 12 + "type": "string", 13 + "format": "did", 14 + "description": "DID of the potential stargazer." 15 + }, 16 + "subject": { 17 + "type": "string", 18 + "description": "Repo DID to check." 19 + } 20 + } 21 + }, 22 + "output": { 23 + "encoding": "application/json", 24 + "schema": { 25 + "type": "object", 26 + "required": ["uri"], 27 + "properties": { 28 + "uri": { 29 + "type": "string", 30 + "format": "at-uri", 31 + "description": "Uri of actor's star record for subject." 32 + } 33 + } 34 + } 35 + } 36 + } 37 + } 38 + }
+39
lexicons/graph/getFollow.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.graph.getFollow", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["actor", "subject"], 10 + "properties": { 11 + "actor": { 12 + "type": "string", 13 + "format": "did", 14 + "description": "DID of the potential follower." 15 + }, 16 + "subject": { 17 + "type": "string", 18 + "format": "did", 19 + "description": "Followee DID to check." 20 + } 21 + } 22 + }, 23 + "output": { 24 + "encoding": "application/json", 25 + "schema": { 26 + "type": "object", 27 + "required": ["uri"], 28 + "properties": { 29 + "uri": { 30 + "type": "string", 31 + "format": "at-uri", 32 + "description": "Uri of actor's follow record for subject." 33 + } 34 + } 35 + } 36 + } 37 + } 38 + } 39 + }
+2
web/src/lib/api/lexicons/index.ts
··· 7 7 export * as ShTangledCiTrigger from "./types/sh/tangled/ci/trigger.js"; 8 8 export * as ShTangledCiTriggerPipeline from "./types/sh/tangled/ci/triggerPipeline.js"; 9 9 export * as ShTangledFeedComment from "./types/sh/tangled/feed/comment.js"; 10 + export * as ShTangledFeedGetStar from "./types/sh/tangled/feed/getStar.js"; 10 11 export * as ShTangledFeedListComments from "./types/sh/tangled/feed/listComments.js"; 11 12 export * as ShTangledFeedListCommentsBy from "./types/sh/tangled/feed/listCommentsBy.js"; 12 13 export * as ShTangledFeedListReactions from "./types/sh/tangled/feed/listReactions.js"; ··· 35 36 export * as ShTangledGitTempListLanguages from "./types/sh/tangled/git/temp/listLanguages.js"; 36 37 export * as ShTangledGitTempListTags from "./types/sh/tangled/git/temp/listTags.js"; 37 38 export * as ShTangledGraphFollow from "./types/sh/tangled/graph/follow.js"; 39 + export * as ShTangledGraphGetFollow from "./types/sh/tangled/graph/getFollow.js"; 38 40 export * as ShTangledGraphListFollows from "./types/sh/tangled/graph/listFollows.js"; 39 41 export * as ShTangledGraphListFollowsBy from "./types/sh/tangled/graph/listFollowsBy.js"; 40 42 export * as ShTangledGraphListVouches from "./types/sh/tangled/graph/listVouches.js";
+40
web/src/lib/api/lexicons/types/sh/tangled/feed/getStar.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.query("sh.tangled.feed.getStar", { 6 + params: /*#__PURE__*/ v.object({ 7 + /** 8 + * DID of the potential stargazer. 9 + */ 10 + actor: /*#__PURE__*/ v.didString(), 11 + /** 12 + * Repo DID to check. 13 + */ 14 + subject: /*#__PURE__*/ v.string(), 15 + }), 16 + output: { 17 + type: "lex", 18 + schema: /*#__PURE__*/ v.object({ 19 + /** 20 + * Uri of actor's star record for subject. 21 + */ 22 + uri: /*#__PURE__*/ v.resourceUriString(), 23 + }), 24 + }, 25 + }); 26 + 27 + type main$schematype = typeof _mainSchema; 28 + 29 + export interface mainSchema extends main$schematype {} 30 + 31 + export const mainSchema = _mainSchema as mainSchema; 32 + 33 + export interface $params extends v.InferInput<mainSchema["params"]> {} 34 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 35 + 36 + declare module "@atcute/lexicons/ambient" { 37 + interface XRPCQueries { 38 + "sh.tangled.feed.getStar": mainSchema; 39 + } 40 + }
+40
web/src/lib/api/lexicons/types/sh/tangled/graph/getFollow.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.query("sh.tangled.graph.getFollow", { 6 + params: /*#__PURE__*/ v.object({ 7 + /** 8 + * DID of the potential follower. 9 + */ 10 + actor: /*#__PURE__*/ v.didString(), 11 + /** 12 + * Followee DID to check. 13 + */ 14 + subject: /*#__PURE__*/ v.didString(), 15 + }), 16 + output: { 17 + type: "lex", 18 + schema: /*#__PURE__*/ v.object({ 19 + /** 20 + * Uri of actor's follow record for subject. 21 + */ 22 + uri: /*#__PURE__*/ v.resourceUriString(), 23 + }), 24 + }, 25 + }); 26 + 27 + type main$schematype = typeof _mainSchema; 28 + 29 + export interface mainSchema extends main$schematype {} 30 + 31 + export const mainSchema = _mainSchema as mainSchema; 32 + 33 + export interface $params extends v.InferInput<mainSchema["params"]> {} 34 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 35 + 36 + declare module "@atcute/lexicons/ambient" { 37 + interface XRPCQueries { 38 + "sh.tangled.graph.getFollow": mainSchema; 39 + } 40 + }