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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
47 lines · 1.7 KB Raw History
// Records that the viewer watched some stories.
//   POST /xrpc/social.grain.unspecced.markStoriesViewed { stories: [at-uri] }
//
// Viewed state lives in _story_views, private to the viewer, so what you
// watched on the web is what your phone sees as watched and the other way
// round. Marking the same story twice is a no-op, and a URI that is not a
// story the appview knows is dropped silently: the client got the URI from us,
// and the only way it is missing now is that the story was deleted in between.

import { defineProcedure, InvalidRequestError } from "$hatk";

const MAX_STORIES = 100;

export default defineProcedure("social.grain.unspecced.markStoriesViewed", async (ctx) => {
  const { ok, db, viewer } = ctx;
  if (!viewer) throw new Error("Authentication required");

  const { stories } = ctx.input;
  if (!Array.isArray(stories) || stories.length === 0) {
    throw new InvalidRequestError("stories must be a non-empty array of AT URIs");
  }
  if (stories.length > MAX_STORIES) {
    throw new InvalidRequestError(`stories may hold at most ${MAX_STORIES} URIs`);
  }
  const uris = [...new Set(stories)];
  if (uris.some((u) => typeof u !== "string" || !u.startsWith("at://"))) {
    throw new InvalidRequestError("stories must be AT URIs");
  }

  const known = (await db.query(
    `SELECT uri FROM "social.grain.story"
     WHERE uri IN (${uris.map((_, i) => `$${i + 1}`).join(",")})`,
    uris,
  )) as { uri: string }[];

  const now = new Date().toISOString();
  for (const { uri } of known) {
    await db.run(
      `INSERT INTO _story_views (did, subject, created_at)
       VALUES ($1, $2, $3)
       ON CONFLICT (did, subject) DO NOTHING`,
      [viewer.did, uri, now],
    );
  }

  return ok({});
});