Skip to content

Commit

Permalink
feat: impl home timeline (#440)
Browse files Browse the repository at this point in the history
  • Loading branch information
laminne authored Jun 15, 2024
1 parent 9443a00 commit 89f3547
Show file tree
Hide file tree
Showing 18 changed files with 691 additions and 100 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"blurhash": "^2.0.5",
"file-type": "^19.0.0",
"hono": "^4.0.0",
"ioredis": "^5.4.1",
"jose": "^5.2.1",
"prisma": "^5.9.1",
"sharp": "^0.33.4",
Expand Down
30 changes: 30 additions & 0 deletions pkg/intermodule/timeline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Result } from '@mikuroxina/mini-fn';
import { hc } from 'hono/client';

import type { Note } from '../notes/model/note.js';
import type { TimelineModuleHandlerType } from '../timeline/mod.js';

export class TimelineModule {
private readonly client = hc<TimelineModuleHandlerType>(
'http://localhost:3000',
);
constructor() {}

/*
* @description Push note to timeline
* @param note to be pushed
* */
async pushNoteToTimeline(note: Note): Promise<Result.Result<Error, void>> {
const res = await this.client.timeline.index.$post({
json: {
id: note.getID(),
authorId: note.getAuthorID(),
},
});
if (!res.ok) {
return Result.err(new Error('Failed to push note'));
}

return Result.ok(undefined);
}
}
40 changes: 33 additions & 7 deletions pkg/timeline/adaptor/repository/dummy.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,60 @@
import { Result } from '@mikuroxina/mini-fn';

import type { AccountID } from '../../../accounts/model/account.js';
import type { Note } from '../../../notes/model/note.js';
import type { Note, NoteID } from '../../../notes/model/note.js';
import type {
FetchAccountTimelineFilter,
FetchHomeTimelineFilter,
TimelineRepository,
} from '../../model/repository.js';

export class InMemoryTimelineRepository implements TimelineRepository {
private readonly data: Set<Note>;
private readonly data: Map<NoteID, Note>;

constructor(data: readonly Note[] = []) {
this.data = new Set<Note>(data);
this.data = new Map(data.map((v) => [v.getID(), v]));
}

async getAccountTimeline(
accountId: AccountID,
filter: FetchAccountTimelineFilter,
): Promise<Result.Result<Error, Note[]>> {
const accountNotes = [...this.data].filter(
(note) => note.getAuthorID() === accountId,
(note) => note[1].getAuthorID() === accountId,
);

// ToDo: filter hasAttachment, noNSFW
accountNotes.sort(
(a, b) => b.getCreatedAt().getTime() - a.getCreatedAt().getTime(),
(a, b) => b[1].getCreatedAt().getTime() - a[1].getCreatedAt().getTime(),
);
const beforeIndex = filter.beforeId
? accountNotes.findIndex((note) => note.getID() === filter.beforeId)
? accountNotes.findIndex((note) => note[1].getID() === filter.beforeId)
: accountNotes.length - 1;
return Result.ok(accountNotes.slice(0, beforeIndex));

return Result.ok(accountNotes.slice(0, beforeIndex).map((note) => note[1]));
}

async getHomeTimeline(
noteIDs: NoteID[],
filter: FetchHomeTimelineFilter,
): Promise<Result.Result<Error, Note[]>> {
const notes: Note[] = [];
for (const noteID of noteIDs) {
const n = this.data.get(noteID);
if (!n) {
return Result.err(new Error('Not found'));
}
notes.push(n);
}

// ToDo: filter hasAttachment, noNSFW
notes.sort(
(a, b) => b.getCreatedAt().getTime() - a.getCreatedAt().getTime(),
);
const beforeIndex = filter.beforeId
? notes.findIndex((note) => note.getID() === filter.beforeId)
: notes.length;

return Result.ok(notes.slice(0, beforeIndex));
}
}
58 changes: 58 additions & 0 deletions pkg/timeline/adaptor/repository/dummyCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Result } from '@mikuroxina/mini-fn';

