Skip to content

Commit

Permalink
Allow activities endpoint to include own activities
Browse files Browse the repository at this point in the history
refs [AP-377](https://linear.app/tryghost/issue/AP-377/inbox-returning-33mb-of-data), [#40](#40)

Added a new query parameter `includeOwn` to the activities endpoint. When set
to `true`, the endpoint will also return all activites performed by the user.
  • Loading branch information
mike182uk committed Sep 18, 2024
1 parent 44a6a36 commit ba30dea
Show file tree
Hide file tree
Showing 2 changed files with 77 additions and 20 deletions.
23 changes: 23 additions & 0 deletions src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,26 @@ await client.schema.createTableIfNotExists('key_value', function (table) {
table.json('value').notNullable();
table.datetime('expires').nullable();
});

// Helper function to get the meta data for a list of activity URIs
// from the database. This allows us to fetch information about the activities
// without having to fetch the full activity object. This is a bit of a hack to
// support sorting / filtering of the activities and should be replaced when we
// have a proper db schema
export async function getActivityMeta(uris: string[]): Promise<Map<string, { id: number, type: string }>> {
const results = await client
.select('key', 'id', client.raw('JSON_EXTRACT(value, "$.type") as type'))
.from('key_value')
.whereIn('key', uris.map(uri => `["${uri}"]`));

const map = new Map<string, { id: number, type: string }>();

for (const result of results) {
map.set(result.key.substring(2, result.key.length - 2), {
id: result.id,
type: result.type,
});
}

return map;
}
74 changes: 54 additions & 20 deletions src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { Buffer } from 'node:buffer';
import { Context, Next } from 'hono';
import sanitizeHtml from 'sanitize-html';
import { v4 as uuidv4 } from 'uuid';
import { getActivityMeta } from './db';
import { addToList, removeFromList } from './kv-helpers';
import { toURL } from './toURL';
import { ContextData, HonoContextVariables, fedify } from './app';
Expand Down Expand Up @@ -378,7 +379,7 @@ export async function siteChangedWebhook(
});
}

async function buildInboxItem(
async function buildActivity(
uri: string,
db: KvStore,
apCtx: APContext<ContextData>,
Expand Down Expand Up @@ -458,7 +459,7 @@ export async function inboxHandler(

for (const item of inbox) {
try {
const builtInboxItem = await buildInboxItem(item, globaldb, apCtx, liked);
const builtInboxItem = await buildActivity(item, globaldb, apCtx, liked);

if (builtInboxItem) {
items.push(builtInboxItem);
Expand Down Expand Up @@ -499,49 +500,82 @@ export async function getActivities(
const cursor = queryCursor ? Buffer.from(queryCursor, 'base64url').toString('utf-8') : null;
const limit = Number.parseInt(ctx.req.query('limit') || DEFAULT_LIMIT.toString(), 10);

// Fetch the liked items from the database:
// Parse includeOwn from query parameters
// This is used to include the user's own activities in the results
const includeOwn = ctx.req.query('includeOwn') === 'true';

// Fetch the liked object refs from the database:
// - Data is structured as an array of strings
// - Each string is a URI to an object in the database
// This is used to add a "liked" property to the item if the user has liked it
const liked = (await db.get<string[]>(['liked'])) || [];
const likedRefs = (await db.get<string[]>(['liked'])) || [];

// Fetch the inbox from the database:
// Fetch the refs of the activities in the inbox from the database:
// - Data is structured as an array of strings
// - Each string is a URI to an object in the database
// - First item is the oldest, last item is the newest
const inboxRefs = ((await db.get<string[]>(['inbox'])) || [])

// Fetch the refs of the activities in the outbox from the database (if
// user is requesting their own activities):
// - Data is structured as an array of strings
// - Each string is a URI to an object in the database
// - First item is the oldest, last item is the newest
const inbox = ((await db.get<string[]>(['inbox'])) || [])
// Reverse so that the newest items are first
.reverse();
let outboxRefs: string[] = [];

if (includeOwn) {
outboxRefs = await db.get<string[]>(['outbox']) || [];
}

// To be able to return a sorted / filtered "feed" of activities, we need to
// fetch some additional meta data about the referenced activities. Doing this
// upfront allows us to sort, filter and paginate the activities before
// building them for the response which saves us from having to perform
// unnecessary database lookups for referenced activities that will not be
// included in the response. If we can't find the meta data in the database
// for an activity, we skip it as this is unexpected
let activityRefs = [...inboxRefs, ...outboxRefs];
const activityMeta = await getActivityMeta(activityRefs);

activityRefs = activityRefs.filter(ref => activityMeta.has(ref));

// Sort the activity refs by the id of the activity (newest first)
// We are using the id to sort because currently not all activity types have
// a timestamp. The id property is a unique auto incremented number at the
// database level
activityRefs.sort((a, b) => {
return activityMeta.get(b)!.id - activityMeta.get(a)!.id;
});

// Find the starting index based on the cursor
const startIndex = cursor ? inbox.indexOf(cursor) + 1 : 0;
const startIndex = cursor ? activityRefs.findIndex(ref => ref === cursor) + 1 : 0;

// Slice the results array based on the cursor and limit
const paginatedInbox = inbox.slice(startIndex, startIndex + limit);
const paginatedRefs = activityRefs.slice(startIndex, startIndex + limit);

// Determine the next cursor
const nextCursor = startIndex + paginatedInbox.length < inbox.length
? Buffer.from(paginatedInbox[paginatedInbox.length - 1]).toString('base64url')
const nextCursor = startIndex + paginatedRefs.length < activityRefs.length
? Buffer.from(paginatedRefs[paginatedRefs.length - 1]).toString('base64url')
: null;

// Prepare the items for the response
const items = [];
// Build the activities for the response
const activities = [];

for (const item of paginatedInbox) {
for (const ref of paginatedRefs) {
try {
const builtInboxItem = await buildInboxItem(item, globaldb, apCtx, liked);
const builtActivity = await buildActivity(ref, globaldb, apCtx, likedRefs);

if (builtInboxItem) {
items.push(builtInboxItem);
if (builtActivity) {
activities.push(builtActivity);
}
} catch (err) {
console.log(err);
}
}

// Return the paginated prepared inbox items and the next cursor
// Return the built activities and the next cursor
return new Response(JSON.stringify({
items,
items: activities,
nextCursor,
}), {
headers: {
Expand Down

0 comments on commit ba30dea

Please sign in to comment.