Merge branch 'main' into admin-reports-api

Update local branch & solve conflicts in hydrate.ts
This commit is contained in:
P. Reis 2024-05-04 20:23:16 -03:00
commit d903a2d0fb
8 changed files with 149 additions and 15 deletions

View File

@ -31,6 +31,7 @@ import { blocksController } from '@/controllers/api/blocks.ts';
import { bookmarksController } from '@/controllers/api/bookmarks.ts'; import { bookmarksController } from '@/controllers/api/bookmarks.ts';
import { emptyArrayController, emptyObjectController, notImplementedController } from '@/controllers/api/fallback.ts'; import { emptyArrayController, emptyObjectController, notImplementedController } from '@/controllers/api/fallback.ts';
import { instanceController } from '@/controllers/api/instance.ts'; import { instanceController } from '@/controllers/api/instance.ts';
import { markersController, updateMarkersController } from '@/controllers/api/markers.ts';
import { mediaController } from '@/controllers/api/media.ts'; import { mediaController } from '@/controllers/api/media.ts';
import { mutesController } from '@/controllers/api/mutes.ts'; import { mutesController } from '@/controllers/api/mutes.ts';
import { notificationsController } from '@/controllers/api/notifications.ts'; import { notificationsController } from '@/controllers/api/notifications.ts';
@ -62,6 +63,7 @@ import {
zapController, zapController,
} from '@/controllers/api/statuses.ts'; } from '@/controllers/api/statuses.ts';
import { streamingController } from '@/controllers/api/streaming.ts'; import { streamingController } from '@/controllers/api/streaming.ts';
import { suggestionsV1Controller, suggestionsV2Controller } from '@/controllers/api/suggestions.ts';
import { import {
hashtagTimelineController, hashtagTimelineController,
homeTimelineController, homeTimelineController,
@ -185,12 +187,18 @@ app.get('/api/pleroma/frontend_configurations', frontendConfigController);
app.get('/api/v1/trends/tags', cache({ cacheName: 'web', expires: Time.minutes(15) }), trendingTagsController); app.get('/api/v1/trends/tags', cache({ cacheName: 'web', expires: Time.minutes(15) }), trendingTagsController);
app.get('/api/v1/trends', cache({ cacheName: 'web', expires: Time.minutes(15) }), trendingTagsController); app.get('/api/v1/trends', cache({ cacheName: 'web', expires: Time.minutes(15) }), trendingTagsController);
app.get('/api/v1/suggestions', suggestionsV1Controller);
app.get('/api/v2/suggestions', suggestionsV2Controller);
app.get('/api/v1/notifications', requirePubkey, notificationsController); app.get('/api/v1/notifications', requirePubkey, notificationsController);
app.get('/api/v1/favourites', requirePubkey, favouritesController); app.get('/api/v1/favourites', requirePubkey, favouritesController);
app.get('/api/v1/bookmarks', requirePubkey, bookmarksController); app.get('/api/v1/bookmarks', requirePubkey, bookmarksController);
app.get('/api/v1/blocks', requirePubkey, blocksController); app.get('/api/v1/blocks', requirePubkey, blocksController);
app.get('/api/v1/mutes', requirePubkey, mutesController); app.get('/api/v1/mutes', requirePubkey, mutesController);
app.get('/api/v1/markers', requireProof(), markersController);
app.post('/api/v1/markers', requireProof(), updateMarkersController);
app.get('/api/v1/admin/accounts', requireRole('admin'), adminAccountsController); app.get('/api/v1/admin/accounts', requireRole('admin'), adminAccountsController);
app.get('/api/v1/pleroma/admin/config', requireRole('admin'), configController); app.get('/api/v1/pleroma/admin/config', requireRole('admin'), configController);
app.post('/api/v1/pleroma/admin/config', requireRole('admin'), updateConfigController); app.post('/api/v1/pleroma/admin/config', requireRole('admin'), updateConfigController);
@ -205,9 +213,7 @@ app.get('/api/v1/admin/reports', requirePubkey, requireRole('admin'), viewAllRep
// Not (yet) implemented. // Not (yet) implemented.
app.get('/api/v1/custom_emojis', emptyArrayController); app.get('/api/v1/custom_emojis', emptyArrayController);
app.get('/api/v1/filters', emptyArrayController); app.get('/api/v1/filters', emptyArrayController);
app.get('/api/v1/mutes', emptyArrayController);
app.get('/api/v1/domain_blocks', emptyArrayController); app.get('/api/v1/domain_blocks', emptyArrayController);
app.get('/api/v1/markers', emptyObjectController);
app.get('/api/v1/conversations', emptyArrayController); app.get('/api/v1/conversations', emptyArrayController);
app.get('/api/v1/lists', emptyArrayController); app.get('/api/v1/lists', emptyArrayController);

View File

@ -45,6 +45,7 @@ const instanceController: AppController = async (c) => {
'mastodon_api_streaming', 'mastodon_api_streaming',
'exposable_reactions', 'exposable_reactions',
'quote_posting', 'quote_posting',
'v2_suggestions',
], ],
}, },
}, },

