Merge branch 'stats' into 'main'
Add a stats module See merge request soapbox-pub/ditto!73
This commit is contained in:
commit
e91fbf7b50
|
@ -1,4 +1,4 @@
|
||||||
image: denoland/deno:1.38.4
|
image: denoland/deno:1.38.5
|
||||||
|
|
||||||
default:
|
default:
|
||||||
interruptible: true
|
interruptible: true
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
deno 1.38.4
|
deno 1.38.5
|
|
@ -18,6 +18,6 @@ async function sync([url]: string[]) {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const values = filteredArray(relaySchema).parse(data) as `wss://${string}`[];
|
const values = filteredArray(relaySchema).parse(data) as `wss://${string}`[];
|
||||||
await addRelays(values);
|
await addRelays(values, { active: true });
|
||||||
console.log(`Done: added ${values.length} relays.`);
|
console.log(`Done: added ${values.length} relays.`);
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,7 +59,7 @@ const createAccountController: AppController = async (c) => {
|
||||||
const verifyCredentialsController: AppController = async (c) => {
|
const verifyCredentialsController: AppController = async (c) => {
|
||||||
const pubkey = c.get('pubkey')!;
|
const pubkey = c.get('pubkey')!;
|
||||||
|
|
||||||
const event = await getAuthor(pubkey);
|
const event = await getAuthor(pubkey, { relations: ['author_stats'] });
|
||||||
if (event) {
|
if (event) {
|
||||||
return c.json(await renderAccount(event, { withSource: true }));
|
return c.json(await renderAccount(event, { withSource: true }));
|
||||||
} else {
|
} else {
|
||||||
|
@ -138,7 +138,15 @@ const accountStatusesController: AppController = async (c) => {
|
||||||
return c.json([]);
|
return c.json([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const filter: DittoFilter<1> = { authors: [pubkey], kinds: [1], relations: ['author'], since, until, limit };
|
const filter: DittoFilter<1> = {
|
||||||
|
authors: [pubkey],
|
||||||
|
kinds: [1],
|
||||||
|
relations: ['author', 'event_stats', 'author_stats'],
|
||||||
|
since,
|
||||||
|
until,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
|
||||||
if (tagged) {
|
if (tagged) {
|
||||||
filter['#t'] = [tagged];
|
filter['#t'] = [tagged];
|
||||||
}
|
}
|
||||||
|
@ -257,7 +265,9 @@ const favouritesController: AppController = async (c) => {
|
||||||
.map((event) => event.tags.find((tag) => tag[0] === 'e')?.[1])
|
.map((event) => event.tags.find((tag) => tag[0] === 'e')?.[1])
|
||||||
.filter((id): id is string => !!id);
|
.filter((id): id is string => !!id);
|
||||||
|
|
||||||
const events1 = await mixer.getFilters([{ kinds: [1], ids, relations: ['author'] }], { timeout: Time.seconds(1) });
|
const events1 = await mixer.getFilters([{ kinds: [1], ids, relations: ['author', 'event_stats', 'author_stats'] }], {
|
||||||
|
timeout: Time.seconds(1),
|
||||||
|
});
|
||||||
|
|
||||||
const statuses = await Promise.all(events1.map((event) => renderStatus(event, c.get('pubkey'))));
|
const statuses = await Promise.all(events1.map((event) => renderStatus(event, c.get('pubkey'))));
|
||||||
return paginated(c, events1, statuses);
|
return paginated(c, events1, statuses);
|
||||||
|
|
|
@ -69,7 +69,7 @@ function searchEvents({ q, type, limit, account_id }: SearchQuery): Promise<Even
|
||||||
const filter: DittoFilter = {
|
const filter: DittoFilter = {
|
||||||
kinds: typeToKinds(type),
|
kinds: typeToKinds(type),
|
||||||
search: q,
|
search: q,
|
||||||
relations: ['author'],
|
relations: ['author', 'event_stats', 'author_stats'],
|
||||||
limit,
|
limit,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -115,16 +115,20 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery): Promise<Ditt
|
||||||
const result = nip19.decode(q);
|
const result = nip19.decode(q);
|
||||||
switch (result.type) {
|
switch (result.type) {
|
||||||
case 'npub':
|
case 'npub':
|
||||||
if (accounts) filters.push({ kinds: [0], authors: [result.data] });
|
if (accounts) filters.push({ kinds: [0], authors: [result.data], relations: ['author_stats'] });
|
||||||
break;
|
break;
|
||||||
case 'nprofile':
|
case 'nprofile':
|
||||||
if (accounts) filters.push({ kinds: [0], authors: [result.data.pubkey] });
|
if (accounts) filters.push({ kinds: [0], authors: [result.data.pubkey], relations: ['author_stats'] });
|
||||||
break;
|
break;
|
||||||
case 'note':
|
case 'note':
|
||||||
if (statuses) filters.push({ kinds: [1], ids: [result.data] });
|
if (statuses) {
|
||||||
|
filters.push({ kinds: [1], ids: [result.data], relations: ['author', 'event_stats', 'author_stats'] });
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'nevent':
|
case 'nevent':
|
||||||
if (statuses) filters.push({ kinds: [1], ids: [result.data.id] });
|
if (statuses) {
|
||||||
|
filters.push({ kinds: [1], ids: [result.data.id], relations: ['author', 'event_stats', 'author_stats'] });
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
|
@ -136,11 +140,11 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery): Promise<Ditt
|
||||||
} else if (accounts && ACCT_REGEX.test(q)) {
|
} else if (accounts && ACCT_REGEX.test(q)) {
|
||||||
const pubkey = await lookupNip05Cached(q);
|
const pubkey = await lookupNip05Cached(q);
|
||||||
if (pubkey) {
|
if (pubkey) {
|
||||||
filters.push({ kinds: [0], authors: [pubkey] });
|
filters.push({ kinds: [0], authors: [pubkey], relations: ['author_stats'] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return filters.map((filter) => ({ ...filter, relations: ['author'] }));
|
return filters;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { searchController };
|
export { searchController };
|
||||||
|
|
|
@ -29,7 +29,7 @@ const createStatusSchema = z.object({
|
||||||
const statusController: AppController = async (c) => {
|
const statusController: AppController = async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
|
|
||||||
const event = await getEvent(id, { kind: 1, relations: ['author'] });
|
const event = await getEvent(id, { kind: 1, relations: ['author', 'event_stats', 'author_stats'] });
|
||||||
if (event) {
|
if (event) {
|
||||||
return c.json(await renderStatus(event, c.get('pubkey')));
|
return c.json(await renderStatus(event, c.get('pubkey')));
|
||||||
}
|
}
|
||||||
|
@ -89,7 +89,7 @@ const createStatusController: AppController = async (c) => {
|
||||||
|
|
||||||
const contextController: AppController = async (c) => {
|
const contextController: AppController = async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
const event = await getEvent(id, { kind: 1, relations: ['author'] });
|
const event = await getEvent(id, { kind: 1, relations: ['author', 'event_stats', 'author_stats'] });
|
||||||
|
|
||||||
async function renderStatuses(events: Event<1>[]) {
|
async function renderStatuses(events: Event<1>[]) {
|
||||||
const statuses = await Promise.all(events.map((event) => renderStatus(event, c.get('pubkey'))));
|
const statuses = await Promise.all(events.map((event) => renderStatus(event, c.get('pubkey'))));
|
||||||
|
@ -110,7 +110,7 @@ const contextController: AppController = async (c) => {
|
||||||
|
|
||||||
const favouriteController: AppController = async (c) => {
|
const favouriteController: AppController = async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
const target = await getEvent(id, { kind: 1, relations: ['author'] });
|
const target = await getEvent(id, { kind: 1, relations: ['author', 'event_stats', 'author_stats'] });
|
||||||
|
|
||||||
if (target) {
|
if (target) {
|
||||||
await createEvent({
|
await createEvent({
|
||||||
|
|
|
@ -35,7 +35,7 @@ const hashtagTimelineController: AppController = (c) => {
|
||||||
/** Render statuses for timelines. */
|
/** Render statuses for timelines. */
|
||||||
async function renderStatuses(c: AppContext, filters: DittoFilter<1>[]) {
|
async function renderStatuses(c: AppContext, filters: DittoFilter<1>[]) {
|
||||||
const events = await mixer.getFilters(
|
const events = await mixer.getFilters(
|
||||||
filters.map((filter) => ({ ...filter, relations: ['author'] })),
|
filters.map((filter) => ({ ...filter, relations: ['author', 'event_stats', 'author_stats'] })),
|
||||||
{ timeout: Time.seconds(1) },
|
{ timeout: Time.seconds(1) },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
20
src/db.ts
20
src/db.ts
|
@ -13,6 +13,22 @@ interface DittoDB {
|
||||||
users: UserRow;
|
users: UserRow;
|
||||||
relays: RelayRow;
|
relays: RelayRow;
|
||||||
unattached_media: UnattachedMediaRow;
|
unattached_media: UnattachedMediaRow;
|
||||||
|
author_stats: AuthorStatsRow;
|
||||||
|
event_stats: EventStatsRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthorStatsRow {
|
||||||
|
pubkey: string;
|
||||||
|
followers_count: number;
|
||||||
|
following_count: number;
|
||||||
|
notes_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventStatsRow {
|
||||||
|
event_id: string;
|
||||||
|
replies_count: number;
|
||||||
|
reposts_count: number;
|
||||||
|
reactions_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EventRow {
|
interface EventRow {
|
||||||
|
@ -101,7 +117,7 @@ async function migrate() {
|
||||||
console.log('Everything up-to-date.');
|
console.log('Everything up-to-date.');
|
||||||
} else {
|
} else {
|
||||||
console.log('Migrations finished!');
|
console.log('Migrations finished!');
|
||||||
for (const { migrationName, status } of results.results) {
|
for (const { migrationName, status } of results.results!) {
|
||||||
console.log(` - ${migrationName}: ${status}`);
|
console.log(` - ${migrationName}: ${status}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -110,4 +126,4 @@ async function migrate() {
|
||||||
|
|
||||||
await migrate();
|
await migrate();
|
||||||
|
|
||||||
export { db, type DittoDB, type EventRow, type TagRow, type UserRow };
|
export { type AuthorStatsRow, db, type DittoDB, type EventRow, type EventStatsRow, type TagRow, type UserRow };
|
||||||
|
|
|
@ -80,6 +80,9 @@ type EventQuery = SelectQueryBuilder<DittoDB, 'events', {
|
||||||
content: string;
|
content: string;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
sig: string;
|
sig: string;
|
||||||
|
stats_replies_count?: number;
|
||||||
|
stats_reposts_count?: number;
|
||||||
|
stats_reactions_count?: number;
|
||||||
author_id?: string;
|
author_id?: string;
|
||||||
author_tags?: string;
|
author_tags?: string;
|
||||||
author_kind?: number;
|
author_kind?: number;
|
||||||
|
@ -87,6 +90,9 @@ type EventQuery = SelectQueryBuilder<DittoDB, 'events', {
|
||||||
author_content?: string;
|
author_content?: string;
|
||||||
author_created_at?: number;
|
author_created_at?: number;
|
||||||
author_sig?: string;
|
author_sig?: string;
|
||||||
|
author_stats_followers_count?: number;
|
||||||
|
author_stats_following_count?: number;
|
||||||
|
author_stats_notes_count?: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
/** Build the query for a filter. */
|
/** Build the query for a filter. */
|
||||||
|
@ -134,7 +140,7 @@ function getFilterQuery(filter: DittoFilter): EventQuery {
|
||||||
query = query
|
query = query
|
||||||
.leftJoin('tags', 'tags.event_id', 'events.id')
|
.leftJoin('tags', 'tags.event_id', 'events.id')
|
||||||
.where('tags.tag', '=', tag)
|
.where('tags.tag', '=', tag)
|
||||||
.where('tags.value', 'in', value) as typeof query;
|
.where('tags.value', 'in', value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -165,7 +171,27 @@ function getFilterQuery(filter: DittoFilter): EventQuery {
|
||||||
'authors.tags as author_tags',
|
'authors.tags as author_tags',
|
||||||
'authors.created_at as author_created_at',
|
'authors.created_at as author_created_at',
|
||||||
'authors.sig as author_sig',
|
'authors.sig as author_sig',
|
||||||
]) as typeof query;
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.relations?.includes('author_stats')) {
|
||||||
|
query = query
|
||||||
|
.leftJoin('author_stats', 'author_stats.pubkey', 'events.pubkey')
|
||||||
|
.select((eb) => [
|
||||||
|
eb.fn.coalesce('author_stats.followers_count', eb.val(0)).as('author_stats_followers_count'),
|
||||||
|
eb.fn.coalesce('author_stats.following_count', eb.val(0)).as('author_stats_following_count'),
|
||||||
|
eb.fn.coalesce('author_stats.notes_count', eb.val(0)).as('author_stats_notes_count'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.relations?.includes('event_stats')) {
|
||||||
|
query = query
|
||||||
|
.leftJoin('event_stats', 'event_stats.event_id', 'events.id')
|
||||||
|
.select((eb) => [
|
||||||
|
eb.fn.coalesce('event_stats.replies_count', eb.val(0)).as('stats_replies_count'),
|
||||||
|
eb.fn.coalesce('event_stats.reposts_count', eb.val(0)).as('stats_reposts_count'),
|
||||||
|
eb.fn.coalesce('event_stats.reactions_count', eb.val(0)).as('stats_reactions_count'),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.search) {
|
if (filter.search) {
|
||||||
|
@ -184,8 +210,13 @@ function getFiltersQuery(filters: DittoFilter[]) {
|
||||||
.reduce((result, query) => result.unionAll(query));
|
.reduce((result, query) => result.unionAll(query));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AuthorStats = Omit<DittoDB['author_stats'], 'pubkey'>;
|
||||||
|
type EventStats = Omit<DittoDB['event_stats'], 'event_id'>;
|
||||||
|
|
||||||
interface DittoEvent<K extends number = number> extends Event<K> {
|
interface DittoEvent<K extends number = number> extends Event<K> {
|
||||||
author?: Event<0>;
|
author?: DittoEvent<0>;
|
||||||
|
author_stats?: AuthorStats;
|
||||||
|
event_stats?: EventStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get events for filters from the database. */
|
/** Get events for filters from the database. */
|
||||||
|
@ -223,6 +254,22 @@ async function getFilters<K extends number>(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof row.author_stats_followers_count === 'number') {
|
||||||
|
event.author_stats = {
|
||||||
|
followers_count: row.author_stats_followers_count,
|
||||||
|
following_count: row.author_stats_following_count!,
|
||||||
|
notes_count: row.author_stats_notes_count!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof row.stats_replies_count === 'number') {
|
||||||
|
event.event_stats = {
|
||||||
|
replies_count: row.stats_replies_count,
|
||||||
|
reposts_count: row.stats_reposts_count!,
|
||||||
|
reactions_count: row.stats_reactions_count!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return event;
|
return event;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { Kysely } from '@/deps.ts';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.createTable('author_stats')
|
||||||
|
.addColumn('pubkey', 'text', (col) => col.primaryKey())
|
||||||
|
.addColumn('followers_count', 'integer', (col) => col.notNull().defaultTo(0))
|
||||||
|
.addColumn('following_count', 'integer', (col) => col.notNull().defaultTo(0))
|
||||||
|
.addColumn('notes_count', 'integer', (col) => col.notNull().defaultTo(0))
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createTable('event_stats')
|
||||||
|
.addColumn('event_id', 'text', (col) => col.primaryKey().references('events.id').onDelete('cascade'))
|
||||||
|
.addColumn('replies_count', 'integer', (col) => col.notNull().defaultTo(0))
|
||||||
|
.addColumn('reposts_count', 'integer', (col) => col.notNull().defaultTo(0))
|
||||||
|
.addColumn('reactions_count', 'integer', (col) => col.notNull().defaultTo(0))
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema.dropTable('author_stats').execute();
|
||||||
|
await db.schema.dropTable('event_stats').execute();
|
||||||
|
}
|
|
@ -1,14 +1,19 @@
|
||||||
import { tldts } from '@/deps.ts';
|
import { tldts } from '@/deps.ts';
|
||||||
import { db } from '@/db.ts';
|
import { db } from '@/db.ts';
|
||||||
|
|
||||||
|
interface AddRelaysOpts {
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/** Inserts relays into the database, skipping duplicates. */
|
/** Inserts relays into the database, skipping duplicates. */
|
||||||
function addRelays(relays: `wss://${string}`[]) {
|
function addRelays(relays: `wss://${string}`[], opts: AddRelaysOpts = {}) {
|
||||||
if (!relays.length) return Promise.resolve();
|
if (!relays.length) return Promise.resolve();
|
||||||
|
const { active = false } = opts;
|
||||||
|
|
||||||
const values = relays.map((url) => ({
|
const values = relays.map((url) => ({
|
||||||
url: new URL(url).toString(),
|
url: new URL(url).toString(),
|
||||||
domain: tldts.getDomain(url)!,
|
domain: tldts.getDomain(url)!,
|
||||||
active: true,
|
active,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return db.insertInto('relays')
|
return db.insertInto('relays')
|
||||||
|
|
|
@ -63,6 +63,7 @@ export {
|
||||||
type CompiledQuery,
|
type CompiledQuery,
|
||||||
FileMigrationProvider,
|
FileMigrationProvider,
|
||||||
type Insertable,
|
type Insertable,
|
||||||
|
type InsertQueryBuilder,
|
||||||
Kysely,
|
Kysely,
|
||||||
Migrator,
|
Migrator,
|
||||||
type NullableInsertKeys,
|
type NullableInsertKeys,
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { type Event, type Filter, matchFilters } from '@/deps.ts';
|
||||||
import type { EventData } from '@/types.ts';
|
import type { EventData } from '@/types.ts';
|
||||||
|
|
||||||
/** Additional properties that may be added by Ditto to events. */
|
/** Additional properties that may be added by Ditto to events. */
|
||||||
type Relation = 'author';
|
type Relation = 'author' | 'author_stats' | 'event_stats';
|
||||||
|
|
||||||
/** Custom filter interface that extends Nostr filters with extra options for Ditto. */
|
/** Custom filter interface that extends Nostr filters with extra options for Ditto. */
|
||||||
interface DittoFilter<K extends number = number> extends Filter<K> {
|
interface DittoFilter<K extends number = number> extends Filter<K> {
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { isEphemeralKind } from '@/kinds.ts';
|
||||||
import * as mixer from '@/mixer.ts';
|
import * as mixer from '@/mixer.ts';
|
||||||
import { publish } from '@/pool.ts';
|
import { publish } from '@/pool.ts';
|
||||||
import { isLocallyFollowed } from '@/queries.ts';
|
import { isLocallyFollowed } from '@/queries.ts';
|
||||||
|
import { updateStats } from '@/stats.ts';
|
||||||
import { Sub } from '@/subs.ts';
|
import { Sub } from '@/subs.ts';
|
||||||
import { getTagSet } from '@/tags.ts';
|
import { getTagSet } from '@/tags.ts';
|
||||||
import { eventAge, isRelay, nostrDate, Time } from '@/utils.ts';
|
import { eventAge, isRelay, nostrDate, Time } from '@/utils.ts';
|
||||||
|
@ -68,7 +69,10 @@ async function storeEvent(event: Event, data: EventData): Promise<void> {
|
||||||
if (deletion) {
|
if (deletion) {
|
||||||
return Promise.reject(new RelayError('blocked', 'event was deleted'));
|
return Promise.reject(new RelayError('blocked', 'event was deleted'));
|
||||||
} else {
|
} else {
|
||||||
await eventsDB.insertEvent(event, data).catch(console.warn);
|
await Promise.all([
|
||||||
|
eventsDB.insertEvent(event, data).catch(console.warn),
|
||||||
|
updateStats(event).catch(console.warn),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return Promise.reject(new RelayError('blocked', 'only registered users can post'));
|
return Promise.reject(new RelayError('blocked', 'only registered users can post'));
|
||||||
|
|
|
@ -27,8 +27,14 @@ const getEvent = async <K extends number = number>(
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get a Nostr `set_medatadata` event for a user's pubkey. */
|
/** Get a Nostr `set_medatadata` event for a user's pubkey. */
|
||||||
const getAuthor = async (pubkey: string, timeout = 1000): Promise<Event<0> | undefined> => {
|
const getAuthor = async (pubkey: string, opts: GetEventOpts<0> = {}): Promise<Event<0> | undefined> => {
|
||||||
const [event] = await mixer.getFilters([{ authors: [pubkey], kinds: [0], limit: 1 }], { limit: 1, timeout });
|
const { relations, timeout = 1000 } = opts;
|
||||||
|
|
||||||
|
const [event] = await mixer.getFilters(
|
||||||
|
[{ authors: [pubkey], relations, kinds: [0], limit: 1 }],
|
||||||
|
{ limit: 1, timeout },
|
||||||
|
);
|
||||||
|
|
||||||
return event;
|
return event;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -60,7 +66,7 @@ async function getAncestors(event: Event<1>, result = [] as Event<1>[]): Promise
|
||||||
const inReplyTo = replyTag ? replyTag[1] : undefined;
|
const inReplyTo = replyTag ? replyTag[1] : undefined;
|
||||||
|
|
||||||
if (inReplyTo) {
|
if (inReplyTo) {
|
||||||
const parentEvent = await getEvent(inReplyTo, { kind: 1, relations: ['author'] });
|
const parentEvent = await getEvent(inReplyTo, { kind: 1, relations: ['author', 'event_stats', 'author_stats'] });
|
||||||
|
|
||||||
if (parentEvent) {
|
if (parentEvent) {
|
||||||
result.push(parentEvent);
|
result.push(parentEvent);
|
||||||
|
@ -73,7 +79,10 @@ async function getAncestors(event: Event<1>, result = [] as Event<1>[]): Promise
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDescendants(eventId: string): Promise<Event<1>[]> {
|
function getDescendants(eventId: string): Promise<Event<1>[]> {
|
||||||
return mixer.getFilters([{ kinds: [1], '#e': [eventId], relations: ['author'] }], { limit: 200, timeout: 2000 });
|
return mixer.getFilters(
|
||||||
|
[{ kinds: [1], '#e': [eventId], relations: ['author', 'event_stats', 'author_stats'] }],
|
||||||
|
{ limit: 200, timeout: 2000 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns whether the pubkey is followed by a local user. */
|
/** Returns whether the pubkey is followed by a local user. */
|
||||||
|
|
|
@ -6,6 +6,6 @@ if (Conf.sentryDsn) {
|
||||||
console.log('Sentry enabled');
|
console.log('Sentry enabled');
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: Conf.sentryDsn,
|
dsn: Conf.sentryDsn,
|
||||||
tracesSampleRate: 1.0,
|
tracesSampleRate: .2,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,176 @@
|
||||||
|
import { type AuthorStatsRow, db, type DittoDB, type EventStatsRow } from '@/db.ts';
|
||||||
|
import * as eventsDB from '@/db/events.ts';
|
||||||
|
import { type Event, findReplyTag, type InsertQueryBuilder } from '@/deps.ts';
|
||||||
|
|
||||||
|
type AuthorStat = keyof Omit<AuthorStatsRow, 'pubkey'>;
|
||||||
|
type EventStat = keyof Omit<EventStatsRow, 'event_id'>;
|
||||||
|
|
||||||
|
type AuthorStatDiff = ['author_stats', pubkey: string, stat: AuthorStat, diff: number];
|
||||||
|
type EventStatDiff = ['event_stats', eventId: string, stat: EventStat, diff: number];
|
||||||
|
type StatDiff = AuthorStatDiff | EventStatDiff;
|
||||||
|
|
||||||
|
/** Store stats for the event in LMDB. */
|
||||||
|
async function updateStats<K extends number>(event: Event<K>) {
|
||||||
|
let prev: Event<K> | undefined;
|
||||||
|
const queries: InsertQueryBuilder<DittoDB, any, unknown>[] = [];
|
||||||
|
|
||||||
|
// Kind 3 is a special case - replace the count with the new list.
|
||||||
|
if (event.kind === 3) {
|
||||||
|
prev = await maybeGetPrev(event);
|
||||||
|
if (!prev || event.created_at >= prev.created_at) {
|
||||||
|
queries.push(updateFollowingCountQuery(event as Event<3>));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statDiffs = getStatsDiff(event, prev);
|
||||||
|
const pubkeyDiffs = statDiffs.filter(([table]) => table === 'author_stats') as AuthorStatDiff[];
|
||||||
|
const eventDiffs = statDiffs.filter(([table]) => table === 'event_stats') as EventStatDiff[];
|
||||||
|
|
||||||
|
if (pubkeyDiffs.length) queries.push(authorStatsQuery(pubkeyDiffs));
|
||||||
|
if (eventDiffs.length) queries.push(eventStatsQuery(eventDiffs));
|
||||||
|
|
||||||
|
if (queries.length) {
|
||||||
|
await Promise.all(queries.map((query) => query.execute()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calculate stats changes ahead of time so we can build an efficient query. */
|
||||||
|
function getStatsDiff<K extends number>(event: Event<K>, prev: Event<K> | undefined): StatDiff[] {
|
||||||
|
const statDiffs: StatDiff[] = [];
|
||||||
|
|
||||||
|
const firstTaggedId = event.tags.find(([name]) => name === 'e')?.[1];
|
||||||
|
const inReplyToId = findReplyTag(event as Event<1>)?.[1];
|
||||||
|
|
||||||
|
switch (event.kind) {
|
||||||
|
case 1:
|
||||||
|
statDiffs.push(['author_stats', event.pubkey, 'notes_count', 1]);
|
||||||
|
if (inReplyToId) {
|
||||||
|
statDiffs.push(['event_stats', inReplyToId, 'replies_count', 1]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
statDiffs.push(...getFollowDiff(event as Event<3>, prev as Event<3> | undefined));
|
||||||
|
break;
|
||||||
|
case 6:
|
||||||
|
if (firstTaggedId) {
|
||||||
|
statDiffs.push(['event_stats', firstTaggedId, 'reposts_count', 1]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 7:
|
||||||
|
if (firstTaggedId) {
|
||||||
|
statDiffs.push(['event_stats', firstTaggedId, 'reactions_count', 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return statDiffs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create an author stats query from the list of diffs. */
|
||||||
|
function authorStatsQuery(diffs: AuthorStatDiff[]) {
|
||||||
|
const values: AuthorStatsRow[] = diffs.map(([_, pubkey, stat, diff]) => {
|
||||||
|
const row: AuthorStatsRow = {
|
||||||
|
pubkey,
|
||||||
|
followers_count: 0,
|
||||||
|
following_count: 0,
|
||||||
|
notes_count: 0,
|
||||||
|
};
|
||||||
|
row[stat] = diff;
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
return db.insertInto('author_stats')
|
||||||
|
.values(values)
|
||||||
|
.onConflict((oc) =>
|
||||||
|
oc
|
||||||
|
.column('pubkey')
|
||||||
|
.doUpdateSet((eb) => ({
|
||||||
|
followers_count: eb('followers_count', '+', eb.ref('excluded.followers_count')),
|
||||||
|
following_count: eb('following_count', '+', eb.ref('excluded.following_count')),
|
||||||
|
notes_count: eb('notes_count', '+', eb.ref('excluded.notes_count')),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create an event stats query from the list of diffs. */
|
||||||
|
function eventStatsQuery(diffs: EventStatDiff[]) {
|
||||||
|
const values: EventStatsRow[] = diffs.map(([_, event_id, stat, diff]) => {
|
||||||
|
const row: EventStatsRow = {
|
||||||
|
event_id,
|
||||||
|
replies_count: 0,
|
||||||
|
reposts_count: 0,
|
||||||
|
reactions_count: 0,
|
||||||
|
};
|
||||||
|
row[stat] = diff;
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
return db.insertInto('event_stats')
|
||||||
|
.values(values)
|
||||||
|
.onConflict((oc) =>
|
||||||
|
oc
|
||||||
|
.column('event_id')
|
||||||
|
.doUpdateSet((eb) => ({
|
||||||
|
replies_count: eb('replies_count', '+', eb.ref('excluded.replies_count')),
|
||||||
|
reposts_count: eb('reposts_count', '+', eb.ref('excluded.reposts_count')),
|
||||||
|
reactions_count: eb('reactions_count', '+', eb.ref('excluded.reactions_count')),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the last version of the event, if any. */
|
||||||
|
async function maybeGetPrev<K extends number>(event: Event<K>): Promise<Event<K>> {
|
||||||
|
const [prev] = await eventsDB.getFilters([
|
||||||
|
{ kinds: [event.kind], authors: [event.pubkey], limit: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set the following count to the total number of unique "p" tags in the follow list. */
|
||||||
|
function updateFollowingCountQuery({ pubkey, tags }: Event<3>) {
|
||||||
|
const following_count = new Set(
|
||||||
|
tags
|
||||||
|
.filter(([name]) => name === 'p')
|
||||||
|
.map(([_, value]) => value),
|
||||||
|
).size;
|
||||||
|
|
||||||
|
return db.insertInto('author_stats')
|
||||||
|
.values({
|
||||||
|
pubkey,
|
||||||
|
following_count,
|
||||||
|
followers_count: 0,
|
||||||
|
notes_count: 0,
|
||||||
|
})
|
||||||
|
.onConflict((oc) =>
|
||||||
|
oc
|
||||||
|
.column('pubkey')
|
||||||
|
.doUpdateSet({ following_count })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compare the old and new follow events (if any), and return a diff array. */
|
||||||
|
function getFollowDiff(event: Event<3>, prev?: Event<3>): AuthorStatDiff[] {
|
||||||
|
const prevTags = prev?.tags ?? [];
|
||||||
|
|
||||||
|
const prevPubkeys = new Set(
|
||||||
|
prevTags
|
||||||
|
.filter(([name]) => name === 'p')
|
||||||
|
.map(([_, value]) => value),
|
||||||
|
);
|
||||||
|
|
||||||
|
const pubkeys = new Set(
|
||||||
|
event.tags
|
||||||
|
.filter(([name]) => name === 'p')
|
||||||
|
.map(([_, value]) => value),
|
||||||
|
);
|
||||||
|
|
||||||
|
const added = [...pubkeys].filter((pubkey) => !prevPubkeys.has(pubkey));
|
||||||
|
const removed = [...prevPubkeys].filter((pubkey) => !pubkeys.has(pubkey));
|
||||||
|
|
||||||
|
return [
|
||||||
|
...added.map((pubkey): AuthorStatDiff => ['author_stats', pubkey, 'followers_count', 1]),
|
||||||
|
...removed.map((pubkey): AuthorStatDiff => ['author_stats', pubkey, 'followers_count', -1]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export { updateStats };
|
|
@ -15,7 +15,7 @@ async function renderEventAccounts(c: AppContext, filters: Filter[]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const accounts = await Promise.all([...pubkeys].map(async (pubkey) => {
|
const accounts = await Promise.all([...pubkeys].map(async (pubkey) => {
|
||||||
const author = await getAuthor(pubkey);
|
const author = await getAuthor(pubkey, { relations: ['author_stats'] });
|
||||||
if (author) {
|
if (author) {
|
||||||
return renderAccount(author);
|
return renderAccount(author);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
import * as eventsDB from '@/db/events.ts';
|
import { type DittoEvent } from '@/db/events.ts';
|
||||||
import { findUser } from '@/db/users.ts';
|
import { findUser } from '@/db/users.ts';
|
||||||
import { lodash, nip19, type UnsignedEvent } from '@/deps.ts';
|
import { lodash, nip19, type UnsignedEvent } from '@/deps.ts';
|
||||||
import { getFollowedPubkeys } from '@/queries.ts';
|
|
||||||
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
|
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
|
||||||
import { verifyNip05Cached } from '@/utils/nip05.ts';
|
import { verifyNip05Cached } from '@/utils/nip05.ts';
|
||||||
import { Nip05, nostrDate, nostrNow, parseNip05 } from '@/utils.ts';
|
import { Nip05, nostrDate, nostrNow, parseNip05 } from '@/utils.ts';
|
||||||
|
@ -12,7 +11,10 @@ interface ToAccountOpts {
|
||||||
withSource?: boolean;
|
withSource?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderAccount(event: UnsignedEvent<0>, opts: ToAccountOpts = {}) {
|
async function renderAccount(
|
||||||
|
event: Omit<NonNullable<DittoEvent['author']>, 'id' | 'sig'>,
|
||||||
|
opts: ToAccountOpts = {},
|
||||||
|
) {
|
||||||
const { withSource = false } = opts;
|
const { withSource = false } = opts;
|
||||||
const { pubkey } = event;
|
const { pubkey } = event;
|
||||||
|
|
||||||
|
@ -26,12 +28,9 @@ async function renderAccount(event: UnsignedEvent<0>, opts: ToAccountOpts = {})
|
||||||
|
|
||||||
const npub = nip19.npubEncode(pubkey);
|
const npub = nip19.npubEncode(pubkey);
|
||||||
|
|
||||||
const [user, parsed05, followersCount, followingCount, statusesCount] = await Promise.all([
|
const [user, parsed05] = await Promise.all([
|
||||||
findUser({ pubkey }),
|
findUser({ pubkey }),
|
||||||
parseAndVerifyNip05(nip05, pubkey),
|
parseAndVerifyNip05(nip05, pubkey),
|
||||||
eventsDB.countFilters([{ kinds: [3], '#p': [pubkey] }]),
|
|
||||||
getFollowedPubkeys(pubkey).then((pubkeys) => pubkeys.length),
|
|
||||||
eventsDB.countFilters([{ kinds: [1], authors: [pubkey] }]),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -40,14 +39,14 @@ async function renderAccount(event: UnsignedEvent<0>, opts: ToAccountOpts = {})
|
||||||
avatar: picture,
|
avatar: picture,
|
||||||
avatar_static: picture,
|
avatar_static: picture,
|
||||||
bot: false,
|
bot: false,
|
||||||
created_at: event ? nostrDate(event.created_at).toISOString() : new Date().toISOString(),
|
created_at: nostrDate(event.created_at).toISOString(),
|
||||||
discoverable: true,
|
discoverable: true,
|
||||||
display_name: name,
|
display_name: name,
|
||||||
emojis: renderEmojis(event),
|
emojis: renderEmojis(event),
|
||||||
fields: [],
|
fields: [],
|
||||||
follow_requests_count: 0,
|
follow_requests_count: 0,
|
||||||
followers_count: followersCount,
|
followers_count: event.author_stats?.followers_count ?? 0,
|
||||||
following_count: followingCount,
|
following_count: event.author_stats?.following_count ?? 0,
|
||||||
fqn: parsed05?.handle || npub,
|
fqn: parsed05?.handle || npub,
|
||||||
header: banner,
|
header: banner,
|
||||||
header_static: banner,
|
header_static: banner,
|
||||||
|
@ -65,7 +64,7 @@ async function renderAccount(event: UnsignedEvent<0>, opts: ToAccountOpts = {})
|
||||||
follow_requests_count: 0,
|
follow_requests_count: 0,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
statuses_count: statusesCount,
|
statuses_count: event.author_stats?.notes_count ?? 0,
|
||||||
url: Conf.local(`/users/${pubkey}`),
|
url: Conf.local(`/users/${pubkey}`),
|
||||||
username: parsed05?.nickname || npub.substring(0, 8),
|
username: parsed05?.nickname || npub.substring(0, 8),
|
||||||
pleroma: {
|
pleroma: {
|
||||||
|
|
|
@ -13,7 +13,10 @@ import { DittoAttachment, renderAttachment } from '@/views/mastodon/attachments.
|
||||||
import { renderEmojis } from '@/views/mastodon/emojis.ts';
|
import { renderEmojis } from '@/views/mastodon/emojis.ts';
|
||||||
|
|
||||||
async function renderStatus(event: eventsDB.DittoEvent<1>, viewerPubkey?: string) {
|
async function renderStatus(event: eventsDB.DittoEvent<1>, viewerPubkey?: string) {
|
||||||
const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey);
|
const account = event.author
|
||||||
|
? await renderAccount({ ...event.author, author_stats: event.author_stats })
|
||||||
|
: await accountFromPubkey(event.pubkey);
|
||||||
|
|
||||||
const replyTag = findReplyTag(event);
|
const replyTag = findReplyTag(event);
|
||||||
|
|
||||||
const mentionedPubkeys = [
|
const mentionedPubkeys = [
|
||||||
|
@ -26,13 +29,10 @@ async function renderStatus(event: eventsDB.DittoEvent<1>, viewerPubkey?: string
|
||||||
|
|
||||||
const { html, links, firstUrl } = parseNoteContent(event.content);
|
const { html, links, firstUrl } = parseNoteContent(event.content);
|
||||||
|
|
||||||
const [mentions, card, repliesCount, reblogsCount, favouritesCount, [repostEvent], [reactionEvent]] = await Promise
|
const [mentions, card, [repostEvent], [reactionEvent]] = await Promise
|
||||||
.all([
|
.all([
|
||||||
Promise.all(mentionedPubkeys.map(toMention)),
|
Promise.all(mentionedPubkeys.map(toMention)),
|
||||||
firstUrl ? unfurlCardCached(firstUrl) : null,
|
firstUrl ? unfurlCardCached(firstUrl) : null,
|
||||||
eventsDB.countFilters([{ kinds: [1], '#e': [event.id] }]),
|
|
||||||
eventsDB.countFilters([{ kinds: [6], '#e': [event.id] }]),
|
|
||||||
eventsDB.countFilters([{ kinds: [7], '#e': [event.id] }]),
|
|
||||||
viewerPubkey
|
viewerPubkey
|
||||||
? eventsDB.getFilters([{ kinds: [6], '#e': [event.id], authors: [viewerPubkey] }], { limit: 1 })
|
? eventsDB.getFilters([{ kinds: [6], '#e': [event.id], authors: [viewerPubkey] }], { limit: 1 })
|
||||||
: [],
|
: [],
|
||||||
|
@ -66,9 +66,9 @@ async function renderStatus(event: eventsDB.DittoEvent<1>, viewerPubkey?: string
|
||||||
spoiler_text: (cw ? cw[1] : subject?.[1]) || '',
|
spoiler_text: (cw ? cw[1] : subject?.[1]) || '',
|
||||||
visibility: 'public',
|
visibility: 'public',
|
||||||
language: event.tags.find((tag) => tag[0] === 'lang')?.[1] || null,
|
language: event.tags.find((tag) => tag[0] === 'lang')?.[1] || null,
|
||||||
replies_count: repliesCount,
|
replies_count: event.event_stats?.replies_count ?? 0,
|
||||||
reblogs_count: reblogsCount,
|
reblogs_count: event.event_stats?.reposts_count ?? 0,
|
||||||
favourites_count: favouritesCount,
|
favourites_count: event.event_stats?.reactions_count ?? 0,
|
||||||
favourited: reactionEvent?.content === '+',
|
favourited: reactionEvent?.content === '+',
|
||||||
reblogged: Boolean(repostEvent),
|
reblogged: Boolean(repostEvent),
|
||||||
muted: false,
|
muted: false,
|
||||||
|
@ -86,8 +86,8 @@ async function renderStatus(event: eventsDB.DittoEvent<1>, viewerPubkey?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toMention(pubkey: string) {
|
async function toMention(pubkey: string) {
|
||||||
const profile = await getAuthor(pubkey);
|
const author = await getAuthor(pubkey);
|
||||||
const account = profile ? await renderAccount(profile) : undefined;
|
const account = author ? await renderAccount(author) : undefined;
|
||||||
|
|
||||||
if (account) {
|
if (account) {
|
||||||
return {
|
return {
|
||||||
|
|
Loading…
Reference in New Issue