+39
-2
api/scripts/generate_typescript.ts
+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
+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
+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
+1
frontend/src/pages/IndexPage.tsx
+33
-3
frontend/src/routes/middleware.ts
+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
+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
+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
+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
+
}