View File

@ -0,0 +1,64 @@
import { z } from 'zod';
import { AppController } from '@/app.ts';
import { parseBody } from '@/utils/api.ts';
const kv = await Deno.openKv();
type Timeline = 'home' | 'notifications';
interface Marker {
last_read_id: string;
version: number;
updated_at: string;
}
export const markersController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const timelines = c.req.queries('timeline[]') ?? [];
const results = await kv.getMany<Marker[]>(
timelines.map((timeline) => ['markers', pubkey, timeline]),
);
const marker = results.reduce<Record<string, Marker>>((acc, { key, value }) => {
if (value) {
const timeline = key[key.length - 1] as string;
acc[timeline] = value;
}
return acc;
}, {});
return c.json(marker);
};
const markerDataSchema = z.object({
last_read_id: z.string(),
});
export const updateMarkersController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const record = z.record(z.enum(['home', 'notifications']), markerDataSchema).parse(await parseBody(c.req.raw));
const timelines = Object.keys(record) as Timeline[];
const markers: Record<string, Marker> = {};
const entries = await kv.getMany<Marker[]>(
timelines.map((timeline) => ['markers', pubkey, timeline]),
);
for (const timeline of timelines) {
const last = entries.find(({ key }) => key[key.length - 1] === timeline);
const marker: Marker = {
last_read_id: record[timeline]!.last_read_id,
version: last?.value ? last.value.version + 1 : 1,
updated_at: new Date().toISOString(),
};
await kv.set(['markers', pubkey, timeline], marker);
markers[timeline] = marker;
}
return c.json(markers);
};

View File

