Skip to content

Commit d3f0c11

Browse files
authored
🚧 Show snapshots of records when available (#454)
* 🐛 Fix account takedown with high-sev takedowns (#431) :construction: Show snapshots of records when available :sparkles: Remove loading and error states for snapshots * :sparkles: Improve api auth * :broom: Cleanup
1 parent a57d1ed commit d3f0c11

File tree

8 files changed

+374
-42
lines changed

8 files changed

+374
-42
lines changed
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { NextRequest } from 'next/server'
2+
3+
const SNAPSHOT_API_URL = process.env.SNAPSHOT_API_URL
4+
const SNAPSHOT_AUTH_HEADER = process.env.SNAPSHOT_AUTH_HEADER
5+
6+
export async function GET(request: NextRequest) {
7+
const searchParams = request.nextUrl.searchParams
8+
const uri = searchParams.get('uri')
9+
10+
if (!uri) {
11+
return Response.json(
12+
{ error: 'Missing required query parameter: uri' },
13+
{ status: 400 },
14+
)
15+
}
16+
17+
try {
18+
const snapshotUrl = new URL('/get-snapshot', SNAPSHOT_API_URL)
19+
snapshotUrl.searchParams.set('uri', uri)
20+
21+
const headers: Record<string, string> = {
22+
'Content-Type': 'application/json',
23+
}
24+
if (SNAPSHOT_AUTH_HEADER) {
25+
headers['Authorization'] = SNAPSHOT_AUTH_HEADER
26+
}
27+
28+
const response = await fetch(snapshotUrl.toString(), {
29+
method: 'GET',
30+
headers,
31+
})
32+
33+
if (!response.ok) {
34+
const errorData = await response
35+
.json()
36+
.catch(() => ({ error: 'Unknown error' }))
37+
return Response.json(errorData, { status: response.status })
38+
}
39+
40+
const data = await response.json()
41+
return Response.json(data)
42+
} catch (error) {
43+
console.error('Error fetching snapshot:', error)
44+
return Response.json({ error: 'Internal server error' }, { status: 500 })
45+
}
46+
}

components/common/RecordCard.tsx

Lines changed: 131 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535

3636
import type { JSX } from 'react'
3737
import { VerificationBadge } from 'components/verification/Badge'
38+
import { RecordWithSnapshots } from './snapshots/RecordWithSnapshots'
3839

3940
export function RecordCard(props: {
4041
uri: string
@@ -73,29 +74,50 @@ export function RecordCard(props: {
7374
}
7475
if (parsed.collection === CollectionId.ProfileStatus) {
7576
return (
76-
<BaseRecordCard
77-
uri={uri}
78-
renderRecord={(record) => {
79-
console.log(record)
80-
return (
81-
<>
82-
<RepoCard did={parsed.did} />
83-
<ProfileStatusCard
84-
value={record.value as unknown as AppBskyActorStatus.Main}
85-
authorDid={parsed.did}
86-
/>
87-
</>
88-
)
89-
}}
90-
/>
77+
<RecordWithSnapshots uri={uri} className="pl-0">
78+
{(selectedSnapshot) => (
79+
<BaseRecordCard
80+
uri={uri}
81+
fallbackRecord={
82+
selectedSnapshot
83+
? (selectedSnapshot as unknown as ToolsOzoneModerationDefs.RecordViewDetail)
84+
: undefined
85+
}
86+
renderRecord={(record) => {
87+
const value = selectedSnapshot
88+
? (selectedSnapshot.value as unknown as AppBskyActorStatus.Main)
89+
: (record.value as unknown as AppBskyActorStatus.Main)
90+
91+
return (
92+
<>
93+
<RepoCard did={parsed.did} />
94+
<ProfileStatusCard value={value} authorDid={parsed.did} />
95+
</>
96+
)
97+
}}
98+
/>
99+
)}
100+
</RecordWithSnapshots>
91101
)
92102
}
93103
if (parsed?.collection === CollectionId.Profile) {
94104
return (
95-
<BaseRecordCard
96-
uri={uri}
97-
renderRecord={(record) => <RepoCard did={parsed.did} />}
98-
/>
105+
<RecordWithSnapshots uri={uri}>
106+
{(selectedSnapshot) => {
107+
const snapshotProfile = selectedSnapshot?.ozoneValue?.handle
108+
? (selectedSnapshot.ozoneValue as unknown as AppBskyActorDefs.ProfileViewDetailed)
109+
: undefined
110+
if (snapshotProfile) {
111+
return <RepoCardView did={parsed.did} profile={snapshotProfile} />
112+
}
113+
return (
114+
<BaseRecordCard
115+
uri={uri}
116+
renderRecord={(_record) => <RepoCard did={parsed.did} />}
117+
/>
118+
)
119+
}}
120+
</RecordWithSnapshots>
99121
)
100122
}
101123
return (
@@ -134,13 +156,43 @@ function PostCard({
134156
return post
135157
},
136158
})
159+
137160
if (error) {
138161
// Temp fallback for taken-down posts, re: TODO above
139162
return (
140-
<BaseRecordCard
141-
uri={uri}
142-
renderRecord={(record) => <GenericRecordCard {...{ record }} />}
143-
/>
163+
<RecordWithSnapshots uri={uri}>
164+
{(selectedSnapshot) => {
165+
if (selectedSnapshot?.ozoneValue?.thread?.post) {
166+
return (
167+
<PostAsCard
168+
dense
169+
showLabels={showLabels}
170+
parent={selectedSnapshot.ozoneValue.thread.parent}
171+
item={{ post: selectedSnapshot.ozoneValue.thread.post }}
172+
isAuthorTakendown={isAuthorTakendown}
173+
isAuthorDeactivated={isAuthorDeactivated}
174+
controls={['like', 'repost', 'workspace']}
175+
/>
176+
)
177+
}
178+
if (selectedSnapshot?.value) {
179+
return (
180+
<GenericRecordCard
181+
record={
182+
selectedSnapshot.value as unknown as ToolsOzoneModerationDefs.RecordViewDetail
183+
}
184+
/>
185+
)
186+
}
187+
return (
188+
<LoadingFailedDense
189+
className="text-gray-600 mb-2"
190+
error={error}
191+
noPadding
192+
/>
193+
)
194+
}}
195+
</RecordWithSnapshots>
144196
)
145197
}
146198

@@ -192,27 +244,49 @@ function PostCard({
192244
if (!data || !AppBskyFeedDefs.isThreadViewPost(data.thread)) {
193245
return null
194246
}
247+
248+
const thread = data.thread
249+
195250
return (
196-
<PostAsCard
197-
dense
198-
showLabels={showLabels}
199-
parent={data.thread.parent}
200-
item={{ post: data.thread.post }}
201-
isAuthorTakendown={isAuthorTakendown}
202-
isAuthorDeactivated={isAuthorDeactivated}
203-
controls={['like', 'repost', 'workspace']}
204-
/>
251+
<RecordWithSnapshots uri={uri}>
252+
{(selectedSnapshot) => {
253+
// If a snapshot is selected, modify the post data to show the snapshot
254+
const postItem = selectedSnapshot
255+
? {
256+
post: {
257+
...thread.post,
258+
cid: selectedSnapshot.cid,
259+
record: selectedSnapshot.value,
260+
},
261+
}
262+
: { post: thread.post }
263+
264+
return (
265+
<PostAsCard
266+
dense
267+
showLabels={showLabels}
268+
parent={thread.parent}
269+
item={postItem}
270+
isAuthorTakendown={isAuthorTakendown}
271+
isAuthorDeactivated={isAuthorDeactivated}
272+
controls={['like', 'repost', 'workspace']}
273+
/>
274+
)
275+
}}
276+
</RecordWithSnapshots>
205277
)
206278
}
207279

208280
function BaseRecordCard({
209281
uri,
210282
renderRecord,
283+
fallbackRecord,
211284
}: {
212285
uri: string
213286
renderRecord: (
214287
record: ToolsOzoneModerationDefs.RecordViewDetail,
215288
) => JSX.Element
289+
fallbackRecord?: ToolsOzoneModerationDefs.RecordViewDetail
216290
}) {
217291
const labelerAgent = useLabelerAgent()
218292

@@ -227,11 +301,15 @@ function BaseRecordCard({
227301
},
228302
})
229303
if (error) {
304+
// If we have a fallback record (e.g., from snapshot), use it instead of showing error
305+
if (fallbackRecord) {
306+
return renderRecord(fallbackRecord)
307+
}
230308
return (
231309
<LoadingFailedDense
232310
className="text-gray-600 mb-2"
233-
noPadding
234311
error={error}
312+
noPadding
235313
/>
236314
)
237315
}
@@ -363,12 +441,25 @@ export function RepoCard(props: { did: string }) {
363441
const { data: { repo, profile } = {}, error } = useRepoAndProfile({ did })
364442

365443
if (error) {
444+
const profileUri = `at://${did}/app.bsky.actor.profile/self`
366445
return (
367-
<LoadingFailedDense
368-
className="text-gray-600 mb-2"
369-
noPadding
370-
error={error}
371-
/>
446+
<RecordWithSnapshots uri={profileUri}>
447+
{(selectedSnapshot) => {
448+
const snapshotProfile = selectedSnapshot?.ozoneValue?.handle
449+
? (selectedSnapshot.ozoneValue as unknown as AppBskyActorDefs.ProfileViewDetailed)
450+
: undefined
451+
if (snapshotProfile) {
452+
return <RepoCardView did={did} profile={snapshotProfile} />
453+
}
454+
return (
455+
<LoadingFailedDense
456+
className="text-gray-600 mb-2"
457+
noPadding
458+
error={error}
459+
/>
460+
)
461+
}}
462+
</RecordWithSnapshots>
372463
)
373464
}
374465
if (!repo) {
@@ -417,9 +508,9 @@ export const RepoCardView = ({
417508
<span className="font-bold">{profile.displayName}</span>
418509
<span
419510
className="ml-1 text-gray-500 dark:text-gray-50"
420-
title={`@${repo?.handle || did}`}
511+
title={`@${repo?.handle || profile.handle || did}`}
421512
>
422-
@{repo?.handle || did}
513+
@{repo?.handle || profile.handle || did}
423514
</span>
424515
<VerificationBadge
425516
className="ml-0.5"
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { ReactNode, useState } from 'react'
2+
import { useRecordSnapshots, RecordSnapshot } from '@/lib/useRecordSnapshots'
3+
import { SnapshotIndicator } from './SnapshotIndicator'
4+
5+
interface RecordWithSnapshotsProps {
6+
uri: string
7+
children: (snapshot: RecordSnapshot | null) => ReactNode
8+
className?: string
9+
}
10+
11+
export function RecordWithSnapshots({
12+
uri,
13+
children,
14+
className = 'pl-10',
15+
}: RecordWithSnapshotsProps) {
16+
const [selectedSnapshot, setSelectedSnapshot] =
17+
useState<RecordSnapshot | null>(null)
18+
19+
const {
20+
data: snapshotData,
21+
error: snapshotError,
22+
isLoading: snapshotsLoading,
23+
} = useRecordSnapshots(uri)
24+
25+
const handleSelectSnapshot = (snapshot: RecordSnapshot) => {
26+
setSelectedSnapshot(snapshot)
27+
}
28+
29+
const handleResetToLive = () => {
30+
setSelectedSnapshot(null)
31+
}
32+
33+
return (
34+
<>
35+
{children(selectedSnapshot)}
36+
<div className={`flex items-center gap-2 ${className}`}>
37+
<SnapshotIndicator
38+
snapshots={snapshotData?.snapshots}
39+
total={snapshotData?.total}
40+
error={!!snapshotError}
41+
isLoading={snapshotsLoading}
42+
onSelectSnapshot={handleSelectSnapshot}
43+
/>
44+
{selectedSnapshot && (
45+
<button
46+
type="button"
47+
onClick={handleResetToLive}
48+
className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
49+
>
50+
(viewing snapshot - click to view live version)
51+
</button>
52+
)}
53+
</div>
54+
</>
55+
)
56+
}

0 commit comments

Comments
 (0)