~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2

web: Writes, reports and notifications go through grain-serve

Merged chadtmiller.com asked to merge into feature/rust from grain.social/grain:web/writes-client

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,
web/src/lib/utils/records.ts+64−13
web/src/lib/labels.ts+17−0
web/src/lib/mutations.ts+18−9
web/src/lib/pds.tsnew+47−0
web/src/lib/queries.ts+10−3
web/src/routes/notifications/+page.svelte+3−2
web/src/routes/profile/[actor]/+page.svelte+3−2
web/src/routes/settings/account/+page.svelte+3−2
web/src/routes/settings/profile/+page.svelte+6−7