import type { AccountID } from '../../../accounts/model/account.js';
import { type Note, type NoteID } from '../../../notes/model/note.js';
import type {
CacheObjectKey,
TimelineNotesCacheRepository,
} from '../../model/repository.js';

export class InMemoryTimelineCacheRepository
implements TimelineNotesCacheRepository
{
private readonly data: Map<CacheObjectKey, NoteID[]>;
constructor(data: [AccountID, NoteID[]][] = []) {
this.data = new Map(
data.map(([accountID, noteIDs]) => [
`timeline:home:${accountID}`,
noteIDs,
]),
);
}

private generateObjectKey(accountID: AccountID): CacheObjectKey {
return `timeline:home:${accountID}`;
}

async addNotesToHomeTimeline(
accountID: AccountID,
notes: Note[],
): Promise<Result.Result<Error, void>> {
const objectKey = this.generateObjectKey(accountID);
if (!this.data.has(objectKey)) {
this.data.set(
objectKey,
notes.map((note) => note.getID()),
);
return Result.ok(undefined);
}
const fetched = this.data.get(objectKey)!;
// NOTE: replace by updated object
this.data.delete(objectKey);

fetched.push(...notes.map((note) => note.getID()));
this.data.set(objectKey, fetched);

return Result.ok(undefined);
}

async getHomeTimeline(
accountID: AccountID,
): Promise<Result.Result<Error, NoteID[]>> {
const fetched = this.data.get(this.generateObjectKey(accountID));
if (!fetched) {
return Result.err(new Error('Not found'));
}
return Result.ok(fetched.sort());
}
}
25 changes: 23 additions & 2 deletions pkg/timeline/adaptor/repository/prisma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
} from '../../model/repository.js';

