+27
.tangled/workflows/deploy.yml
+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
+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
+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
+2
-1
packages/appview/package.json
+15
-2
packages/appview/src/api/oauth.ts
+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
+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
+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
+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
+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
-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
+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
+2
packages/appview/src/ingestors/index.ts
+223
packages/appview/src/ingestors/jetstream.ts
+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
-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
-2
packages/appview/src/lexicons/types/xyz/statusphere/getStatuses.ts
+1
packages/appview/src/lib/env.ts
+1
packages/appview/src/lib/env.ts
+4
-1
packages/appview/src/lib/hydrate.ts
+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
+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
+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
+1
-1
packages/client/src/components/StatusForm.tsx
+12
-2
packages/client/src/components/StatusList.tsx
+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
+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
+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
+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
-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
-2
packages/lexicon/src/types/xyz/statusphere/getStatuses.ts