Statusphere, but in atcute and SvelteKit
atproto svelte sveltekit drizzle atcute typescript
at trunk 207 lines 5.1 kB view raw
1import { invalid } from '@sveltejs/kit'; 2 3import * as v from 'valibot'; 4 5import { ComAtprotoRepoCreateRecord } from '@atcute/atproto'; 6import { ok } from '@atcute/client'; 7import type { CanonicalResourceUri, Did, Handle } from '@atcute/lexicons'; 8import * as TID from '@atcute/tid'; 9import { and, desc, eq, inArray, lt, or } from 'drizzle-orm'; 10 11import { form, query } from '$app/server'; 12 13import type { XyzStatusphereStatus } from '$lib/lexicons'; 14import { requireAuth } from '$lib/server/auth'; 15import { db, schema } from '$lib/server/db'; 16import { statusOptions } from '$lib/status-options'; 17 18export interface CurrentUser { 19 did: Did; 20 handle: Handle; 21 displayName?: string; 22} 23 24/** returns the current user's profile, or null if not signed in */ 25export const getCurrentUser = query(async (): Promise<CurrentUser | null> => { 26 let did: Did; 27 try { 28 const auth = await requireAuth(); 29 did = auth.session.did; 30 } catch { 31 return null; 32 } 33 34 const [identity, profile] = await Promise.all([ 35 db.select().from(schema.identity).where(eq(schema.identity.did, did)).get(), 36 db.select().from(schema.profile).where(eq(schema.profile.did, did)).get(), 37 ]); 38 39 return { 40 did, 41 handle: (identity?.handle ?? 'handle.invalid') as Handle, 42 displayName: profile?.record.displayName ?? undefined, 43 }; 44}); 45 46const encodeCursor = (sortAt: number, uri: string): string => { 47 return `${sortAt}:${uri}`; 48}; 49 50const cursorSchema = v.pipe( 51 v.string(), 52 v.rawTransform(({ dataset, addIssue, NEVER }) => { 53 const input = dataset.value; 54 55 const idx = input.indexOf(':'); 56 if (idx === -1) { 57 addIssue({ message: 'invalid cursor format' }); 58 return NEVER; 59 } 60 61 const sortAt = parseInt(input.slice(0, idx), 10); 62 const uri = input.slice(idx + 1); 63 64 if (Number.isNaN(sortAt) || !uri) { 65 addIssue({ message: 'invalid cursor format' }); 66 return NEVER; 67 } 68 69 return { sortAt, uri }; 70 }), 71); 72 73export const postStatus = form( 74 v.object({ 75 status: v.pipe(v.string(), v.minLength(1), v.maxLength(32), v.maxGraphemes(1)), 76 }), 77 async ({ status }, issue) => { 78 const { session, client } = await requireAuth(); 79 80 if (!statusOptions.includes(status)) { 81 invalid(issue.status(`invalid status`)); 82 } 83 84 const rkey = TID.now(); 85 const createdAt = new Date().toISOString(); 86 87 const record: XyzStatusphereStatus.Main = { 88 $type: 'xyz.statusphere.status', 89 createdAt: createdAt, 90 status: status, 91 }; 92 93 try { 94 await ok( 95 client.call(ComAtprotoRepoCreateRecord, { 96 input: { 97 repo: session.did, 98 collection: 'xyz.statusphere.status', 99 rkey: rkey, 100 record, 101 }, 102 }), 103 ); 104 } catch (err) { 105 console.error(`failed to post status:`, err); 106 107 invalid(`could not post status - please try again`); 108 } 109 110 // insert locally so we don't have to wait for ingester 111 { 112 const uri: CanonicalResourceUri = `at://${session.did}/xyz.statusphere.status/${rkey}`; 113 const indexedAt = Date.now(); 114 const sortAt = Math.min(Date.parse(createdAt), indexedAt); 115 116 await db 117 .insert(schema.status) 118 .values({ 119 uri, 120 authorDid: session.did, 121 rkey, 122 record, 123 sortAt, 124 indexedAt, 125 }) 126 .onConflictDoNothing() 127 .run(); 128 } 129 }, 130); 131 132export interface AuthorView { 133 did: Did; 134 handle: Handle; 135 displayName?: string; 136 avatar?: string; 137} 138 139export interface StatusView { 140 author: AuthorView; 141 record: XyzStatusphereStatus.Main; 142 indexedAt: string; 143} 144 145export interface TimelineResponse { 146 cursor: string | undefined; 147 statuses: StatusView[]; 148} 149 150export const getTimeline = query( 151 v.object({ 152 cursor: v.optional(cursorSchema), 153 }), 154 async ({ cursor }): Promise<TimelineResponse> => { 155 const limit = 20; 156 157 const statusRows = await db 158 .select() 159 .from(schema.status) 160 .where( 161 cursor 162 ? or( 163 lt(schema.status.sortAt, cursor.sortAt), 164 and(eq(schema.status.sortAt, cursor.sortAt), lt(schema.status.uri, cursor.uri)), 165 ) 166 : undefined, 167 ) 168 .orderBy(desc(schema.status.sortAt), desc(schema.status.uri)) 169 .limit(limit + 1) 170 .all(); 171 172 const hasMore = statusRows.length > limit; 173 const items = hasMore ? statusRows.slice(0, limit) : statusRows; 174 175 const dids = [...new Set(items.map((s) => s.authorDid))]; 176 177 const [identities, profiles] = await Promise.all([ 178 db.select().from(schema.identity).where(inArray(schema.identity.did, dids)).all(), 179 db.select().from(schema.profile).where(inArray(schema.profile.did, dids)).all(), 180 ]); 181 182 const identityMap = new Map(identities.map((i) => [i.did, i])); 183 const profileMap = new Map(profiles.map((p) => [p.did, p])); 184 185 const statuses = items.map((s): StatusView => { 186 const identity = identityMap.get(s.authorDid); 187 const profile = profileMap.get(s.authorDid); 188 189 return { 190 author: { 191 did: s.authorDid as Did, 192 handle: (identity?.handle ?? 'handle.invalid') as Handle, 193 displayName: profile?.record.displayName ?? undefined, 194 }, 195 record: s.record, 196 indexedAt: new Date(s.sortAt).toISOString(), 197 }; 198 }); 199 200 const last = items[items.length - 1]; 201 202 return { 203 cursor: hasMore && last ? encodeCursor(last.sortAt, last.uri) : undefined, 204 statuses, 205 }; 206 }, 207);