Statusphere, but in atcute and SvelteKit
atproto
svelte
sveltekit
drizzle
atcute
typescript
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);