export class PrismaTimelineRepository implements TimelineRepository {
private readonly TIMELINE_NOTE_LIMIT = 20;
constructor(private readonly prisma: PrismaClient) {}

private deserialize(
Expand Down Expand Up @@ -55,7 +56,6 @@ export class PrismaTimelineRepository implements TimelineRepository {
accountId: AccountID,
filter: FetchAccountTimelineFilter,
): Promise<Result.Result<Error, Note[]>> {
console.log(filter);
const accountNotes = await this.prisma.note.findMany({
where: {
authorId: accountId,
Expand All @@ -67,7 +67,28 @@ export class PrismaTimelineRepository implements TimelineRepository {
id: filter.beforeId ?? '',
},
});
console.log(accountNotes);

return Result.ok(this.deserialize(accountNotes));
}

async getHomeTimeline(
noteIDs: NoteID[],
filter: FetchAccountTimelineFilter,
): Promise<Result.Result<Error, Note[]>> {
const homeNotes = await this.prisma.note.findMany({
where: {
id: {
in: noteIDs,
},
},
orderBy: {
createdAt: 'desc',
},
cursor: {
id: filter.beforeId ?? '',
},
take: this.TIMELINE_NOTE_LIMIT,
});
return Result.ok(this.deserialize(homeNotes));
}
}
55 changes: 55 additions & 0 deletions pkg/timeline/adaptor/repository/valkeyCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Result } from '@mikuroxina/mini-fn';
import { type Redis } from 'ioredis';

import type { AccountID } from '../../../accounts/model/account.js';
import { type Note, type NoteID } from '../../../notes/model/note.js';
import type {
CacheObjectKey,
TimelineNotesCacheRepository,
} from '../../model/repository.js';

export class ValkeyTimelineCacheRepository
implements TimelineNotesCacheRepository
{
constructor(private readonly redisClient: Redis) {}

private generateObjectKey(accountID: AccountID): CacheObjectKey {
return `timeline:home:${accountID}`;
}

async addNotesToHomeTimeline(
accountID: AccountID,
notes: Note[],
): Promise<Result.Result<Error, void>> {
try {
// ToDo: replace with bulk insert
await Promise.all(
notes.map((v) =>
this.redisClient.zadd(
this.generateObjectKey(accountID),
v.getCreatedAt().getTime(),
v.getID(),
),
),
);
return Result.ok(undefined);
} catch (e) {
return Result.err(e as Error);
}
}

async getHomeTimeline(
accountID: AccountID,
): Promise<Result.Result<Error, NoteID[]>> {
try {
const fetched = await this.redisClient.zrange(
this.generateObjectKey(accountID),
0,
-1,
);
return Result.ok(fetched as NoteID[]);
} catch (e) {
return Result.err(e as Error);
}
}
}
88 changes: 64 additions & 24 deletions pkg/timeline/mod.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,86 @@
import { OpenAPIHono } from '@hono/zod-openapi';
import { Result } from '@mikuroxina/mini-fn';
import { Option, Result } from '@mikuroxina/mini-fn';

import type { AccountID } from '../accounts/model/account.js';
import { AccountModule } from '../intermodule/account.js';
import { Note, type NoteID } from '../notes/model/note.js';
import { TimelineController } from './adaptor/controller/timeline.js';
import { InMemoryTimelineRepository } from './adaptor/repository/dummy.js';
import { GetAccountTimelineRoute } from './router.js';
import { InMemoryTimelineCacheRepository } from './adaptor/repository/dummyCache.js';
import { GetAccountTimelineRoute, PushNoteToTimelineRoute } from './router.js';
import { AccountTimelineService } from './service/account.js';
import { NoteVisibilityService } from './service/noteVisibility.js';
import { PushTimelineService } from './service/push.js';

const accountModule = new AccountModule();
const timelineRepository = new InMemoryTimelineRepository();
const timelineNotesCacheRepository = new InMemoryTimelineCacheRepository();
const noteVisibilityService = new NoteVisibilityService(accountModule);
const controller = new TimelineController({
accountTimelineService: new AccountTimelineService({
noteVisibilityService: new NoteVisibilityService(accountModule),
noteVisibilityService: noteVisibilityService,
timelineRepository: timelineRepository,
}),
accountModule,
});
const pushTimelineService = new PushTimelineService(
accountModule,
noteVisibilityService,
timelineNotesCacheRepository,
);

export type TimelineModuleHandlerType = typeof pushNoteToTimeline;

export const timeline = new OpenAPIHono().doc('/timeline/doc.json', {
openapi: '3.0.0',
info: {
title: 'Timeline API',
version: '0.1.0',
},
});
timeline.openapi(GetAccountTimelineRoute, async (c) => {
// ToDo: get account id who is trying to see the timeline
const { id } = c.req.param();
const { has_attachment, no_nsfw, before_id } = c.req.valid('query');

export const timeline = new OpenAPIHono()
.doc('/timeline/doc.json', {
openapi: '3.0.0',
info: {
title: 'Timeline API',
version: '0.1.0',
},
})
.openapi(GetAccountTimelineRoute, async (c) => {
// ToDo: get account id who is trying to see the timeline
const { id } = c.req.param();
const { has_attachment, no_nsfw, before_id } = c.req.valid('query');

const res = await controller.getAccountTimeline(
id,
'',
has_attachment,
no_nsfw,
before_id,
const res = await controller.getAccountTimeline(
id,
'',
has_attachment,
no_nsfw,
before_id,
);
if (Result.isErr(res)) {
return c.json({ error: res[1].message, status: 400 });
}

return c.json(res[1]);
});

// ToDo: Require internal access token in this endpoint
// NOTE: This is internal endpoint
const pushNoteToTimeline = timeline.openapi(
PushNoteToTimelineRoute,
async (c) => {
const { id, authorId } = c.req.valid('json');
const res = await pushTimelineService.handle(
Note.new({
id: id as NoteID,
authorID: authorId as AccountID,
content: '',
contentsWarningComment: '',
createdAt: new Date(),
originalNoteID: Option.none(),
sendTo: Option.none(),
visibility: 'FOLLOWERS',
}),
);
if (Result.isErr(res)) {
return c.json({ error: res[1].message, status: 400 });
}

return c.json(res[1]);
});
return new Response(undefined, { status: 204 });
},
);

// ToDo: impl DropNoteFromTimelineRoute
Loading

0 comments on commit 89f3547

Please sign in to comment.