grain.social is a photo sharing platform built on atproto.

add bsky follow lexicon and button on profile

Changed files
+210 -3
__generated__
types
app
bsky
graph
lexicons
app
bsky
graph
static
+10
__generated__/index.ts
··· 63 63 export class AppBskyNS { 64 64 _server: Server 65 65 embed: AppBskyEmbedNS 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 + 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 + _server: Server 83 + 84 + constructor(server: Server) { 85 + this._server = server 86 + } 87 + } 88 + 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 + AppBskyGraphFollow: { 451 + lexicon: 1, 452 + id: 'app.bsky.graph.follow', 453 + defs: { 454 + main: { 455 + key: 'tid', 456 + type: 'record', 457 + record: { 458 + type: 'object', 459 + required: ['subject', 'createdAt'], 460 + properties: { 461 + subject: { 462 + type: 'string', 463 + format: 'did', 464 + }, 465 + createdAt: { 466 + type: 'string', 467 + format: 'datetime', 468 + }, 469 + }, 470 + }, 471 + description: 472 + "Record declaring a social 'follow' relationship of another account. Duplicate follows will be ignored by the AppView.", 473 + }, 474 + }, 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 + 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 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from "npm:@atproto/lexicon" 5 + import { CID } from "npm:multiformats/cid" 6 + import { validate as _validate } from '../../../../lexicons.ts' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util.ts' 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate 15 + const id = 'app.bsky.graph.follow' 16 + 17 + export interface Record { 18 + $type: 'app.bsky.graph.follow' 19 + subject: string 20 + createdAt: string 21 + [k: string]: unknown 22 + } 23 + 24 + const hashRecord = 'main' 25 + 26 + export function isRecord<V>(v: V) { 27 + return is$typed(v, id, hashRecord) 28 + } 29 + 30 + export function validateRecord<V>(v: V) { 31 + return validate<Record & V>(v, id, hashRecord, true) 32 + }
+2 -1
lexicons.json
··· 3 3 "app.feed.post", 4 4 "app.bsky.feed.post", 5 5 "app.bsky.actor.profile", 6 - "app.bsky.actor.defs" 6 + "app.bsky.actor.defs", 7 + "app.bsky.graph.follow" 7 8 ] 8 9 }
+28
lexicons/app/bsky/graph/follow.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.graph.follow", 4 + "defs": { 5 + "main": { 6 + "key": "tid", 7 + "type": "record", 8 + "record": { 9 + "type": "object", 10 + "required": [ 11 + "subject", 12 + "createdAt" 13 + ], 14 + "properties": { 15 + "subject": { 16 + "type": "string", 17 + "format": "did" 18 + }, 19 + "createdAt": { 20 + "type": "string", 21 + "format": "datetime" 22 + } 23 + } 24 + }, 25 + "description": "Record declaring a social 'follow' relationship of another account. Duplicate follows will be ignored by the AppView." 26 + } 27 + } 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 + 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 + let follow: WithBffMeta<BskyFollow> | undefined; 136 + if (ctx.currentUser) { 137 + follow = getFollow( 138 + profile.did, 139 + ctx.currentUser.did, 140 + ctx, 141 + ); 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 + 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 + 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 + ); 205 + }), 206 + route("/follow/:did", ["POST"], async (_req, params, ctx) => { 207 + requireAuth(ctx); 208 + const did = params.did; 209 + if (!did) return ctx.next(); 210 + const followUri = await ctx.createRecord<BskyFollow>( 211 + "app.bsky.graph.follow", 212 + { 213 + subject: did, 214 + createdAt: new Date().toISOString(), 215 + }, 216 + ); 217 + return ctx.html( 218 + <FollowButton followeeDid={did} followUri={followUri} />, 219 + ); 220 + }), 221 + route("/follow/:did/:rkey", ["DELETE"], async (_req, params, ctx) => { 222 + requireAuth(ctx); 223 + const did = params.did; 224 + const rkey = params.rkey; 225 + if (!did) return ctx.next(); 226 + await ctx.deleteRecord( 227 + `at://${ctx.currentUser.did}/app.bsky.graph.follow/${rkey}`, 228 + ); 229 + return ctx.html( 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 + function getFollow(followeeDid: string, followerDid: string, ctx: BffContext) { 660 + const { items: [follow] } = ctx.indexService.getRecords< 661 + WithBffMeta<BskyFollow> 662 + >( 663 + "app.bsky.graph.follow", 664 + { 665 + where: [ 666 + { 667 + field: "did", 668 + equals: followerDid, 669 + }, 670 + { 671 + field: "subject", 672 + equals: followeeDid, 673 + }, 674 + ], 675 + }, 676 + ); 677 + return follow; 678 + } 679 + 621 680 function getGalleryItemsAndPhotos( 622 681 ctx: BffContext, 623 682 galleries: WithBffMeta<Gallery>[], ··· 1150 1209 ); 1151 1210 } 1152 1211 1212 + function FollowButton({ 1213 + followeeDid, 1214 + followUri, 1215 + }: Readonly<{ followeeDid: string; load?: boolean; followUri?: string }>) { 1216 + const isFollowing = followUri; 1217 + return ( 1218 + <Button 1219 + variant="primary" 1220 + class={cn( 1221 + "w-full sm:w-fit", 1222 + isFollowing && 1223 + "bg-zinc-200 dark:bg-zinc-800 border-zinc-200 dark:border-zinc-800", 1224 + )} 1225 + {...(isFollowing 1226 + ? { 1227 + children: "Following", 1228 + "hx-delete": `/follow/${followeeDid}/${new AtUri(followUri).rkey}`, 1229 + } 1230 + : { 1231 + children: ( 1232 + <> 1233 + <i class="fa-solid fa-plus mr-2" />Follow 1234 + </> 1235 + ), 1236 + "hx-post": `/follow/${followeeDid}`, 1237 + })} 1238 + hx-trigger="click" 1239 + hx-target="this" 1240 + hx-swap="outerHTML" 1241 + /> 1242 + ); 1243 + } 1244 + 1153 1245 function ProfilePage({ 1246 + followUri, 1154 1247 loggedInUserDid, 1155 1248 timelineItems, 1156 1249 profile, 1157 1250 selectedTab, 1158 1251 galleries, 1159 1252 }: Readonly<{ 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 + 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 - {loggedInUserDid === profile.did 1270 + {!isCreator && loggedInUserDid 1271 + ? ( 1272 + <div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row"> 1273 + <FollowButton followeeDid={profile.did} followUri={followUri} /> 1274 + </div> 1275 + ) 1276 + : null} 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 - [...ctx.cfg.collections!, "app.bsky.actor.profile"], 2144 + [ 2145 + ...ctx.cfg.collections!, 2146 + "app.bsky.actor.profile", 2147 + "app.bsky.graph.follow", 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 + .border-zinc-200 { 448 + border-color: var(--color-zinc-200); 449 + } 447 450 .border-zinc-900 { 448 451 border-color: var(--color-zinc-900); 449 452 }