@ -9,7 +9,6 @@ import { bech32ToPubkey } from '@/utils.ts';
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { UserStore } from '@/storages/UserStore.ts';
const debug = Debug('ditto:streaming'); const debug = Debug('ditto:streaming');
@ -68,17 +67,14 @@ const streamingController: AppController = (c) => {
const filter = await topicToFilter(stream, c.req.query(), pubkey); const filter = await topicToFilter(stream, c.req.query(), pubkey);
if (!filter) return; if (!filter) return;
const store = pubkey ? new UserStore(pubkey, Storages.admin) : Storages.admin;
try { try {
for await (const msg of Storages.pubsub.req([filter], { signal: controller.signal })) { for await (const msg of Storages.pubsub.req([filter], { signal: controller.signal })) {
if (msg[0] === 'EVENT') { if (msg[0] === 'EVENT') {
const [event] = await store.query([{ ids: [msg[2].id] }]); const event = msg[2];
if (!event) continue;
await hydrateEvents({ await hydrateEvents({
events: [event], events: [event],
storage: store, storage: Storages.admin,
signal: AbortSignal.timeout(1000), signal: AbortSignal.timeout(1000),
}); });

View File

@ -0,0 +1,51 @@
import { NStore } from '@nostrify/nostrify';
import { AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { getTagSet } from '@/tags.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
export const suggestionsV1Controller: AppController = async (c) => {
const store = c.get('store');
const signal = c.req.raw.signal;
const accounts = await renderSuggestedAccounts(store, signal);
return c.json(accounts);
};
export const suggestionsV2Controller: AppController = async (c) => {
const store = c.get('store');
const signal = c.req.raw.signal;
const accounts = await renderSuggestedAccounts(store, signal);
const suggestions = accounts.map((account) => ({
source: 'staff',
account,
}));
return c.json(suggestions);
};
async function renderSuggestedAccounts(store: NStore, signal?: AbortSignal) {
const [follows] = await store.query(
[{ kinds: [3], authors: [Conf.pubkey], limit: 1 }],
{ signal },
);
// TODO: pagination
const pubkeys = [...getTagSet(follows?.tags ?? [], 'p')].slice(0, 20);
const profiles = await store.query(
[{ kinds: [0], authors: pubkeys, limit: pubkeys.length }],
{ signal },
)
.then((events) => hydrateEvents({ events, storage: store, signal }));
const accounts = await Promise.all(pubkeys.map((pubkey) => {
const profile = profiles.find((event) => event.pubkey === pubkey);
return profile ? renderAccount(profile) : accountFromPubkey(pubkey);
}));
return accounts.filter(Boolean);
}

View File

@ -54,15 +54,18 @@ function deleteUnattachedMediaByUrl(url: string) {
} }
/** Get unattached media by IDs. */ /** Get unattached media by IDs. */
function getUnattachedMediaByIds(ids: string[]) { // deno-lint-ignore require-await
async function getUnattachedMediaByIds(ids: string[]) {
if (!ids.length) return [];
return selectUnattachedMediaQuery() return selectUnattachedMediaQuery()
.where('id', 'in', ids) .where('id', 'in', ids)
.execute(); .execute();
} }
/** Delete rows as an event with media is being created. */ /** Delete rows as an event with media is being created. */
function deleteAttachedMedia(pubkey: string, urls: string[]) { async function deleteAttachedMedia(pubkey: string, urls: string[]): Promise<void> {
return db.deleteFrom('unattached_media') if (!urls.length) return;
await db.deleteFrom('unattached_media')
.where('pubkey', '=', pubkey) .where('pubkey', '=', pubkey)
.where('url', 'in', urls) .where('url', 'in', urls)
.execute(); .execute();

View File

@ -91,6 +91,13 @@ function assembleEvents(
} }
} }
if (event.kind === 7) {
const id = event.tags.find(([name]) => name === 'e')?.[1];
if (id) {
event.reacted = b.find((e) => matchFilter({ kinds: [1], ids: [id] }, e));
}
}
if (event.kind === 1984) { if (event.kind === 1984) {
const targetAccountId = event.tags.find(([name]) => name === 'p')?.[1]; const targetAccountId = event.tags.find(([name]) => name === 'p')?.[1];
if (targetAccountId) { if (targetAccountId) {

View File

@ -2,6 +2,7 @@ import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { nostrDate } from '@/utils.ts'; import { nostrDate } from '@/utils.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts';
import { NostrEvent } from '@nostrify/nostrify';
interface RenderNotificationOpts { interface RenderNotificationOpts {
viewerPubkey: string; viewerPubkey: string;
@ -32,7 +33,7 @@ async function renderMention(event: DittoEvent, opts: RenderNotificationOpts) {
if (!status) return; if (!status) return;
return { return {
id: event.id, id: notificationId(event),
type: 'mention', type: 'mention',
created_at: nostrDate(event.created_at).toISOString(), created_at: nostrDate(event.created_at).toISOString(),
account: status.account, account: status.account,
@ -47,7 +48,7 @@ async function renderReblog(event: DittoEvent, opts: RenderNotificationOpts) {
const account = event.author ? await renderAccount(event.author) : accountFromPubkey(event.pubkey); const account = event.author ? await renderAccount(event.author) : accountFromPubkey(event.pubkey);
return { return {
id: event.id, id: notificationId(event),
type: 'reblog', type: 'reblog',
created_at: nostrDate(event.created_at).toISOString(), created_at: nostrDate(event.created_at).toISOString(),
account, account,
@ -62,7 +63,7 @@ async function renderFavourite(event: DittoEvent, opts: RenderNotificationOpts)
const account = event.author ? await renderAccount(event.author) : accountFromPubkey(event.pubkey); const account = event.author ? await renderAccount(event.author) : accountFromPubkey(event.pubkey);
return { return {
id: event.id, id: notificationId(event),
type: 'favourite', type: 'favourite',
created_at: nostrDate(event.created_at).toISOString(), created_at: nostrDate(event.created_at).toISOString(),
account, account,
@ -77,7 +78,7 @@ async function renderReaction(event: DittoEvent, opts: RenderNotificationOpts) {
const account = event.author ? await renderAccount(event.author) : accountFromPubkey(event.pubkey); const account = event.author ? await renderAccount(event.author) : accountFromPubkey(event.pubkey);
return { return {
id: event.id, id: notificationId(event),
type: 'pleroma:emoji_reaction', type: 'pleroma:emoji_reaction',
emoji: event.content, emoji: event.content,
created_at: nostrDate(event.created_at).toISOString(), created_at: nostrDate(event.created_at).toISOString(),
@ -86,4 +87,9 @@ async function renderReaction(event: DittoEvent, opts: RenderNotificationOpts) {
}; };
} }
/** This helps notifications be sorted in the correct order. */
function notificationId({ id, created_at }: NostrEvent): string {
return `${created_at}-${id}`;
}
export { renderNotification }; export { renderNotification };