grain.social is a photo sharing platform built on atproto. grain.social
atproto photography appview
57
fork

Configure Feed

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

add bsky follow lexicon and button on profile

+210 -3
+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 }