Highly ambitious ATProtocol AppView service and sdks

fix BlobRef, add helper to render blob to cdn url, bring in bsky profile to Slices frontend

Changed files
+620 -233
api
frontend
src
lexicons
app
bsky
actor
com
atproto
+39 -2
api/scripts/generate_typescript.ts
··· 487 487 isExported: true, 488 488 properties: [ 489 489 { name: "$type", type: "string" }, 490 - { name: "ref", type: "string" }, 490 + { name: "ref", type: "{ $link: string }" }, 491 491 { name: "mimeType", type: "string" }, 492 492 { name: "size", type: "number" }, 493 493 ], ··· 1134 1134 `return await response.json() as T;`, 1135 1135 ], 1136 1136 }, 1137 + { 1138 + name: "blobToCdnUrl", 1139 + scope: "public", 1140 + parameters: [ 1141 + { name: "blobRef", type: "BlobRef" }, 1142 + { name: "did", type: "string" }, 1143 + { name: "preset", type: "'avatar' | 'banner' | 'feed_thumbnail' | 'feed_fullsize'", hasQuestionToken: true }, 1144 + { name: "cdnBaseUrl", type: "string", hasQuestionToken: true }, 1145 + ], 1146 + returnType: "string", 1147 + statements: [ 1148 + `// Convert BlobRef to CDN URL with size preset`, 1149 + `const cdnBase = cdnBaseUrl || 'https://cdn.bsky.app/img';`, 1150 + `const sizePreset = preset || 'feed_fullsize';`, 1151 + `const cid = blobRef.ref;`, 1152 + `return \`\${cdnBase}/\${sizePreset}/plain/\${did}/\${cid}@jpeg\`;`, 1153 + ], 1154 + }, 1137 1155 ], 1138 1156 }); 1139 1157 } ··· 1620 1638 1621 1639 const finalCode = await formatCode(unformattedCode); 1622 1640 1641 + // Add utility function after the generated client code 1642 + const utilityFunction = ` 1643 + 1644 + // Utility function to convert BlobRef to CDN URL using record context 1645 + export function recordBlobToCdnUrl<T>( 1646 + record: RecordResponse<T>, 1647 + blobRef: BlobRef, 1648 + preset?: 'avatar' | 'banner' | 'feed_thumbnail' | 'feed_fullsize', 1649 + cdnBaseUrl?: string 1650 + ): string { 1651 + const cdnBase = cdnBaseUrl || 'https://cdn.bsky.app/img'; 1652 + const sizePreset = preset || 'feed_fullsize'; 1653 + const cid = blobRef.ref.$link; 1654 + return \`\${cdnBase}/\${sizePreset}/plain/\${record.did}/\${cid}@jpeg\`; 1655 + } 1656 + `; 1657 + 1658 + const finalCodeWithUtility = finalCode + utilityFunction; 1659 + 1623 1660 // Output to stdout for the Rust handler to capture 1624 - Deno.stdout.writeSync(new TextEncoder().encode(finalCode)); 1661 + Deno.stdout.writeSync(new TextEncoder().encode(finalCodeWithUtility));
+253 -225
frontend/src/client.ts
··· 1 1 // Generated TypeScript client for AT Protocol records 2 - // Generated at: 2025-08-30 17:33:35 UTC 2 + // Generated at: 2025-08-30 19:20:05 UTC 3 3 // Lexicons: 6 4 4 5 5 /** ··· 9 9 * 10 10 * const client = new AtProtoClient( 11 11 * 'https://slices-api.fly.dev', 12 - * 'at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lwzmbjpqxk2q' 12 + * 'at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lx5zq4t56s2q' 13 13 * ); 14 14 * 15 15 * // List records from the app.bsky.actor.profile collection ··· 228 228 229 229 export interface BlobRef { 230 230 $type: string; 231 - ref: string; 231 + ref: { $link: string }; 232 232 mimeType: string; 233 233 size: number; 234 234 } ··· 243 243 searchRecords(params: SearchRecordsParams): Promise<ListRecordsResponse<T>>; 244 244 } 245 245 246 + export interface AppBskyActorProfile { 247 + /** Small image to be displayed next to posts from account. AKA, 'profile picture' */ 248 + avatar?: BlobRef; 249 + /** Larger horizontal image to display behind profile view. */ 250 + banner?: BlobRef; 251 + /** Self-label values, specific to the Bluesky application, on the overall account. */ 252 + labels?: 253 + | ComAtprotoLabelDefs["SelfLabels"] 254 + | { 255 + $type: string; 256 + [key: string]: unknown; 257 + }; 258 + createdAt?: string; 259 + pinnedPost?: ComAtprotoRepoStrongRef; 260 + /** Free-form profile description text. */ 261 + description?: string; 262 + displayName?: string; 263 + joinedViaStarterPack?: ComAtprotoRepoStrongRef; 264 + } 265 + 266 + export type AppBskyActorProfileSortFields = 267 + | "createdAt" 268 + | "description" 269 + | "displayName"; 270 + 271 + export interface SocialSlicesSlice { 272 + /** Name of the slice */ 273 + name: string; 274 + /** Primary domain namespace for this slice (e.g. social.grain) */ 275 + domain: string; 276 + /** When the slice was created */ 277 + createdAt: string; 278 + } 279 + 280 + export type SocialSlicesSliceSortFields = "name" | "domain" | "createdAt"; 281 + 282 + export interface SocialSlicesLexicon { 283 + /** Namespaced identifier for the lexicon */ 284 + nsid: string; 285 + /** The lexicon schema definitions as JSON */ 286 + definitions: string; 287 + /** When the lexicon was created */ 288 + createdAt: string; 289 + /** When the lexicon was last updated */ 290 + updatedAt?: string; 291 + /** AT-URI reference to the slice this lexicon belongs to */ 292 + slice: string; 293 + } 294 + 295 + export type SocialSlicesLexiconSortFields = 296 + | "nsid" 297 + | "definitions" 298 + | "createdAt" 299 + | "updatedAt" 300 + | "slice"; 301 + 302 + export interface SocialSlicesActorProfile { 303 + displayName?: string; 304 + /** Free-form profile description text. */ 305 + description?: string; 306 + /** Small image to be displayed next to posts from account. AKA, 'profile picture' */ 307 + avatar?: BlobRef; 308 + createdAt?: string; 309 + } 310 + 311 + export type SocialSlicesActorProfileSortFields = 312 + | "displayName" 313 + | "description" 314 + | "createdAt"; 315 + 246 316 export interface ComAtprotoLabelDefsLabel { 247 317 /** Optionally, CID specifying the specific version of 'uri' resource this label applies to. */ 248 318 cid?: string; ··· 264 334 ver?: number; 265 335 } 266 336 337 + export interface ComAtprotoLabelDefsSelfLabel { 338 + /** The short string name of the value or type of this label. */ 339 + val: string; 340 + } 341 + 342 + export interface ComAtprotoLabelDefsSelfLabels { 343 + values: ComAtprotoLabelDefs["SelfLabel"][]; 344 + } 345 + 267 346 export interface ComAtprotoLabelDefsLabelValueDefinition { 268 - /** Does the user need to have adult content enabled in order to configure this label? */ 269 - adultOnly?: boolean; 270 347 /** What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing. */ 271 348 blurs: string; 272 - /** The default setting for this label. */ 273 - defaultSetting?: string; 274 - /** The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+). */ 275 - identifier: string; 276 349 locales: ComAtprotoLabelDefs["LabelValueDefinitionStrings"][]; 277 350 /** How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing. */ 278 351 severity: string; 352 + /** Does the user need to have adult content enabled in order to configure this label? */ 353 + adultOnly?: boolean; 354 + /** The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+). */ 355 + identifier: string; 356 + /** The default setting for this label. */ 357 + defaultSetting?: string; 279 358 } 280 359 281 360 export interface ComAtprotoLabelDefsLabelValueDefinitionStrings { 282 - /** A longer description of what the label means and why it might be applied. */ 283 - description: string; 284 361 /** The code of the language these strings are written in. */ 285 362 lang: string; 286 363 /** A short human-readable name for the label. */ 287 364 name: string; 288 - } 289 - 290 - export interface ComAtprotoLabelDefsSelfLabel { 291 - /** The short string name of the value or type of this label. */ 292 - val: string; 293 - } 294 - 295 - export interface ComAtprotoLabelDefsSelfLabels { 296 - values: ComAtprotoLabelDefs["SelfLabel"][]; 365 + /** A longer description of what the label means and why it might be applied. */ 366 + description: string; 297 367 } 298 368 299 369 export interface ComAtprotoRepoStrongRef { ··· 301 371 uri: string; 302 372 } 303 373 304 - export interface AppBskyActorProfile { 305 - /** Small image to be displayed next to posts from account. AKA, 'profile picture' */ 306 - avatar?: BlobRef; 307 - /** Larger horizontal image to display behind profile view. */ 308 - banner?: BlobRef; 309 - createdAt?: string; 310 - /** Free-form profile description text. */ 311 - description?: string; 312 - displayName?: string; 313 - joinedViaStarterPack?: ComAtprotoRepoStrongRef; 314 - /** Self-label values, specific to the Bluesky application, on the overall account. */ 315 - labels?: 316 - | ComAtprotoLabelDefs["SelfLabels"] 317 - | { 318 - $type: string; 319 - [key: string]: unknown; 320 - }; 321 - pinnedPost?: ComAtprotoRepoStrongRef; 322 - } 323 - 324 - export type AppBskyActorProfileSortFields = 325 - | "createdAt" 326 - | "description" 327 - | "displayName"; 328 - 329 - export interface SocialSlicesActorProfile { 330 - /** Small image to be displayed next to posts from account. AKA, 'profile picture' */ 331 - avatar?: BlobRef; 332 - createdAt?: string; 333 - /** Free-form profile description text. */ 334 - description?: string; 335 - displayName?: string; 336 - } 337 - 338 - export type SocialSlicesActorProfileSortFields = 339 - | "createdAt" 340 - | "description" 341 - | "displayName"; 342 - 343 - export interface SocialSlicesLexicon { 344 - /** When the lexicon was created */ 345 - createdAt: string; 346 - /** The lexicon schema definitions as JSON */ 347 - definitions: string; 348 - /** Namespaced identifier for the lexicon */ 349 - nsid: string; 350 - /** AT-URI reference to the slice this lexicon belongs to */ 351 - slice: string; 352 - /** When the lexicon was last updated */ 353 - updatedAt?: string; 354 - } 355 - 356 - export type SocialSlicesLexiconSortFields = 357 - | "createdAt" 358 - | "definitions" 359 - | "nsid" 360 - | "slice" 361 - | "updatedAt"; 362 - 363 - export interface SocialSlicesSlice { 364 - /** When the slice was created */ 365 - createdAt: string; 366 - /** Name of the slice */ 367 - name: string; 368 - } 369 - 370 - export type SocialSlicesSliceSortFields = "createdAt" | "name"; 371 - 372 374 export interface ComAtprotoLabelDefs { 373 375 readonly Label: ComAtprotoLabelDefsLabel; 376 + readonly SelfLabel: ComAtprotoLabelDefsSelfLabel; 377 + readonly SelfLabels: ComAtprotoLabelDefsSelfLabels; 374 378 readonly LabelValueDefinition: ComAtprotoLabelDefsLabelValueDefinition; 375 379 readonly LabelValueDefinitionStrings: ComAtprotoLabelDefsLabelValueDefinitionStrings; 376 - readonly SelfLabel: ComAtprotoLabelDefsSelfLabel; 377 - readonly SelfLabels: ComAtprotoLabelDefsSelfLabels; 378 380 } 379 381 380 382 class BaseClient { ··· 488 490 } 489 491 490 492 return (await response.json()) as T; 493 + } 494 + 495 + public blobToCdnUrl( 496 + blobRef: BlobRef, 497 + did: string, 498 + preset?: "avatar" | "banner" | "feed_thumbnail" | "feed_fullsize", 499 + cdnBaseUrl?: string 500 + ): string { 501 + // Convert BlobRef to CDN URL with size preset 502 + const cdnBase = cdnBaseUrl || "https://cdn.bsky.app/img"; 503 + const sizePreset = preset || "feed_fullsize"; 504 + const cid = blobRef.ref; 505 + return `${cdnBase}/${sizePreset}/plain/${did}/${cid}@jpeg`; 491 506 } 492 507 } 493 508 ··· 612 627 } 613 628 } 614 629 615 - class ProfileActorSlicesSocialClient extends BaseClient { 630 + class SliceSlicesSocialClient extends BaseClient { 616 631 private readonly sliceUri: string; 617 632 618 633 constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { ··· 621 636 } 622 637 623 638 async listRecords( 624 - params?: ListRecordsParams<SocialSlicesActorProfileSortFields> 625 - ): Promise<ListRecordsResponse<SocialSlicesActorProfile>> { 639 + params?: ListRecordsParams<SocialSlicesSliceSortFields> 640 + ): Promise<ListRecordsResponse<SocialSlicesSlice>> { 626 641 const requestParams = { ...params, slice: this.sliceUri }; 627 - return await this.makeRequest< 628 - ListRecordsResponse<SocialSlicesActorProfile> 629 - >("social.slices.actor.profile.listRecords", "GET", requestParams); 642 + return await this.makeRequest<ListRecordsResponse<SocialSlicesSlice>>( 643 + "social.slices.slice.listRecords", 644 + "GET", 645 + requestParams 646 + ); 630 647 } 631 648 632 649 async getRecord( 633 650 params: GetRecordParams 634 - ): Promise<RecordResponse<SocialSlicesActorProfile>> { 651 + ): Promise<RecordResponse<SocialSlicesSlice>> { 635 652 const requestParams = { ...params, slice: this.sliceUri }; 636 - return await this.makeRequest<RecordResponse<SocialSlicesActorProfile>>( 637 - "social.slices.actor.profile.getRecord", 653 + return await this.makeRequest<RecordResponse<SocialSlicesSlice>>( 654 + "social.slices.slice.getRecord", 638 655 "GET", 639 656 requestParams 640 657 ); 641 658 } 642 659 643 660 async searchRecords( 644 - params: SearchRecordsParams<SocialSlicesActorProfileSortFields> 645 - ): Promise<ListRecordsResponse<SocialSlicesActorProfile>> { 661 + params: SearchRecordsParams<SocialSlicesSliceSortFields> 662 + ): Promise<ListRecordsResponse<SocialSlicesSlice>> { 646 663 const requestParams = { ...params, slice: this.sliceUri }; 647 - return await this.makeRequest< 648 - ListRecordsResponse<SocialSlicesActorProfile> 649 - >("social.slices.actor.profile.searchRecords", "GET", requestParams); 664 + return await this.makeRequest<ListRecordsResponse<SocialSlicesSlice>>( 665 + "social.slices.slice.searchRecords", 666 + "GET", 667 + requestParams 668 + ); 650 669 } 651 670 652 671 async createRecord( 653 - record: SocialSlicesActorProfile, 672 + record: SocialSlicesSlice, 654 673 useSelfRkey?: boolean 655 674 ): Promise<{ uri: string; cid: string }> { 656 - const recordValue = { $type: "social.slices.actor.profile", ...record }; 675 + const recordValue = { $type: "social.slices.slice", ...record }; 657 676 const payload = { 658 677 slice: this.sliceUri, 659 678 ...(useSelfRkey ? { rkey: "self" } : {}), 660 679 record: recordValue, 661 680 }; 662 681 return await this.makeRequest<{ uri: string; cid: string }>( 663 - "social.slices.actor.profile.createRecord", 682 + "social.slices.slice.createRecord", 664 683 "POST", 665 684 payload 666 685 ); ··· 668 687 669 688 async updateRecord( 670 689 rkey: string, 671 - record: SocialSlicesActorProfile 690 + record: SocialSlicesSlice 672 691 ): Promise<{ uri: string; cid: string }> { 673 - const recordValue = { $type: "social.slices.actor.profile", ...record }; 692 + const recordValue = { $type: "social.slices.slice", ...record }; 674 693 const payload = { 675 694 slice: this.sliceUri, 676 695 rkey, 677 696 record: recordValue, 678 697 }; 679 698 return await this.makeRequest<{ uri: string; cid: string }>( 680 - "social.slices.actor.profile.updateRecord", 699 + "social.slices.slice.updateRecord", 681 700 "POST", 682 701 payload 683 702 ); ··· 685 704 686 705 async deleteRecord(rkey: string): Promise<void> { 687 706 return await this.makeRequest<void>( 688 - "social.slices.actor.profile.deleteRecord", 707 + "social.slices.slice.deleteRecord", 689 708 "POST", 690 709 { rkey } 691 710 ); 692 711 } 693 - } 694 712 695 - class ActorSlicesSocialClient extends BaseClient { 696 - readonly profile: ProfileActorSlicesSocialClient; 697 - private readonly sliceUri: string; 713 + async codegen(request: CodegenXrpcRequest): Promise<CodegenXrpcResponse> { 714 + return await this.makeRequest<CodegenXrpcResponse>( 715 + "social.slices.slice.codegen", 716 + "POST", 717 + request 718 + ); 719 + } 698 720 699 - constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { 700 - super(baseUrl, oauthClient); 701 - this.sliceUri = sliceUri; 702 - this.profile = new ProfileActorSlicesSocialClient( 703 - baseUrl, 704 - sliceUri, 705 - oauthClient 721 + async stats(params: SliceStatsParams): Promise<SliceStatsOutput> { 722 + return await this.makeRequest<SliceStatsOutput>( 723 + "social.slices.slice.stats", 724 + "POST", 725 + params 726 + ); 727 + } 728 + 729 + async records(params: SliceRecordsParams): Promise<SliceRecordsOutput> { 730 + return await this.makeRequest<SliceRecordsOutput>( 731 + "social.slices.slice.records", 732 + "POST", 733 + params 734 + ); 735 + } 736 + 737 + async getActors(params?: GetActorsParams): Promise<GetActorsResponse> { 738 + const requestParams = { ...params, slice: this.sliceUri }; 739 + return await this.makeRequest<GetActorsResponse>( 740 + "social.slices.slice.getActors", 741 + "GET", 742 + requestParams 743 + ); 744 + } 745 + 746 + async startSync(params: BulkSyncParams): Promise<SyncJobResponse> { 747 + const requestParams = { ...params, slice: this.sliceUri }; 748 + return await this.makeRequest<SyncJobResponse>( 749 + "social.slices.slice.startSync", 750 + "POST", 751 + requestParams 752 + ); 753 + } 754 + 755 + async getJobStatus(params: GetJobStatusParams): Promise<JobStatus> { 756 + return await this.makeRequest<JobStatus>( 757 + "social.slices.slice.getJobStatus", 758 + "GET", 759 + params 760 + ); 761 + } 762 + 763 + async getJobHistory( 764 + params: GetJobHistoryParams 765 + ): Promise<GetJobHistoryResponse> { 766 + return await this.makeRequest<GetJobHistoryResponse>( 767 + "social.slices.slice.getJobHistory", 768 + "GET", 769 + params 770 + ); 771 + } 772 + 773 + async getJetstreamStatus(): Promise<JetstreamStatusResponse> { 774 + return await this.makeRequest<JetstreamStatusResponse>( 775 + "social.slices.slice.getJetstreamStatus", 776 + "GET" 777 + ); 778 + } 779 + 780 + async syncUserCollections( 781 + params?: SyncUserCollectionsRequest 782 + ): Promise<SyncUserCollectionsResult> { 783 + const requestParams = { slice: this.sliceUri, ...params }; 784 + return await this.makeRequest<SyncUserCollectionsResult>( 785 + "social.slices.slice.syncUserCollections", 786 + "POST", 787 + requestParams 706 788 ); 707 789 } 708 790 } ··· 791 873 } 792 874 } 793 875 794 - class SliceSlicesSocialClient extends BaseClient { 876 + class ProfileActorSlicesSocialClient extends BaseClient { 795 877 private readonly sliceUri: string; 796 878 797 879 constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { ··· 800 882 } 801 883 802 884 async listRecords( 803 - params?: ListRecordsParams<SocialSlicesSliceSortFields> 804 - ): Promise<ListRecordsResponse<SocialSlicesSlice>> { 885 + params?: ListRecordsParams<SocialSlicesActorProfileSortFields> 886 + ): Promise<ListRecordsResponse<SocialSlicesActorProfile>> { 805 887 const requestParams = { ...params, slice: this.sliceUri }; 806 - return await this.makeRequest<ListRecordsResponse<SocialSlicesSlice>>( 807 - "social.slices.slice.listRecords", 808 - "GET", 809 - requestParams 810 - ); 888 + return await this.makeRequest< 889 + ListRecordsResponse<SocialSlicesActorProfile> 890 + >("social.slices.actor.profile.listRecords", "GET", requestParams); 811 891 } 812 892 813 893 async getRecord( 814 894 params: GetRecordParams 815 - ): Promise<RecordResponse<SocialSlicesSlice>> { 895 + ): Promise<RecordResponse<SocialSlicesActorProfile>> { 816 896 const requestParams = { ...params, slice: this.sliceUri }; 817 - return await this.makeRequest<RecordResponse<SocialSlicesSlice>>( 818 - "social.slices.slice.getRecord", 897 + return await this.makeRequest<RecordResponse<SocialSlicesActorProfile>>( 898 + "social.slices.actor.profile.getRecord", 819 899 "GET", 820 900 requestParams 821 901 ); 822 902 } 823 903 824 904 async searchRecords( 825 - params: SearchRecordsParams<SocialSlicesSliceSortFields> 826 - ): Promise<ListRecordsResponse<SocialSlicesSlice>> { 905 + params: SearchRecordsParams<SocialSlicesActorProfileSortFields> 906 + ): Promise<ListRecordsResponse<SocialSlicesActorProfile>> { 827 907 const requestParams = { ...params, slice: this.sliceUri }; 828 - return await this.makeRequest<ListRecordsResponse<SocialSlicesSlice>>( 829 - "social.slices.slice.searchRecords", 830 - "GET", 831 - requestParams 832 - ); 908 + return await this.makeRequest< 909 + ListRecordsResponse<SocialSlicesActorProfile> 910 + >("social.slices.actor.profile.searchRecords", "GET", requestParams); 833 911 } 834 912 835 913 async createRecord( 836 - record: SocialSlicesSlice, 914 + record: SocialSlicesActorProfile, 837 915 useSelfRkey?: boolean 838 916 ): Promise<{ uri: string; cid: string }> { 839 - const recordValue = { $type: "social.slices.slice", ...record }; 917 + const recordValue = { $type: "social.slices.actor.profile", ...record }; 840 918 const payload = { 841 919 slice: this.sliceUri, 842 920 ...(useSelfRkey ? { rkey: "self" } : {}), 843 921 record: recordValue, 844 922 }; 845 923 return await this.makeRequest<{ uri: string; cid: string }>( 846 - "social.slices.slice.createRecord", 924 + "social.slices.actor.profile.createRecord", 847 925 "POST", 848 926 payload 849 927 ); ··· 851 929 852 930 async updateRecord( 853 931 rkey: string, 854 - record: SocialSlicesSlice 932 + record: SocialSlicesActorProfile 855 933 ): Promise<{ uri: string; cid: string }> { 856 - const recordValue = { $type: "social.slices.slice", ...record }; 934 + const recordValue = { $type: "social.slices.actor.profile", ...record }; 857 935 const payload = { 858 936 slice: this.sliceUri, 859 937 rkey, 860 938 record: recordValue, 861 939 }; 862 940 return await this.makeRequest<{ uri: string; cid: string }>( 863 - "social.slices.slice.updateRecord", 941 + "social.slices.actor.profile.updateRecord", 864 942 "POST", 865 943 payload 866 944 ); ··· 868 946 869 947 async deleteRecord(rkey: string): Promise<void> { 870 948 return await this.makeRequest<void>( 871 - "social.slices.slice.deleteRecord", 949 + "social.slices.actor.profile.deleteRecord", 872 950 "POST", 873 951 { rkey } 874 952 ); 875 953 } 876 - 877 - async codegen(request: CodegenXrpcRequest): Promise<CodegenXrpcResponse> { 878 - return await this.makeRequest<CodegenXrpcResponse>( 879 - "social.slices.slice.codegen", 880 - "POST", 881 - request 882 - ); 883 - } 884 - 885 - async stats(params: SliceStatsParams): Promise<SliceStatsOutput> { 886 - return await this.makeRequest<SliceStatsOutput>( 887 - "social.slices.slice.stats", 888 - "POST", 889 - params 890 - ); 891 - } 954 + } 892 955 893 - async records(params: SliceRecordsParams): Promise<SliceRecordsOutput> { 894 - return await this.makeRequest<SliceRecordsOutput>( 895 - "social.slices.slice.records", 896 - "POST", 897 - params 898 - ); 899 - } 956 + class ActorSlicesSocialClient extends BaseClient { 957 + readonly profile: ProfileActorSlicesSocialClient; 958 + private readonly sliceUri: string; 900 959 901 - async getActors(params?: GetActorsParams): Promise<GetActorsResponse> { 902 - const requestParams = { ...params, slice: this.sliceUri }; 903 - return await this.makeRequest<GetActorsResponse>( 904 - "social.slices.slice.getActors", 905 - "GET", 906 - requestParams 907 - ); 908 - } 909 - 910 - async startSync(params: BulkSyncParams): Promise<SyncJobResponse> { 911 - const requestParams = { ...params, slice: this.sliceUri }; 912 - return await this.makeRequest<SyncJobResponse>( 913 - "social.slices.slice.startSync", 914 - "POST", 915 - requestParams 916 - ); 917 - } 918 - 919 - async getJobStatus(params: GetJobStatusParams): Promise<JobStatus> { 920 - return await this.makeRequest<JobStatus>( 921 - "social.slices.slice.getJobStatus", 922 - "GET", 923 - params 924 - ); 925 - } 926 - 927 - async getJobHistory( 928 - params: GetJobHistoryParams 929 - ): Promise<GetJobHistoryResponse> { 930 - return await this.makeRequest<GetJobHistoryResponse>( 931 - "social.slices.slice.getJobHistory", 932 - "GET", 933 - params 934 - ); 935 - } 936 - 937 - async getJetstreamStatus(): Promise<JetstreamStatusResponse> { 938 - return await this.makeRequest<JetstreamStatusResponse>( 939 - "social.slices.slice.getJetstreamStatus", 940 - "GET" 941 - ); 942 - } 943 - 944 - async syncUserCollections( 945 - params?: SyncUserCollectionsRequest 946 - ): Promise<SyncUserCollectionsResult> { 947 - const requestParams = { slice: this.sliceUri, ...params }; 948 - return await this.makeRequest<SyncUserCollectionsResult>( 949 - "social.slices.slice.syncUserCollections", 950 - "POST", 951 - requestParams 960 + constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { 961 + super(baseUrl, oauthClient); 962 + this.sliceUri = sliceUri; 963 + this.profile = new ProfileActorSlicesSocialClient( 964 + baseUrl, 965 + sliceUri, 966 + oauthClient 952 967 ); 953 968 } 954 969 } 955 970 956 971 class SlicesSocialClient extends BaseClient { 972 + readonly slice: SliceSlicesSocialClient; 973 + readonly lexicon: LexiconSlicesSocialClient; 957 974 readonly actor: ActorSlicesSocialClient; 958 - readonly lexicon: LexiconSlicesSocialClient; 959 - readonly slice: SliceSlicesSocialClient; 960 975 private readonly sliceUri: string; 961 976 962 977 constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { 963 978 super(baseUrl, oauthClient); 964 979 this.sliceUri = sliceUri; 965 - this.actor = new ActorSlicesSocialClient(baseUrl, sliceUri, oauthClient); 980 + this.slice = new SliceSlicesSocialClient(baseUrl, sliceUri, oauthClient); 966 981 this.lexicon = new LexiconSlicesSocialClient( 967 982 baseUrl, 968 983 sliceUri, 969 984 oauthClient 970 985 ); 971 - this.slice = new SliceSlicesSocialClient(baseUrl, sliceUri, oauthClient); 986 + this.actor = new ActorSlicesSocialClient(baseUrl, sliceUri, oauthClient); 972 987 } 973 988 } 974 989 ··· 1058 1073 return await response.json(); 1059 1074 } 1060 1075 } 1076 + 1077 + // Utility function to convert BlobRef to CDN URL using record context 1078 + export function recordBlobToCdnUrl<T>( 1079 + record: RecordResponse<T>, 1080 + blobRef: BlobRef, 1081 + preset?: "avatar" | "banner" | "feed_thumbnail" | "feed_fullsize", 1082 + cdnBaseUrl?: string 1083 + ): string { 1084 + const cdnBase = cdnBaseUrl || "https://cdn.bsky.app/img"; 1085 + const sizePreset = preset || "feed_fullsize"; 1086 + const cid = blobRef.ref.$link; 1087 + return `${cdnBase}/${sizePreset}/plain/${record.did}/${cid}@jpeg`; 1088 + }
+14 -3
frontend/src/components/Layout.tsx
··· 3 3 interface LayoutProps { 4 4 title?: string; 5 5 children: JSX.Element | JSX.Element[]; 6 - currentUser?: { handle?: string; isAuthenticated: boolean }; 6 + currentUser?: { handle?: string; isAuthenticated: boolean; avatar?: string }; 7 7 } 8 8 9 9 export function Layout({ ··· 49 49 <div className="flex items-center space-x-4"> 50 50 {currentUser?.isAuthenticated ? ( 51 51 <div className="flex items-center space-x-3"> 52 + {currentUser.avatar && ( 53 + <img 54 + src={currentUser.avatar} 55 + alt="Profile avatar" 56 + className="w-6 h-6 rounded-full" 57 + /> 58 + )} 52 59 <span className="text-sm text-gray-600"> 53 - {currentUser.handle ? `@${currentUser.handle}` : "Authenticated User"} 60 + {currentUser.handle 61 + ? `@${currentUser.handle}` 62 + : "Authenticated User"} 54 63 </span> 55 64 <a 56 65 href="/settings" ··· 78 87 </div> 79 88 </div> 80 89 </nav> 81 - <main className="max-w-5xl mx-auto mt-8 px-4 pb-16 min-h-[calc(100vh-200px)]">{children}</main> 90 + <main className="max-w-5xl mx-auto mt-8 px-4 pb-16 min-h-[calc(100vh-200px)]"> 91 + {children} 92 + </main> 82 93 </body> 83 94 </html> 84 95 );
+1
frontend/src/pages/IndexPage.tsx
··· 96 96 data. 97 97 </p> 98 98 <button 99 + type="button" 99 100 className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-2 rounded" 100 101 hx-get="/dialogs/create-slice" 101 102 hx-target="body"
+33 -3
frontend/src/routes/middleware.ts
··· 1 - import { sessionStore } from "../config.ts"; 1 + import { sessionStore, atprotoClient } from "../config.ts"; 2 + import { recordBlobToCdnUrl } from "../client.ts"; 2 3 3 4 export interface AuthenticatedUser { 4 5 handle?: string; 5 6 sub?: string; 6 7 isAuthenticated: boolean; 8 + avatar?: string; 7 9 } 8 10 9 11 export interface RouteContext { ··· 13 15 export async function withAuth(req: Request): Promise<RouteContext> { 14 16 // Get current user info from session store 15 17 const currentUser = await sessionStore.getCurrentUser(req); 18 + 19 + // If user is authenticated, try to fetch their Bluesky profile avatar 20 + if (currentUser.isAuthenticated && currentUser.sub) { 21 + try { 22 + // Try to get the user's Bluesky profile from external collections 23 + const profileRecords = 24 + await atprotoClient.app.bsky.actor.profile.listRecords({ 25 + authors: [currentUser.sub], 26 + limit: 1, 27 + }); 28 + 29 + if (profileRecords.records && profileRecords.records.length > 0) { 30 + const profileRecord = profileRecords.records[0]; 31 + if (profileRecord.value.avatar) { 32 + // Convert BlobRef to CDN URL for avatar 33 + currentUser.avatar = recordBlobToCdnUrl( 34 + profileRecord, 35 + profileRecord.value.avatar, 36 + "avatar" 37 + ); 38 + } 39 + } 40 + } catch (error) { 41 + console.log("Could not fetch user avatar:", error); 42 + // Continue without avatar - this is non-critical 43 + } 44 + } 45 + 16 46 return { 17 47 currentUser, 18 48 }; ··· 22 52 if (!context.currentUser.isAuthenticated) { 23 53 return new Response("", { 24 54 status: 401, 25 - headers: { 55 + headers: { 26 56 "HX-Redirect": "/login", 27 - "Location": "/login" 57 + Location: "/login", 28 58 }, 29 59 }); 30 60 }
+64
lexicons/app/bsky/actor/profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.actor.profile", 4 + "defs": { 5 + "main": { 6 + "key": "literal:self", 7 + "type": "record", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "avatar": { 12 + "type": "blob", 13 + "accept": [ 14 + "image/png", 15 + "image/jpeg" 16 + ], 17 + "maxSize": 1000000, 18 + "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" 19 + }, 20 + "banner": { 21 + "type": "blob", 22 + "accept": [ 23 + "image/png", 24 + "image/jpeg" 25 + ], 26 + "maxSize": 1000000, 27 + "description": "Larger horizontal image to display behind profile view." 28 + }, 29 + "labels": { 30 + "refs": [ 31 + "com.atproto.label.defs#selfLabels" 32 + ], 33 + "type": "union", 34 + "description": "Self-label values, specific to the Bluesky application, on the overall account." 35 + }, 36 + "createdAt": { 37 + "type": "string", 38 + "format": "datetime" 39 + }, 40 + "pinnedPost": { 41 + "ref": "com.atproto.repo.strongRef", 42 + "type": "ref" 43 + }, 44 + "description": { 45 + "type": "string", 46 + "maxLength": 2560, 47 + "description": "Free-form profile description text.", 48 + "maxGraphemes": 256 49 + }, 50 + "displayName": { 51 + "type": "string", 52 + "maxLength": 640, 53 + "maxGraphemes": 64 54 + }, 55 + "joinedViaStarterPack": { 56 + "ref": "com.atproto.repo.strongRef", 57 + "type": "ref" 58 + } 59 + } 60 + }, 61 + "description": "A declaration of a Bluesky account profile." 62 + } 63 + } 64 + }
+192
lexicons/com/atproto/label/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.label.defs", 4 + "defs": { 5 + "label": { 6 + "type": "object", 7 + "required": [ 8 + "src", 9 + "uri", 10 + "val", 11 + "cts" 12 + ], 13 + "properties": { 14 + "cid": { 15 + "type": "string", 16 + "format": "cid", 17 + "description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to." 18 + }, 19 + "cts": { 20 + "type": "string", 21 + "format": "datetime", 22 + "description": "Timestamp when this label was created." 23 + }, 24 + "exp": { 25 + "type": "string", 26 + "format": "datetime", 27 + "description": "Timestamp at which this label expires (no longer applies)." 28 + }, 29 + "neg": { 30 + "type": "boolean", 31 + "description": "If true, this is a negation label, overwriting a previous label." 32 + }, 33 + "sig": { 34 + "type": "bytes", 35 + "description": "Signature of dag-cbor encoded label." 36 + }, 37 + "src": { 38 + "type": "string", 39 + "format": "did", 40 + "description": "DID of the actor who created this label." 41 + }, 42 + "uri": { 43 + "type": "string", 44 + "format": "uri", 45 + "description": "AT URI of the record, repository (account), or other resource that this label applies to." 46 + }, 47 + "val": { 48 + "type": "string", 49 + "maxLength": 128, 50 + "description": "The short string name of the value or type of this label." 51 + }, 52 + "ver": { 53 + "type": "integer", 54 + "description": "The AT Protocol version of the label object." 55 + } 56 + }, 57 + "description": "Metadata tag on an atproto resource (eg, repo or record)." 58 + }, 59 + "selfLabel": { 60 + "type": "object", 61 + "required": [ 62 + "val" 63 + ], 64 + "properties": { 65 + "val": { 66 + "type": "string", 67 + "maxLength": 128, 68 + "description": "The short string name of the value or type of this label." 69 + } 70 + }, 71 + "description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel." 72 + }, 73 + "labelValue": { 74 + "type": "string", 75 + "knownValues": [ 76 + "!hide", 77 + "!no-promote", 78 + "!warn", 79 + "!no-unauthenticated", 80 + "dmca-violation", 81 + "doxxing", 82 + "porn", 83 + "sexual", 84 + "nudity", 85 + "nsfl", 86 + "gore" 87 + ] 88 + }, 89 + "selfLabels": { 90 + "type": "object", 91 + "required": [ 92 + "values" 93 + ], 94 + "properties": { 95 + "values": { 96 + "type": "array", 97 + "items": { 98 + "ref": "#selfLabel", 99 + "type": "ref" 100 + }, 101 + "maxLength": 10 102 + } 103 + }, 104 + "description": "Metadata tags on an atproto record, published by the author within the record." 105 + }, 106 + "labelValueDefinition": { 107 + "type": "object", 108 + "required": [ 109 + "identifier", 110 + "severity", 111 + "blurs", 112 + "locales" 113 + ], 114 + "properties": { 115 + "blurs": { 116 + "type": "string", 117 + "description": "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.", 118 + "knownValues": [ 119 + "content", 120 + "media", 121 + "none" 122 + ] 123 + }, 124 + "locales": { 125 + "type": "array", 126 + "items": { 127 + "ref": "#labelValueDefinitionStrings", 128 + "type": "ref" 129 + } 130 + }, 131 + "severity": { 132 + "type": "string", 133 + "description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.", 134 + "knownValues": [ 135 + "inform", 136 + "alert", 137 + "none" 138 + ] 139 + }, 140 + "adultOnly": { 141 + "type": "boolean", 142 + "description": "Does the user need to have adult content enabled in order to configure this label?" 143 + }, 144 + "identifier": { 145 + "type": "string", 146 + "maxLength": 100, 147 + "description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).", 148 + "maxGraphemes": 100 149 + }, 150 + "defaultSetting": { 151 + "type": "string", 152 + "default": "warn", 153 + "description": "The default setting for this label.", 154 + "knownValues": [ 155 + "ignore", 156 + "warn", 157 + "hide" 158 + ] 159 + } 160 + }, 161 + "description": "Declares a label value and its expected interpretations and behaviors." 162 + }, 163 + "labelValueDefinitionStrings": { 164 + "type": "object", 165 + "required": [ 166 + "lang", 167 + "name", 168 + "description" 169 + ], 170 + "properties": { 171 + "lang": { 172 + "type": "string", 173 + "format": "language", 174 + "description": "The code of the language these strings are written in." 175 + }, 176 + "name": { 177 + "type": "string", 178 + "maxLength": 640, 179 + "description": "A short human-readable name for the label.", 180 + "maxGraphemes": 64 181 + }, 182 + "description": { 183 + "type": "string", 184 + "maxLength": 100000, 185 + "description": "A longer description of what the label means and why it might be applied.", 186 + "maxGraphemes": 10000 187 + } 188 + }, 189 + "description": "Strings which describe the label in the UI, localized into a specific language." 190 + } 191 + } 192 + }
+24
lexicons/com/atproto/repo/strongRef.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.repo.strongRef", 4 + "description": "A URI with a content-hash fingerprint.", 5 + "defs": { 6 + "main": { 7 + "type": "object", 8 + "required": [ 9 + "uri", 10 + "cid" 11 + ], 12 + "properties": { 13 + "cid": { 14 + "type": "string", 15 + "format": "cid" 16 + }, 17 + "uri": { 18 + "type": "string", 19 + "format": "at-uri" 20 + } 21 + } 22 + } 23 + } 24 + }