0
2
web: Writes, reports and notifications go through grain-serve
Only what this pull adds. It is built on grain runs on a Rust server modelled on knotless, without hatk.
test/web/writes.test.tsnew+103−0
| @@ -0,0 +1,103 @@ | ||
import { afterEach, describe, expect, test, vi } from "vitest"; | ||
import { LABEL_DEFINITIONS, REPORT_REASONS } from "$lib/labels"; | ||
import { | ||
applyWrites, | ||
createRecord, | ||
createWrite, | ||
deleteRecord, | ||
deleteWrite, | ||
putRecord, | ||
uploadBlob, | ||
} from "$lib/utils/records"; | ||
import { getRecordAtPds, pdsOf } from "$lib/pds"; | ||
| ||
type Sent = { url: string; init: RequestInit }; | ||
| ||
function server(answer: (url: string) => unknown = () => ({})) { | ||
const sent: Sent[] = []; | ||
vi.stubGlobal("window", {}); | ||
vi.stubGlobal("fetch", async (url: string, init: RequestInit = {}) => { | ||
sent.push({ url, init }); | ||
return new Response(JSON.stringify(answer(url)), { status: 200 }); | ||
}); | ||
return sent; | ||
} | ||
| ||
const body = (s: Sent) => JSON.parse(s.init.body as string); | ||
| ||
afterEach(() => vi.unstubAllGlobals()); | ||
| ||
describe("reports", () => { | ||
test("every label a report can name has the reason grain-serve files under it", () => { | ||
for (const def of LABEL_DEFINITIONS) expect(REPORT_REASONS[def.identifier]).toBeTruthy(); | ||
expect(REPORT_REASONS.csae).toMatch(/#reasonChildSafety/); | ||
expect(REPORT_REASONS.copyright).toBe("social.grain.moderation.defs#reasonCopyright"); | ||
}); | ||
}); | ||
| ||
describe("writes", () => { | ||
test("each goes to its social.grain.repo method, without a repo", async () => { | ||
const sent = server(() => ({ uri: "at://did:plc:a/social.grain.favorite/1", cid: "c" })); | ||
const record = { subject: "at://x", createdAt: "t" }; | ||
const made = await createRecord({ collection: "social.grain.favorite", record }); | ||
await putRecord({ collection: "social.grain.actor.profile", rkey: "self", record }); | ||
await deleteRecord({ collection: "social.grain.favorite", rkey: "1" }); | ||
expect(made.uri).toBe("at://did:plc:a/social.grain.favorite/1"); | ||
expect(sent.map((s) => s.url)).toEqual([ | ||
"/xrpc/social.grain.repo.createRecord", | ||
"/xrpc/social.grain.repo.putRecord", | ||
"/xrpc/social.grain.repo.deleteRecord", | ||
]); | ||
expect(body(sent[0])).toEqual({ collection: "social.grain.favorite", record }); | ||
expect(body(sent[1])).toMatchObject({ rkey: "self" }); | ||
expect(body(sent[2])).toEqual({ collection: "social.grain.favorite", rkey: "1" }); | ||
}); | ||
| ||
test("applyWrites names grain's write types, and sends none for no writes", async () => { | ||
const sent = server(() => ({ results: [] })); | ||
await applyWrites([]); | ||
expect(sent).toHaveLength(0); | ||
await applyWrites([ | ||
createWrite("social.grain.gallery", "a", { title: "t" }), | ||
deleteWrite("social.grain.photo", "b"), | ||
]); | ||
expect(sent[0].url).toBe("/xrpc/social.grain.repo.applyWrites"); | ||
expect(body(sent[0]).writes.map((w: { $type: string }) => w.$type)).toEqual([ | ||
"social.grain.repo.applyWrites#create", | ||
"social.grain.repo.applyWrites#delete", | ||
]); | ||
}); | ||
| ||
test("a blob goes up as its bytes", async () => { | ||
const sent = server(() => ({ blob: { $type: "blob" } })); | ||
const blob = new Blob([new Uint8Array([1])], { type: "image/jpeg" }); | ||
expect((await uploadBlob(blob)).blob).toEqual({ $type: "blob" }); | ||
expect(sent[0].url).toBe("/xrpc/social.grain.repo.uploadBlob"); | ||
expect(sent[0].init.body).toBe(blob); | ||
}); | ||
}); | ||
| ||
describe("pds", () => { | ||
test("finds a PDS by the DID document, and reads a record there", async () => { | ||
const sent = server((url) => | ||
url.startsWith("https://plc.directory/") | ||
? { service: [{ id: "#atproto_pds", serviceEndpoint: "https://pds.test/" }] } | ||
: { uri: "at://did:plc:pds1/social.grain.actor.profile/self", value: { avatar: 1 } }, | ||
); | ||
expect(await pdsOf("did:plc:pds1")).toBe("https://pds.test"); | ||
const got = await getRecordAtPds("at://did:plc:pds1/social.grain.actor.profile/self"); | ||
expect(got?.value).toEqual({ avatar: 1 }); | ||
expect(sent.at(-1)!.url).toBe( | ||
"https://pds.test/xrpc/com.atproto.repo.getRecord?repo=did%3Aplc%3Apds1&collection=social.grain.actor.profile&rkey=self", | ||
); | ||
expect(sent.filter((s) => s.url.startsWith("https://plc.directory/"))).toHaveLength(1); | ||
}); | ||
| ||
test("a did:web's document is at its host", async () => { | ||
const sent = server(() => ({ | ||
service: [{ type: "AtprotoPersonalDataServer", serviceEndpoint: "https://web.test" }], | ||
})); | ||
expect(await pdsOf("did:web:example.com")).toBe("https://web.test"); | ||
expect(sent[0].url).toBe("https://example.com/.well-known/did.json"); | ||
}); | ||
}); | ||
web/src/lib/components/molecules/Comment.svelte+3−3
| @@ -1,7 +1,7 @@ | ||
<script lang="ts"> | ||
import { createRecord, deleteRecord } from '$lib/utils/records' | ||
import type { CommentView } from '$lib/lexicons/social/grain/comment/defs.ts' | ||
import type { PhotoView } from '$lib/lexicons/social/grain/photo/defs.ts' | ||
import { callXrpc } from '$hatk/client' | ||
import { createMutation, useQueryClient } from '@tanstack/svelte-query' | ||
import Avatar from '../atoms/Avatar.svelte' | ||
import RichText from '../atoms/RichText.svelte' | ||
| @@ -41,7 +41,7 @@ | ||
| ||
const createFavMut = createMutation(() => ({ | ||
mutationFn: async () => { | ||
return await callXrpc('dev.hatk.createRecord', { | ||
return await createRecord({ | ||
collection: 'social.grain.favorite', | ||
record: { subject: comment.uri, createdAt: new Date().toISOString() }, | ||
}) | ||
| @@ -54,7 +54,7 @@ | ||
const deleteFavMut = createMutation<void, Error, string>(() => ({ | ||
mutationFn: async (uri) => { | ||
const rkey = uri.split('/').pop()! | ||
await callXrpc('dev.hatk.deleteRecord', { collection: 'social.grain.favorite', rkey }) | ||
await deleteRecord({ collection: 'social.grain.favorite', rkey }) | ||
}, | ||
onMutate: () => { | ||
const prev = favOverride !== undefined ? favOverride : viewerFav | ||
web/src/lib/components/molecules/FavoriteButton.svelte+3−2
| @@ -1,4 +1,5 @@ | ||
<script lang="ts"> | ||
import { createRecord, deleteRecord } from '$lib/utils/records' | ||
import { createMutation, useQueryClient } from '@tanstack/svelte-query' | ||
import { callXrpc } from '$hatk/client' | ||
import { Heart } from 'lucide-svelte' | ||
| @@ -49,7 +50,7 @@ | ||
// stands in for the uri a public favourite would have returned. | ||
return { uri: res?.rkey ?? 'pooled' } | ||
} | ||
return await callXrpc('dev.hatk.createRecord', { | ||
return await createRecord({ | ||
collection: 'social.grain.favorite', | ||
record: { subject: galleryUri, createdAt: new Date().toISOString() }, | ||
}) | ||
| @@ -78,7 +79,7 @@ | ||
return | ||
} | ||
const rkey = uri.split('/').pop()! | ||
await callXrpc('dev.hatk.deleteRecord', { | ||
await deleteRecord({ | ||
collection: 'social.grain.favorite', | ||
rkey, | ||
}) | ||
web/src/lib/components/molecules/FollowButton.svelte+4−5
| @@ -1,7 +1,6 @@ | ||
<script lang="ts"> | ||
import { createRecord, deleteRecord } from '$lib/utils/records' | ||
import { createMutation, useQueryClient } from '@tanstack/svelte-query' | ||
import { callXrpc } from '$hatk/client' | ||
import type { CreateRecord } from '$hatk' | ||
import { requireAuth } from '$lib/stores' | ||
| ||
let { | ||
| @@ -20,9 +19,9 @@ | ||
| ||
const queryClient = useQueryClient() | ||
| ||
const followMut = createMutation<CreateRecord['output'], Error>(() => ({ | ||
const followMut = createMutation<{ uri: string; cid: string }, Error>(() => ({ | ||
mutationFn: async () => { | ||
return await callXrpc('dev.hatk.createRecord', { | ||
return await createRecord({ | ||
collection: 'social.grain.graph.follow', | ||
record: { subject: did, createdAt: new Date().toISOString() }, | ||
}) | ||
| @@ -49,7 +48,7 @@ | ||
const unfollowMut = createMutation<void, Error, string, { prev: string | null }>(() => ({ | ||
mutationFn: async (uri) => { | ||
const rkey = uri.split('/').pop()! | ||
await callXrpc('dev.hatk.deleteRecord', { | ||
await deleteRecord({ | ||
collection: 'social.grain.graph.follow', | ||
rkey, | ||
}) | ||
web/src/lib/components/molecules/GalleryMenu.svelte+3−2
| @@ -8,7 +8,8 @@ | ||
*/ | ||
import type { Snippet } from 'svelte' | ||
import type { GalleryView } from '$lib/lexicons/social/grain/gallery/defs.ts' | ||
import { callXrpc } from '$hatk/client' | ||
import { call } from '$lib/xrpc' | ||
import type * as deleteGalleryMethod from '$lib/lexicons/social/grain/gallery/deleteGallery.ts' | ||
import { goto } from '$app/navigation' | ||
import { useQueryClient } from '@tanstack/svelte-query' | ||
import { Trash2, Flag, Pencil } from 'lucide-svelte' | ||
| @@ -38,7 +39,7 @@ | ||
| ||
deleting = true | ||
try { | ||
await callXrpc('social.grain.unspecced.deleteGallery', { rkey }) | ||
await call<typeof deleteGalleryMethod.main>('social.grain.gallery.deleteGallery', { rkey }) | ||
queryClient.invalidateQueries({ queryKey: ['getFeed'] }) | ||
goto(profilePath(gallery.creator)) | ||
} catch (err) { | ||
web/src/lib/components/molecules/ReportButton.svelte+8−6
| @@ -1,10 +1,12 @@ | ||
<script lang="ts"> | ||
import { callXrpc } from '$hatk/client' | ||
import type { AtUriString, CidString } from '@atproto/lex' | ||
import { call } from '$lib/xrpc' | ||
import type * as createReport from '$lib/lexicons/com/atproto/moderation/createReport.ts' | ||
import { createQuery } from '@tanstack/svelte-query' | ||
import { Flag, LoaderCircle } from 'lucide-svelte' | ||
import Modal from '../atoms/Modal.svelte' | ||
import Button from '../atoms/Button.svelte' | ||
import { labelDefsQuery } from '$lib/labels' | ||
import { labelDefsQuery, REPORT_REASONS } from '$lib/labels' | ||
| ||
let { | ||
subjectUri, | ||
| @@ -65,13 +67,13 @@ | ||
submitting = true | ||
error = null | ||
try { | ||
await callXrpc('dev.hatk.createReport', { | ||
await call<typeof createReport.main>('com.atproto.moderation.createReport', { | ||
subject: { | ||
$type: 'com.atproto.repo.strongRef', | ||
uri: subjectUri, | ||
cid: subjectCid, | ||
uri: subjectUri as AtUriString, | ||
cid: subjectCid as CidString, | ||
}, | ||
label, | ||
reasonType: REPORT_REASONS[label] ?? REPORT_REASONS.other, | ||
...(reason.trim() ? { reason: reason.trim() } : {}), | ||
}) | ||
submitted = true | ||
web/src/lib/components/molecules/StoryCreate.svelte+3−3
| @@ -1,8 +1,8 @@ | ||
<script lang="ts"> | ||
import { createRecord, uploadBlob } from '$lib/utils/records' | ||
import { storyPath } from '$lib/utils' | ||
import { X, MapPin, LoaderCircle } from 'lucide-svelte' | ||
import { useQueryClient } from '@tanstack/svelte-query' | ||
import { callXrpc } from '$hatk/client' | ||
import { processPhotos, type ProcessedPhoto } from '$lib/utils/image-resize' | ||
import { reverseGeocode, formatLocationName, extractAddress } from '$lib/utils/nominatim' | ||
import { latLonToH3 } from '$lib/utils/h3' | ||
| @@ -83,9 +83,9 @@ | ||
const binary = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)) | ||
const blob = new Blob([binary], { type: 'image/jpeg' }) | ||
| ||
const uploadResult = await callXrpc('dev.hatk.uploadBlob', blob as any) | ||
const uploadResult = await uploadBlob(blob) | ||
| ||
const result = await callXrpc('dev.hatk.createRecord', { | ||
const result = await createRecord({ | ||
collection: 'social.grain.story', | ||
record: { | ||
media: (uploadResult as any).blob, | ||
web/src/lib/components/organisms/CommentSheet.svelte+3−2
| @@ -1,4 +1,5 @@ | ||
<script lang="ts"> | ||
import { createRecord, deleteRecord } from '$lib/utils/records' | ||
import type { AtUriString } from '@atproto/lex' | ||
import type * as commentGetThread from '$lib/lexicons/social/grain/comment/getThread.ts' | ||
import { query } from '$lib/xrpc' | ||
| @@ -176,7 +177,7 @@ | ||
...(facets ? { facets } : {}), | ||
...(replyToUri ? { replyTo: replyToUri } : {}), | ||
} as never) | ||
: await callXrpc('dev.hatk.createRecord', { | ||
: await createRecord({ | ||
collection: 'social.grain.comment', | ||
record: { | ||
text, | ||
| @@ -230,7 +231,7 @@ | ||
rkey, | ||
} as never) | ||
} else { | ||
await callXrpc('dev.hatk.deleteRecord', { | ||
await deleteRecord({ | ||
collection: 'social.grain.comment', | ||
rkey, | ||
}) | ||
web/src/lib/components/organisms/StoryViewer.svelte+4−4
| @@ -1,9 +1,9 @@ | ||
<script lang="ts"> | ||
import { createRecord, deleteRecord } from '$lib/utils/records' | ||
import { profilePath } from '$lib/utils' | ||
import { createQuery, useQueryClient } from '@tanstack/svelte-query' | ||
import { X, MapPin, Trash2, AlertTriangle, Info, Heart } from 'lucide-svelte' | ||
import { goto } from '$app/navigation' | ||
import { callXrpc } from '$hatk/client' | ||
import { storiesQuery, storyAuthorsQuery, storyQuery, commentThreadQuery } from '$lib/queries' | ||
import { viewer as viewerStore } from '$lib/stores' | ||
import { firstUnviewedIndex, markStoriesViewed } from '$lib/stories' | ||
| @@ -126,7 +126,7 @@ | ||
try { | ||
const rkey = currentStory.uri.split('/').pop()! | ||
const countBeforeDelete = totalStories | ||
await callXrpc('dev.hatk.deleteRecord', { | ||
await deleteRecord({ | ||
collection: 'social.grain.story', | ||
rkey, | ||
}) | ||
| @@ -307,13 +307,13 @@ | ||
const deleteFavUri = favUri | ||
favOverrides[uri] = null | ||
const rkey = deleteFavUri.split('/').pop()! | ||
await callXrpc('dev.hatk.deleteRecord', { collection: 'social.grain.favorite', rkey }) | ||
await deleteRecord({ collection: 'social.grain.favorite', rkey }) | ||
queryClient.invalidateQueries({ queryKey: ['stories', currentDid], refetchType: 'none' }) | ||
queryClient.invalidateQueries({ queryKey: ['getStory'], refetchType: 'none' }) | ||
} else if (!isFaved) { | ||
// Favorite | ||
favOverrides[uri] = 'pending' | ||
const res: any = await callXrpc('dev.hatk.createRecord', { | ||
const res: any = await createRecord({ | ||
collection: 'social.grain.favorite', | ||
record: { subject: uri, createdAt: new Date().toISOString() }, | ||
}) | ||
web/src/lib/utils/bsky-post.ts+3−3
| @@ -1,4 +1,4 @@ | ||
import { callXrpc } from "$hatk/client"; | ||
import { createRecord, uploadBlob } from "$lib/utils/records"; | ||
import { formatStoredLocation } from "$lib/utils/formatLocation"; | ||
import { parseTextToFacets } from "$lib/utils/rich-text"; | ||
| ||
| @@ -90,7 +90,7 @@ export async function createBskyPost(options: BskyPostOptions): Promise<void> { | ||
const base64 = img.dataUrl.split(",")[1]; | ||
const binary = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); | ||
const blob = new Blob([binary], { type: "image/jpeg" }); | ||
const uploadResult = await callXrpc("dev.hatk.uploadBlob", blob as any); | ||
const uploadResult = await uploadBlob(blob); | ||
imageRefs.push({ | ||
image: (uploadResult as any).blob, | ||
alt: img.alt || "", | ||
| @@ -98,7 +98,7 @@ export async function createBskyPost(options: BskyPostOptions): Promise<void> { | ||
}); | ||
} | ||
| ||
await callXrpc("dev.hatk.createRecord", { | ||
await createRecord({ | ||
collection: "app.bsky.feed.post", | ||
record: { | ||
text: postText, | ||