Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow activities endpoint to include own activities #43

Merged
merged 1 commit into from
Sep 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading