tangled
alpha
login
or
join now
grain.social
/
grain
38
fork
atom
grain.social is a photo sharing platform built on atproto.
38
fork
atom
overview
issues
2
pulls
pipelines
add bsky follow lexicon and button on profile
chadtmiller.com
10 months ago
7fbc40b5
46523e6d
+210
-3
7 changed files
expand all
collapse all
unified
split
__generated__
index.ts
lexicons.ts
types
app
bsky
graph
follow.ts
lexicons
app
bsky
graph
follow.json
lexicons.json
main.tsx
static
styles.css
+10
__generated__/index.ts
···
63
63
export class AppBskyNS {
64
64
_server: Server
65
65
embed: AppBskyEmbedNS
66
66
+
graph: AppBskyGraphNS
66
67
feed: AppBskyFeedNS
67
68
richtext: AppBskyRichtextNS
68
69
actor: AppBskyActorNS
···
70
71
constructor(server: Server) {
71
72
this._server = server
72
73
this.embed = new AppBskyEmbedNS(server)
74
74
+
this.graph = new AppBskyGraphNS(server)
73
75
this.feed = new AppBskyFeedNS(server)
74
76
this.richtext = new AppBskyRichtextNS(server)
75
77
this.actor = new AppBskyActorNS(server)
···
77
79
}
78
80
79
81
export class AppBskyEmbedNS {
82
82
+
_server: Server
83
83
+
84
84
+
constructor(server: Server) {
85
85
+
this._server = server
86
86
+
}
87
87
+
}
88
88
+
89
89
+
export class AppBskyGraphNS {
80
90
_server: Server
81
91
82
92
constructor(server: Server) {
+27
__generated__/lexicons.ts
···
447
447
},
448
448
},
449
449
},
450
450
+
AppBskyGraphFollow: {
451
451
+
lexicon: 1,
452
452
+
id: 'app.bsky.graph.follow',
453
453
+
defs: {
454
454
+
main: {
455
455
+
key: 'tid',
456
456
+
type: 'record',
457
457
+
record: {
458
458
+
type: 'object',
459
459
+
required: ['subject', 'createdAt'],
460
460
+
properties: {
461
461
+
subject: {
462
462
+
type: 'string',
463
463
+
format: 'did',
464
464
+
},
465
465
+
createdAt: {
466
466
+
type: 'string',
467
467
+
format: 'datetime',
468
468
+
},
469
469
+
},
470
470
+
},
471
471
+
description:
472
472
+
"Record declaring a social 'follow' relationship of another account. Duplicate follows will be ignored by the AppView.",
473
473
+
},
474
474
+
},
475
475
+
},
450
476
AppBskyGraphDefs: {
451
477
lexicon: 1,
452
478
id: 'app.bsky.graph.defs',
···
2800
2826
AppBskyEmbedRecordWithMedia: 'app.bsky.embed.recordWithMedia',
2801
2827
AppBskyEmbedVideo: 'app.bsky.embed.video',
2802
2828
AppBskyEmbedExternal: 'app.bsky.embed.external',
2829
2829
+
AppBskyGraphFollow: 'app.bsky.graph.follow',
2803
2830
AppBskyGraphDefs: 'app.bsky.graph.defs',
2804
2831
AppBskyFeedDefs: 'app.bsky.feed.defs',
2805
2832
AppBskyFeedPostgate: 'app.bsky.feed.postgate',
+32
__generated__/types/app/bsky/graph/follow.ts
···
1
1
+
/**
2
2
+
* GENERATED CODE - DO NOT MODIFY
3
3
+
*/
4
4
+
import { type ValidationResult, BlobRef } from "npm:@atproto/lexicon"
5
5
+
import { CID } from "npm:multiformats/cid"
6
6
+
import { validate as _validate } from '../../../../lexicons.ts'
7
7
+
import {
8
8
+
type $Typed,
9
9
+
is$typed as _is$typed,
10
10
+
type OmitKey,
11
11
+
} from '../../../../util.ts'
12
12
+
13
13
+
const is$typed = _is$typed,
14
14
+
validate = _validate
15
15
+
const id = 'app.bsky.graph.follow'
16
16
+
17
17
+
export interface Record {
18
18
+
$type: 'app.bsky.graph.follow'
19
19
+
subject: string
20
20
+
createdAt: string
21
21
+
[k: string]: unknown
22
22
+
}
23
23
+
24
24
+
const hashRecord = 'main'
25
25
+
26
26
+
export function isRecord<V>(v: V) {
27
27
+
return is$typed(v, id, hashRecord)
28
28
+
}
29
29
+
30
30
+
export function validateRecord<V>(v: V) {
31
31
+
return validate<Record & V>(v, id, hashRecord, true)
32
32
+
}
+2
-1
lexicons.json
···
3
3
"app.feed.post",
4
4
"app.bsky.feed.post",
5
5
"app.bsky.actor.profile",
6
6
-
"app.bsky.actor.defs"
6
6
+
"app.bsky.actor.defs",
7
7
+
"app.bsky.graph.follow"
7
8
]
8
9
}
+28
lexicons/app/bsky/graph/follow.json
···
1
1
+
{
2
2
+
"lexicon": 1,
3
3
+
"id": "app.bsky.graph.follow",
4
4
+
"defs": {
5
5
+
"main": {
6
6
+
"key": "tid",
7
7
+
"type": "record",
8
8
+
"record": {
9
9
+
"type": "object",
10
10
+
"required": [
11
11
+
"subject",
12
12
+
"createdAt"
13
13
+
],
14
14
+
"properties": {
15
15
+
"subject": {
16
16
+
"type": "string",
17
17
+
"format": "did"
18
18
+
},
19
19
+
"createdAt": {
20
20
+
"type": "string",
21
21
+
"format": "datetime"
22
22
+
}
23
23
+
}
24
24
+
},
25
25
+
"description": "Record declaring a social 'follow' relationship of another account. Duplicate follows will be ignored by the AppView."
26
26
+
}
27
27
+
}
28
28
+
}
+108
-2
main.tsx
···
1
1
import { lexicons } from "$lexicon/lexicons.ts";
2
2
import { Record as BskyProfile } from "$lexicon/types/app/bsky/actor/profile.ts";
3
3
+
import { Record as BskyFollow } from "$lexicon/types/app/bsky/graph/follow.ts";
3
4
import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts";
4
5
import { Record as Profile } from "$lexicon/types/social/grain/actor/profile.ts";
5
6
import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts";
···
131
132
if (!actor) return ctx.next();
132
133
const profile = getActorProfile(actor.did, ctx);
133
134
if (!profile) return ctx.next();
135
135
+
let follow: WithBffMeta<BskyFollow> | undefined;
136
136
+
if (ctx.currentUser) {
137
137
+
follow = getFollow(
138
138
+
profile.did,
139
139
+
ctx.currentUser.did,
140
140
+
ctx,
141
141
+
);
142
142
+
}
134
143
ctx.state.meta = [
135
144
{
136
145
title: profile.displayName
···
142
151
if (tab) {
143
152
return ctx.html(
144
153
<ProfilePage
154
154
+
followUri={follow?.uri}
145
155
loggedInUserDid={ctx.currentUser?.did}
146
156
timelineItems={timelineItems}
147
157
profile={profile}
···
152
162
}
153
163
return ctx.render(
154
164
<ProfilePage
165
165
+
followUri={follow?.uri}
155
166
loggedInUserDid={ctx.currentUser?.did}
156
167
timelineItems={timelineItems}
157
168
profile={profile}
···
190
201
? galleryLink(ctx.currentUser.handle, galleryRkey)
191
202
: undefined}
192
203
/>,
204
204
+
);
205
205
+
}),
206
206
+
route("/follow/:did", ["POST"], async (_req, params, ctx) => {
207
207
+
requireAuth(ctx);
208
208
+
const did = params.did;
209
209
+
if (!did) return ctx.next();
210
210
+
const followUri = await ctx.createRecord<BskyFollow>(
211
211
+
"app.bsky.graph.follow",
212
212
+
{
213
213
+
subject: did,
214
214
+
createdAt: new Date().toISOString(),
215
215
+
},
216
216
+
);
217
217
+
return ctx.html(
218
218
+
<FollowButton followeeDid={did} followUri={followUri} />,
219
219
+
);
220
220
+
}),
221
221
+
route("/follow/:did/:rkey", ["DELETE"], async (_req, params, ctx) => {
222
222
+
requireAuth(ctx);
223
223
+
const did = params.did;
224
224
+
const rkey = params.rkey;
225
225
+
if (!did) return ctx.next();
226
226
+
await ctx.deleteRecord(
227
227
+
`at://${ctx.currentUser.did}/app.bsky.graph.follow/${rkey}`,
228
228
+
);
229
229
+
return ctx.html(
230
230
+
<FollowButton followeeDid={did} followUri={undefined} />,
193
231
);
194
232
}),
195
233
route("/dialogs/gallery/new", (_req, _params, ctx) => {
···
618
656
actorDid?: string;
619
657
};
620
658
659
659
+
function getFollow(followeeDid: string, followerDid: string, ctx: BffContext) {
660
660
+
const { items: [follow] } = ctx.indexService.getRecords<
661
661
+
WithBffMeta<BskyFollow>
662
662
+
>(
663
663
+
"app.bsky.graph.follow",
664
664
+
{
665
665
+
where: [
666
666
+
{
667
667
+
field: "did",
668
668
+
equals: followerDid,
669
669
+
},
670
670
+
{
671
671
+
field: "subject",
672
672
+
equals: followeeDid,
673
673
+
},
674
674
+
],
675
675
+
},
676
676
+
);
677
677
+
return follow;
678
678
+
}
679
679
+
621
680
function getGalleryItemsAndPhotos(
622
681
ctx: BffContext,
623
682
galleries: WithBffMeta<Gallery>[],
···
1150
1209
);
1151
1210
}
1152
1211
1212
1212
+
function FollowButton({
1213
1213
+
followeeDid,
1214
1214
+
followUri,
1215
1215
+
}: Readonly<{ followeeDid: string; load?: boolean; followUri?: string }>) {
1216
1216
+
const isFollowing = followUri;
1217
1217
+
return (
1218
1218
+
<Button
1219
1219
+
variant="primary"
1220
1220
+
class={cn(
1221
1221
+
"w-full sm:w-fit",
1222
1222
+
isFollowing &&
1223
1223
+
"bg-zinc-200 dark:bg-zinc-800 border-zinc-200 dark:border-zinc-800",
1224
1224
+
)}
1225
1225
+
{...(isFollowing
1226
1226
+
? {
1227
1227
+
children: "Following",
1228
1228
+
"hx-delete": `/follow/${followeeDid}/${new AtUri(followUri).rkey}`,
1229
1229
+
}
1230
1230
+
: {
1231
1231
+
children: (
1232
1232
+
<>
1233
1233
+
<i class="fa-solid fa-plus mr-2" />Follow
1234
1234
+
</>
1235
1235
+
),
1236
1236
+
"hx-post": `/follow/${followeeDid}`,
1237
1237
+
})}
1238
1238
+
hx-trigger="click"
1239
1239
+
hx-target="this"
1240
1240
+
hx-swap="outerHTML"
1241
1241
+
/>
1242
1242
+
);
1243
1243
+
}
1244
1244
+
1153
1245
function ProfilePage({
1246
1246
+
followUri,
1154
1247
loggedInUserDid,
1155
1248
timelineItems,
1156
1249
profile,
1157
1250
selectedTab,
1158
1251
galleries,
1159
1252
}: Readonly<{
1253
1253
+
followUri?: string;
1160
1254
loggedInUserDid?: string;
1161
1255
timelineItems: TimelineItem[];
1162
1256
profile: Un$Typed<ProfileView>;
1163
1257
selectedTab?: string;
1164
1258
galleries?: GalleryView[];
1165
1259
}>) {
1260
1260
+
const isCreator = loggedInUserDid === profile.did;
1166
1261
return (
1167
1262
<div class="px-4 mb-4" id="profile-page">
1168
1263
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between my-4">
···
1172
1267
<p class="text-zinc-600 dark:text-zinc-500">@{profile.handle}</p>
1173
1268
<p class="my-2">{profile.description}</p>
1174
1269
</div>
1175
1175
-
{loggedInUserDid === profile.did
1270
1270
+
{!isCreator && loggedInUserDid
1271
1271
+
? (
1272
1272
+
<div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row">
1273
1273
+
<FollowButton followeeDid={profile.did} followUri={followUri} />
1274
1274
+
</div>
1275
1275
+
)
1276
1276
+
: null}
1277
1277
+
{isCreator
1176
1278
? (
1177
1279
<div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row">
1178
1280
<Button variant="primary" class="w-full sm:w-fit" asChild>
···
2039
2141
async function onSignedIn({ actor, ctx }: onSignedInArgs) {
2040
2142
await ctx.backfillCollections(
2041
2143
[actor.did],
2042
2042
-
[...ctx.cfg.collections!, "app.bsky.actor.profile"],
2144
2144
+
[
2145
2145
+
...ctx.cfg.collections!,
2146
2146
+
"app.bsky.actor.profile",
2147
2147
+
"app.bsky.graph.follow",
2148
2148
+
],
2043
2149
);
2044
2150
2045
2151
const profileResults = ctx.indexService.getRecords<Profile>(
+3
static/styles.css
···
444
444
border-style: var(--tw-border-style);
445
445
border-width: 1px;
446
446
}
447
447
+
.border-zinc-200 {
448
448
+
border-color: var(--color-zinc-200);
449
449
+
}
447
450
.border-zinc-900 {
448
451
border-color: var(--color-zinc-900);
449
452
}