the statusphere demo reworked into a vite/react app in a monorepo

Compare changes

Choose any two refs to compare.

Changed files
+436 -145
.tangled
workflows
lexicons
xyz
statusphere
packages
+27
.tangled/workflows/deploy.yml
··· 1 + # Set the following secrets in your repo's pipeline settings: 2 + # RAILWAY_TOKEN 3 + # RAILWAY_SERVICE_ID 4 + 5 + when: 6 + - event: ["push"] 7 + branch: ["main"] 8 + 9 + engine: "nixery" 10 + 11 + dependencies: 12 + nixpkgs: 13 + - rustup 14 + - gcc 15 + 16 + steps: 17 + - name: Install Rust toolchain 18 + command: rustup default stable 19 + 20 + - name: Install Railway CLI 21 + command: cargo install railwayapp --locked 22 + 23 + - name: Link `railway` executable 24 + command: ln -s /tangled/home/.cargo/bin/railway /bin/railway 25 + 26 + - name: Deploy to Railway 27 + command: railway up --ci --service=$RAILWAY_SERVICE_ID
+1 -3
lexicons/xyz/statusphere/getStatuses.json
··· 13 13 "minimum": 1, 14 14 "maximum": 100, 15 15 "default": 50 16 - }, 17 - "cursor": { "type": "string" } 16 + } 18 17 } 19 18 }, 20 19 "output": { ··· 23 22 "type": "object", 24 23 "required": ["statuses"], 25 24 "properties": { 26 - "cursor": { "type": "string" }, 27 25 "statuses": { 28 26 "type": "array", 29 27 "items": {
+1 -1
packages/appview/README.md
··· 55 55 56 56 ## API Endpoints 57 57 58 - - `GET /client-metadata.json` - OAuth client metadata 58 + - `GET /oauth-client-metadata.json` - OAuth client metadata 59 59 - `GET /oauth/callback` - OAuth callback endpoint 60 60 - `POST /login` - Login with handle 61 61 - `POST /logout` - Logout current user
+2 -1
packages/appview/package.json
··· 34 34 "iron-session": "^8.0.4", 35 35 "kysely": "^0.27.5", 36 36 "multiformats": "^13.3.2", 37 - "pino": "^9.6.0" 37 + "pino": "^9.6.0", 38 + "ws": "^8.18.1" 38 39 }, 39 40 "devDependencies": { 40 41 "@atproto/lex-cli": "^0.6.1",
+15 -2
packages/appview/src/api/oauth.ts
··· 9 9 const router = express.Router() 10 10 11 11 // OAuth metadata 12 - router.get('/client-metadata.json', (_req, res) => { 12 + router.get('/oauth-client-metadata.json', (_req, res) => { 13 13 res.json(ctx.oauthClient.clientMetadata) 14 14 }) 15 15 ··· 51 51 router.post('/oauth/initiate', async (req, res) => { 52 52 // Validate 53 53 const handle = req.body?.handle 54 - if (typeof handle !== 'string' || !isValidHandle(handle)) { 54 + if ( 55 + typeof handle !== 'string' || 56 + !(isValidHandle(handle) || isValidUrl(handle)) 57 + ) { 55 58 res.status(400).json({ error: 'Invalid handle' }) 56 59 return 57 60 } ··· 81 84 82 85 return router 83 86 } 87 + 88 + function isValidUrl(url: string): boolean { 89 + try { 90 + const urlp = new URL(url) 91 + // http or https 92 + return urlp.protocol === 'http:' || urlp.protocol === 'https:' 93 + } catch (error) { 94 + return false 95 + } 96 + }
+1 -1
packages/appview/src/auth/client.ts
··· 17 17 clientMetadata: { 18 18 client_name: 'Statusphere React App', 19 19 client_id: publicUrl 20 - ? `${url}/client-metadata.json` 20 + ? `${url}/oauth-client-metadata.json` 21 21 : `http://localhost?redirect_uri=${enc(`${url}/oauth/callback`)}&scope=${enc('atproto transition:generic')}`, 22 22 client_uri: url, 23 23 redirect_uris: [`${url}/oauth/callback`],
+4 -3
packages/appview/src/context.ts
··· 2 2 import { Firehose } from '@atproto/sync' 3 3 import pino from 'pino' 4 4 5 - import { Database } from './db' 6 - import { BidirectionalResolver } from './id-resolver' 5 + import { Database } from '#/db' 6 + import { BidirectionalResolver } from '#/id-resolver' 7 + import { Jetstream } from '#/ingestors' 7 8 8 9 // Application state passed to the router and elsewhere 9 10 export type AppContext = { 10 11 db: Database 11 - ingester: Firehose 12 + ingester: Firehose | Jetstream<any> 12 13 logger: pino.Logger 13 14 oauthClient: OAuthClient 14 15 resolver: BidirectionalResolver
+15
packages/appview/src/db.ts
··· 53 53 }, 54 54 } 55 55 56 + migrations['003'] = { 57 + async up(db: Kysely<unknown>) {}, 58 + async down(_db: Kysely<unknown>) {}, 59 + } 60 + 56 61 migrations['002'] = { 57 62 async up(db: Kysely<unknown>) { 58 63 await db.schema 59 64 .createTable('cursor') 60 65 .addColumn('id', 'integer', (col) => col.primaryKey()) 61 66 .addColumn('seq', 'integer', (col) => col.notNull()) 67 + .execute() 68 + 69 + // Insert initial cursor values: 70 + // id=1 is for firehose, id=2 is for jetstream 71 + await db 72 + .insertInto('cursor' as never) 73 + .values([ 74 + { id: 1, seq: 0 }, 75 + { id: 2, seq: 0 }, 76 + ]) 62 77 .execute() 63 78 }, 64 79 async down(db: Kysely<unknown>) {
+6 -5
packages/appview/src/index.ts
··· 14 14 import { createDb, migrateToLatest } from '#/db' 15 15 import * as error from '#/error' 16 16 import { createBidirectionalResolver, createIdResolver } from '#/id-resolver' 17 - import { createIngester } from '#/ingester' 17 + import { createFirehoseIngester, createJetstreamIngester } from '#/ingestors' 18 18 import { createServer } from '#/lexicons' 19 19 import { env } from '#/lib/env' 20 20 ··· 36 36 // Create the atproto utilities 37 37 const oauthClient = await createClient(db) 38 38 const baseIdResolver = createIdResolver() 39 - const ingester = await createIngester(db, baseIdResolver) 39 + const ingester = await createJetstreamIngester(db) 40 + // Alternative: const ingester = await createFirehoseIngester(db, baseIdResolver) 40 41 const resolver = createBidirectionalResolver(baseIdResolver) 41 42 const ctx = { 42 43 db, ··· 103 104 }) 104 105 } 105 106 } else { 106 - server.xrpc.router.set('trust proxy', true) 107 + app.set('trust proxy', true) 107 108 } 108 109 109 110 // Use the port from env (should be 3001 for the API server) ··· 119 120 async close() { 120 121 this.ctx.logger.info('sigint received, shutting down') 121 122 await this.ctx.ingester.destroy() 122 - return new Promise<void>((resolve) => { 123 + await new Promise<void>((resolve) => { 123 124 this.server.close(() => { 124 125 this.ctx.logger.info('server closed') 125 126 resolve() ··· 134 135 const onCloseSignal = async () => { 135 136 setTimeout(() => process.exit(1), 10000).unref() // Force shutdown after 10s 136 137 await server.close() 137 - process.exit() 138 + process.exit(0) 138 139 } 139 140 140 141 process.on('SIGINT', onCloseSignal)
-90
packages/appview/src/ingester.ts
··· 1 - import { IdResolver } from '@atproto/identity' 2 - import { Firehose, MemoryRunner, type Event } from '@atproto/sync' 3 - import { XyzStatusphereStatus } from '@statusphere/lexicon' 4 - import pino from 'pino' 5 - 6 - import type { Database } from '#/db' 7 - 8 - export async function createIngester(db: Database, idResolver: IdResolver) { 9 - const logger = pino({ name: 'firehose ingestion' }) 10 - 11 - const cursor = await db 12 - .selectFrom('cursor') 13 - .where('id', '=', 1) 14 - .select('seq') 15 - .executeTakeFirst() 16 - 17 - logger.info(`start cursor: ${cursor?.seq}`) 18 - 19 - // For throttling cursor writes 20 - let lastCursorWrite = 0 21 - 22 - const runner = new MemoryRunner({ 23 - startCursor: cursor?.seq || undefined, 24 - setCursor: async (seq) => { 25 - const now = Date.now() 26 - 27 - if (now - lastCursorWrite >= 10000) { 28 - lastCursorWrite = now 29 - await db 30 - .updateTable('cursor') 31 - .set({ seq }) 32 - .where('id', '=', 1) 33 - .execute() 34 - } 35 - }, 36 - }) 37 - 38 - return new Firehose({ 39 - idResolver, 40 - runner, 41 - handleEvent: async (evt: Event) => { 42 - // Watch for write events 43 - if (evt.event === 'create' || evt.event === 'update') { 44 - const now = new Date() 45 - const record = evt.record 46 - 47 - // If the write is a valid status update 48 - if ( 49 - evt.collection === 'xyz.statusphere.status' && 50 - XyzStatusphereStatus.isRecord(record) 51 - ) { 52 - const validatedRecord = XyzStatusphereStatus.validateRecord(record) 53 - if (!validatedRecord.success) return 54 - // Store the status in our SQLite 55 - await db 56 - .insertInto('status') 57 - .values({ 58 - uri: evt.uri.toString(), 59 - authorDid: evt.did, 60 - status: validatedRecord.value.status, 61 - createdAt: validatedRecord.value.createdAt, 62 - indexedAt: now.toISOString(), 63 - }) 64 - .onConflict((oc) => 65 - oc.column('uri').doUpdateSet({ 66 - status: validatedRecord.value.status, 67 - indexedAt: now.toISOString(), 68 - }), 69 - ) 70 - .execute() 71 - } 72 - } else if ( 73 - evt.event === 'delete' && 74 - evt.collection === 'xyz.statusphere.status' 75 - ) { 76 - // Remove the status from our SQLite 77 - await db 78 - .deleteFrom('status') 79 - .where('uri', '=', evt.uri.toString()) 80 - .execute() 81 - } 82 - }, 83 - onError: (err: Error) => { 84 - logger.error({ err }, 'error on firehose ingestion') 85 - }, 86 - filterCollections: ['xyz.statusphere.status'], 87 - excludeIdentity: true, 88 - excludeAccount: true, 89 - }) 90 - }
+93
packages/appview/src/ingestors/firehose.ts
··· 1 + import { IdResolver } from '@atproto/identity' 2 + import { Firehose, MemoryRunner, type Event } from '@atproto/sync' 3 + import { XyzStatusphereStatus } from '@statusphere/lexicon' 4 + import pino from 'pino' 5 + 6 + import type { Database } from '#/db' 7 + 8 + export async function createFirehoseIngester( 9 + db: Database, 10 + idResolver: IdResolver, 11 + ) { 12 + const logger = pino({ name: 'firehose ingestion' }) 13 + 14 + const cursor = await db 15 + .selectFrom('cursor') 16 + .where('id', '=', 1) 17 + .select('seq') 18 + .executeTakeFirst() 19 + 20 + logger.info(`start cursor: ${cursor?.seq}`) 21 + 22 + // For throttling cursor writes 23 + let lastCursorWrite = 0 24 + 25 + const runner = new MemoryRunner({ 26 + startCursor: cursor?.seq || undefined, 27 + setCursor: async (seq) => { 28 + const now = Date.now() 29 + 30 + if (now - lastCursorWrite >= 10000) { 31 + lastCursorWrite = now 32 + await db 33 + .updateTable('cursor') 34 + .set({ seq }) 35 + .where('id', '=', 1) 36 + .execute() 37 + } 38 + }, 39 + }) 40 + 41 + return new Firehose({ 42 + idResolver, 43 + runner, 44 + handleEvent: async (evt: Event) => { 45 + // Watch for write events 46 + if (evt.event === 'create' || evt.event === 'update') { 47 + const now = new Date() 48 + const record = evt.record 49 + 50 + // If the write is a valid status update 51 + if ( 52 + evt.collection === 'xyz.statusphere.status' && 53 + XyzStatusphereStatus.isRecord(record) 54 + ) { 55 + const validatedRecord = XyzStatusphereStatus.validateRecord(record) 56 + if (!validatedRecord.success) return 57 + // Store the status in our SQLite 58 + await db 59 + .insertInto('status') 60 + .values({ 61 + uri: evt.uri.toString(), 62 + authorDid: evt.did, 63 + status: validatedRecord.value.status, 64 + createdAt: validatedRecord.value.createdAt, 65 + indexedAt: now.toISOString(), 66 + }) 67 + .onConflict((oc) => 68 + oc.column('uri').doUpdateSet({ 69 + status: validatedRecord.value.status, 70 + indexedAt: now.toISOString(), 71 + }), 72 + ) 73 + .execute() 74 + } 75 + } else if ( 76 + evt.event === 'delete' && 77 + evt.collection === 'xyz.statusphere.status' 78 + ) { 79 + // Remove the status from our SQLite 80 + await db 81 + .deleteFrom('status') 82 + .where('uri', '=', evt.uri.toString()) 83 + .execute() 84 + } 85 + }, 86 + onError: (err: Error) => { 87 + logger.error({ err }, 'error on firehose ingestion') 88 + }, 89 + filterCollections: ['xyz.statusphere.status'], 90 + excludeIdentity: true, 91 + excludeAccount: true, 92 + }) 93 + }
+2
packages/appview/src/ingestors/index.ts
··· 1 + export * from './jetstream' 2 + export * from './firehose'
+223
packages/appview/src/ingestors/jetstream.ts
··· 1 + import { XyzStatusphereStatus } from '@statusphere/lexicon' 2 + import pino from 'pino' 3 + import WebSocket from 'ws' 4 + 5 + import type { Database } from '#/db' 6 + import { env } from '#/lib/env' 7 + 8 + export async function createJetstreamIngester(db: Database) { 9 + const logger = pino({ name: 'jetstream ingestion' }) 10 + 11 + const cursor = await db 12 + .selectFrom('cursor') 13 + .where('id', '=', 2) 14 + .select('seq') 15 + .executeTakeFirst() 16 + 17 + logger.info(`start cursor: ${cursor?.seq}`) 18 + 19 + // For throttling cursor writes 20 + let lastCursorWrite = 0 21 + 22 + return new Jetstream<XyzStatusphereStatus.Record>({ 23 + instanceUrl: env.JETSTREAM_INSTANCE, 24 + logger, 25 + cursor: cursor?.seq || undefined, 26 + setCursor: async (seq) => { 27 + const now = Date.now() 28 + 29 + if (now - lastCursorWrite >= 30000) { 30 + lastCursorWrite = now 31 + logger.info(`writing cursor: ${seq}`) 32 + await db 33 + .updateTable('cursor') 34 + .set({ seq }) 35 + .where('id', '=', 2) 36 + .execute() 37 + } 38 + }, 39 + handleEvent: async (evt) => { 40 + // ignore account and identity events 41 + if ( 42 + evt.kind !== 'commit' || 43 + evt.commit.collection !== 'xyz.statusphere.status' 44 + ) 45 + return 46 + 47 + const now = new Date() 48 + const uri = `at://${evt.did}/${evt.commit.collection}/${evt.commit.rkey}` 49 + 50 + if ( 51 + (evt.commit.operation === 'create' || 52 + evt.commit.operation === 'update') && 53 + XyzStatusphereStatus.isRecord(evt.commit.record) 54 + ) { 55 + const validatedRecord = XyzStatusphereStatus.validateRecord( 56 + evt.commit.record, 57 + ) 58 + if (!validatedRecord.success) return 59 + 60 + await db 61 + .insertInto('status') 62 + .values({ 63 + uri, 64 + authorDid: evt.did, 65 + status: validatedRecord.value.status, 66 + createdAt: validatedRecord.value.createdAt, 67 + indexedAt: now.toISOString(), 68 + }) 69 + .onConflict((oc) => 70 + oc.column('uri').doUpdateSet({ 71 + status: validatedRecord.value.status, 72 + indexedAt: now.toISOString(), 73 + }), 74 + ) 75 + .execute() 76 + } else if (evt.commit.operation === 'delete') { 77 + await db.deleteFrom('status').where('uri', '=', uri).execute() 78 + } 79 + }, 80 + onError: (err) => { 81 + logger.error({ err }, 'error during jetstream ingestion') 82 + }, 83 + wantedCollections: ['xyz.statusphere.status'], 84 + }) 85 + } 86 + 87 + export class Jetstream<T> { 88 + private instanceUrl: string 89 + private logger: pino.Logger 90 + private handleEvent: (evt: JetstreamEvent<T>) => Promise<void> 91 + private onError: (err: unknown) => void 92 + private setCursor?: (seq: number) => Promise<void> 93 + private cursor?: number 94 + private ws?: WebSocket 95 + private isStarted = false 96 + private isDestroyed = false 97 + private wantedCollections: string[] 98 + 99 + constructor({ 100 + instanceUrl, 101 + logger, 102 + cursor, 103 + setCursor, 104 + handleEvent, 105 + onError, 106 + wantedCollections, 107 + }: { 108 + instanceUrl: string 109 + logger: pino.Logger 110 + cursor?: number 111 + setCursor?: (seq: number) => Promise<void> 112 + handleEvent: (evt: any) => Promise<void> 113 + onError: (err: any) => void 114 + wantedCollections: string[] 115 + }) { 116 + this.instanceUrl = instanceUrl 117 + this.logger = logger 118 + this.cursor = cursor 119 + this.setCursor = setCursor 120 + this.handleEvent = handleEvent 121 + this.onError = onError 122 + this.wantedCollections = wantedCollections 123 + } 124 + 125 + constructUrlWithQuery = (): string => { 126 + const params = new URLSearchParams() 127 + params.append('wantedCollections', this.wantedCollections.join(',')) 128 + if (this.cursor !== undefined) { 129 + params.append('cursor', this.cursor.toString()) 130 + } 131 + return `${this.instanceUrl}/subscribe?${params.toString()}` 132 + } 133 + 134 + start() { 135 + if (this.isStarted) return 136 + this.isStarted = true 137 + this.isDestroyed = false 138 + this.ws = new WebSocket(this.constructUrlWithQuery()) 139 + 140 + this.ws.on('open', () => { 141 + this.logger.info('Jetstream connection opened.') 142 + }) 143 + 144 + this.ws.on('message', async (data) => { 145 + try { 146 + const event: JetstreamEvent<T> = JSON.parse(data.toString()) 147 + 148 + // Update cursor if provided 149 + if (event.time_us !== undefined && this.setCursor) { 150 + await this.setCursor(event.time_us) 151 + } 152 + 153 + await this.handleEvent(event) 154 + } catch (err) { 155 + this.onError(err) 156 + } 157 + }) 158 + 159 + this.ws.on('error', (err) => { 160 + this.onError(err) 161 + }) 162 + 163 + this.ws.on('close', (code, reason) => { 164 + if (!this.isDestroyed) { 165 + this.logger.error(`Jetstream closed. Code: ${code}, Reason: ${reason}`) 166 + } 167 + this.isStarted = false 168 + }) 169 + } 170 + 171 + destroy() { 172 + if (this.ws) { 173 + this.isDestroyed = true 174 + this.ws.close() 175 + this.isStarted = false 176 + this.logger.info('jetstream destroyed gracefully') 177 + } 178 + } 179 + } 180 + 181 + type JetstreamEvent<T> = { 182 + did: string 183 + time_us: number 184 + } & (CommitEvent<T> | AccountEvent | IdentityEvent) 185 + 186 + type CommitEvent<T> = { 187 + kind: 'commit' 188 + commit: 189 + | { 190 + operation: 'create' | 'update' 191 + record: T 192 + rev: string 193 + collection: string 194 + rkey: string 195 + cid: string 196 + } 197 + | { 198 + operation: 'delete' 199 + rev: string 200 + collection: string 201 + rkey: string 202 + } 203 + } 204 + 205 + type IdentityEvent = { 206 + kind: 'identity' 207 + identity: { 208 + did: string 209 + handle: string 210 + seq: number 211 + time: string 212 + } 213 + } 214 + 215 + type AccountEvent = { 216 + kind: 'account' 217 + account: { 218 + active: boolean 219 + did: string 220 + seq: number 221 + time: string 222 + } 223 + }
-6
packages/appview/src/lexicons/lexicons.ts
··· 71 71 maximum: 100, 72 72 default: 50, 73 73 }, 74 - cursor: { 75 - type: 'string', 76 - }, 77 74 }, 78 75 }, 79 76 output: { ··· 82 79 type: 'object', 83 80 required: ['statuses'], 84 81 properties: { 85 - cursor: { 86 - type: 'string', 87 - }, 88 82 statuses: { 89 83 type: 'array', 90 84 items: {
-2
packages/appview/src/lexicons/types/xyz/statusphere/getStatuses.ts
··· 16 16 17 17 export interface QueryParams { 18 18 limit: number 19 - cursor?: string 20 19 } 21 20 22 21 export type InputSchema = undefined 23 22 24 23 export interface OutputSchema { 25 - cursor?: string 26 24 statuses: XyzStatusphereDefs.StatusView[] 27 25 } 28 26
+1
packages/appview/src/lib/env.ts
··· 15 15 COOKIE_SECRET: str({ devDefault: '0'.repeat(32) }), 16 16 SERVICE_DID: str({ default: undefined }), 17 17 PUBLIC_URL: str({ devDefault: '' }), 18 + JETSTREAM_INSTANCE: str({ default: 'wss://jetstream2.us-east.bsky.network' }), 18 19 })
+4 -1
packages/appview/src/lib/hydrate.ts
··· 7 7 import { AppContext } from '#/context' 8 8 import { Status } from '#/db' 9 9 10 + const INVALID_HANDLE = 'handle.invalid' 11 + 10 12 export async function statusToStatusView( 11 13 status: Status, 12 14 ctx: AppContext, ··· 19 21 did: status.authorDid, 20 22 handle: await ctx.resolver 21 23 .resolveDidToHandle(status.authorDid) 22 - .catch(() => 'invalid.handle'), 24 + .then((handle) => (handle.startsWith('did:') ? INVALID_HANDLE : handle)) 25 + .catch(() => INVALID_HANDLE), 23 26 }, 24 27 } 25 28 }
+7 -2
packages/client/index.html
··· 1 - <!DOCTYPE html> 1 + <!doctype html> 2 2 <html lang="en"> 3 3 <head> 4 4 <meta charset="UTF-8" /> 5 5 <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 7 <title>Statusphere React</title> 8 + <script 9 + defer 10 + data-domain="statusphere.mozzius.dev" 11 + src="https://plausible.mozzius.dev/js/script.js" 12 + ></script> 8 13 </head> 9 14 <body> 10 15 <div id="root"></div> 11 16 <script type="module" src="/src/main.tsx"></script> 12 17 </body> 13 - </html> 18 + </html>
+1 -1
packages/client/src/components/Header.tsx
··· 31 31 <img 32 32 src={user.profile.avatar} 33 33 alt={user.profile.displayName || user.profile.handle} 34 - className="w-8 h-8 rounded-full" 34 + className="w-8 h-8 rounded-full text-transparent" 35 35 /> 36 36 ) : ( 37 37 <div className="w-8 h-8 bg-gray-200 dark:bg-gray-700 rounded-full"></div>
+1 -1
packages/client/src/components/StatusForm.tsx
··· 5 5 import useAuth from '#/hooks/useAuth' 6 6 import api from '#/services/api' 7 7 8 - const STATUS_OPTIONS = [ 8 + export const STATUS_OPTIONS = [ 9 9 '๐Ÿ‘', 10 10 '๐Ÿ‘Ž', 11 11 '๐Ÿ’™',
+12 -2
packages/client/src/components/StatusList.tsx
··· 2 2 import { useQuery } from '@tanstack/react-query' 3 3 4 4 import api from '#/services/api' 5 + import { STATUS_OPTIONS } from './StatusForm' 5 6 6 7 const StatusList = () => { 7 8 // Use React Query to fetch and cache statuses ··· 24 25 // Destructure data 25 26 const statuses = data?.statuses || [] 26 27 28 + // Get a random emoji from the STATUS_OPTIONS array 29 + const randomEmoji = 30 + STATUS_OPTIONS[Math.floor(Math.random() * STATUS_OPTIONS.length)] 31 + 27 32 if (isPending && !data) { 28 33 return ( 29 - <div className="py-4 text-center text-gray-500 dark:text-gray-400"> 30 - Loading statuses... 34 + <div className="py-8 text-center"> 35 + <div className="text-5xl mb-2 animate-pulse inline-block"> 36 + {randomEmoji} 37 + </div> 38 + <div className="text-gray-500 dark:text-gray-400"> 39 + Loading statuses... 40 + </div> 31 41 </div> 32 42 ) 33 43 }
+7 -10
packages/client/src/pages/HomePage.tsx
··· 1 1 import Header from '#/components/Header' 2 - import StatusForm from '#/components/StatusForm' 2 + import StatusForm, { STATUS_OPTIONS } from '#/components/StatusForm' 3 3 import StatusList from '#/components/StatusList' 4 4 import { useAuth } from '#/hooks/useAuth' 5 5 6 6 const HomePage = () => { 7 7 const { user, loading, error } = useAuth() 8 8 9 + // Get a random emoji from the STATUS_OPTIONS array 10 + const randomEmoji = 11 + STATUS_OPTIONS[Math.floor(Math.random() * STATUS_OPTIONS.length)] 12 + 9 13 if (loading) { 10 14 return ( 11 - <div className="flex justify-center items-center py-16"> 12 - <div className="text-center p-6"> 13 - <h2 className="text-2xl font-semibold mb-2 text-gray-800 dark:text-gray-200"> 14 - Loading Statusphere... 15 - </h2> 16 - <p className="text-gray-600 dark:text-gray-400"> 17 - Setting up your experience 18 - </p> 19 - </div> 15 + <div className="flex justify-center items-center h-[80vh]"> 16 + <div className="text-9xl animate-pulse">{randomEmoji}</div> 20 17 </div> 21 18 ) 22 19 }
+5 -1
packages/client/src/pages/LoginPage.tsx
··· 49 49 <Header /> 50 50 51 51 <div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm max-w-md mx-auto w-full"> 52 - <h2 className="text-xl font-semibold mb-4">Login with your handle</h2> 52 + <h2 className="text-xl font-semibold mb-4">Login with ATProto</h2> 53 53 54 54 {error && ( 55 55 <div className="text-red-500 mb-4 p-2 bg-red-50 dark:bg-red-950 dark:bg-opacity-30 rounded-md"> ··· 74 74 disabled={pending} 75 75 className="w-full p-3 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-500 transition-colors" 76 76 /> 77 + <p className="text-gray-400 dark:text-gray-500 text-sm mt-2"> 78 + You can also enter an AT Protocol PDS URL, i.e.{' '} 79 + <span className="whitespace-nowrap">https://bsky.social</span> 80 + </p> 77 81 </div> 78 82 79 83 <button
+5 -5
packages/client/src/services/api.ts
··· 10 10 super(StatusphereAgent.fetchHandler) 11 11 } 12 12 13 - private static fetchHandler: Lexicon.AtpBaseClient['fetchHandler'] = async ( 13 + private static fetchHandler: Lexicon.AtpBaseClient['fetchHandler'] = ( 14 14 path, 15 15 options, 16 16 ) => { 17 - return await fetch(path, { 17 + return fetch(path, { 18 18 ...options, 19 19 headers: { 20 20 'Content-Type': 'application/json', ··· 62 62 }, 63 63 64 64 // Get current user 65 - async getCurrentUser(params: XyzStatusphereGetUser.QueryParams) { 65 + getCurrentUser(params: XyzStatusphereGetUser.QueryParams) { 66 66 return agent.xyz.statusphere.getUser(params) 67 67 }, 68 68 69 69 // Get statuses 70 - async getStatuses(params: XyzStatusphereGetStatuses.QueryParams) { 70 + getStatuses(params: XyzStatusphereGetStatuses.QueryParams) { 71 71 return agent.xyz.statusphere.getStatuses(params) 72 72 }, 73 73 74 74 // Create status 75 - async createStatus(params: XyzStatusphereSendStatus.InputSchema) { 75 + createStatus(params: XyzStatusphereSendStatus.InputSchema) { 76 76 return agent.xyz.statusphere.sendStatus(params) 77 77 }, 78 78 }
-6
packages/lexicon/src/lexicons.ts
··· 71 71 maximum: 100, 72 72 default: 50, 73 73 }, 74 - cursor: { 75 - type: 'string', 76 - }, 77 74 }, 78 75 }, 79 76 output: { ··· 82 79 type: 'object', 83 80 required: ['statuses'], 84 81 properties: { 85 - cursor: { 86 - type: 'string', 87 - }, 88 82 statuses: { 89 83 type: 'array', 90 84 items: {
-2
packages/lexicon/src/types/xyz/statusphere/getStatuses.ts
··· 15 15 16 16 export interface QueryParams { 17 17 limit?: number 18 - cursor?: string 19 18 } 20 19 21 20 export type InputSchema = undefined 22 21 23 22 export interface OutputSchema { 24 - cursor?: string 25 23 statuses: XyzStatusphereDefs.StatusView[] 26 24 } 27 25
+3
pnpm-lock.yaml
··· 89 89 pino: 90 90 specifier: ^9.6.0 91 91 version: 9.6.0 92 + ws: 93 + specifier: ^8.18.1 94 + version: 8.18.1 92 95 devDependencies: 93 96 '@atproto/lex-cli': 94 97 specifier: ^0.6.1