Highly ambitious ATProtocol AppView service and sdks
0
fork

Configure Feed

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

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

Chad Miller 58de08ac df3e8cc6

+620 -233
+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 + }