-
-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
18 changed files
with
691 additions
and
100 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.