-
-
Notifications
You must be signed in to change notification settings - Fork 3
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
feat: implement API of timeline #239
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
import { Option } from '@mikuroxina/mini-fn'; | ||
|
||
import type { AccountID, AccountName } from '../../accounts/model/account.js'; | ||
import type { ID } from '../../id/type.js'; | ||
import type { | ||
AccountFollowRepository, | ||
AccountRepository, | ||
} from '../model/repository.js'; | ||
|
||
export class FetchAccountFollowService { | ||
constructor( | ||
private readonly accountFollowRepository: AccountFollowRepository, | ||
private readonly accountRepository: AccountRepository, | ||
) {} | ||
|
||
async fetchFollowsByID(id: ID<AccountID>) /* inferred */ { | ||
return this.accountFollowRepository.fetchAllFollowers(id); | ||
} | ||
|
||
async fetchFollowsByName(name: AccountName) /* inferred */ { | ||
const id = await this.accountRepository | ||
.findByName(name) | ||
.then((o) => Option.unwrap(o)) | ||
.then((a) => a.getID()); | ||
|
||
return this.fetchFollowsByID(id); | ||
} | ||
|
||
async fetchFollowersByID(id: ID<AccountID>) /* inferred */ { | ||
return this.accountFollowRepository.fetchAllFollowing(id); | ||
} | ||
|
||
async fetchFollowersByName(name: AccountName) /* inferred */ { | ||
const id = await this.accountRepository | ||
.findByName(name) | ||
.then((o) => Option.unwrap(o)) | ||
.then((a) => a.getID()); | ||
|
||
return this.fetchFollowersByID(id); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,7 +3,9 @@ import { Option, Result } from '@mikuroxina/mini-fn'; | |
import type { AccountID } from '../../../accounts/model/account.js'; | ||
import type { ID } from '../../../id/type.js'; | ||
import { type Note, type NoteID } from '../../model/note.js'; | ||
import type { NoteRepository } from '../../model/repository.js'; | ||
import type { NoteRepository, NoteFilter } from '../../model/repository.js'; | ||
|
||
const NOTES_LIMIT = /* hard-coded */ 20; | ||
|
||
export class InMemoryNoteRepository implements NoteRepository { | ||
private readonly notes: Map<ID<NoteID>, Note>; | ||
|
@@ -17,6 +19,53 @@ export class InMemoryNoteRepository implements NoteRepository { | |
return Result.ok(undefined); | ||
} | ||
|
||
async getFiltered( | ||
filters: NoteFilter[], | ||
): Promise<Result.Result<Error, Note[]>> { | ||
let notes = this.notes.values(); | ||
for (const f of filters) { | ||
switch (f.type) { | ||
case 'author': | ||
notes = notes.filter((n) => { | ||
return f.any.find((v) => v === n.getAuthorID()) !== undefined; | ||
}); | ||
break; | ||
|
||
case 'attachment': | ||
notes = notes.filter(() => { | ||
return /* hard-coded */ 0 > f.more; | ||
}); | ||
break; | ||
|
||
case 'cw': | ||
notes = notes.filter((n) => { | ||
return n.getCwComment() === f.is; | ||
}); | ||
break; | ||
|
||
case 'created': | ||
notes = notes.filter((n) => { | ||
return n.getCreatedAt() < f.less; | ||
}); | ||
break; | ||
|
||
case 'updated': | ||
notes = notes.filter((n) => { | ||
return Option.map((v: Date) => v < f.less)(n.getUpdatedAt()); | ||
}); | ||
break; | ||
|
||
case 'deleted': | ||
notes = notes.filter((n) => { | ||
return Option.isSome(n.getDeletedAt()) === f.has; | ||
}); | ||
break; | ||
} | ||
} | ||
|
||
return Result.ok(notes.take(NOTES_LIMIT).toArray()); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 時刻でソートしてからLimitしたほうが良い気がします |
||
} | ||
|
||
async deleteByID(id: ID<NoteID>): Promise<Result.Result<Error, void>> { | ||
const target = await this.findByID(id); | ||
if (Option.isNone(target)) { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,10 +6,19 @@ import type { Note, NoteID } from './note.js'; | |
|
||
export interface NoteRepository { | ||
create(note: Note): Promise<Result.Result<Error, void>>; | ||
getFiltered(filters: NoteFilter[]): Promise<Result.Result<Error, Note[]>>; | ||
findByAuthorID( | ||
authorID: ID<AccountID>, | ||
limit: number, | ||
): Promise<Option.Option<Note[]>>; | ||
findByID(id: ID<NoteID>): Promise<Option.Option<Note>>; | ||
deleteByID(id: ID<NoteID>): Promise<Result.Result<Error, void>>; | ||
} | ||
|
||
export type NoteFilter = | ||
| { type: 'author'; any: ID<AccountID>[] } | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 複数のユーザーの投稿をフィルターしたいユースケースが浮かなばなかったのですが、そういうユースケースがあるんでしょうか? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ここでは投稿者で投稿をフィルタしているので, タイムラインに流す投稿は 指定したアカウントがフォローしているアカウント全ての投稿 に絞る, という手法を取っています. 指定したアカウントとは, つまり |
||
| { type: 'attachment'; more: number } | ||
| { type: 'cw'; is: string } | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note.ContentsWarningCommentはあればContentsWarning扱いされるものなので、この書き方だとCWCommentの完全一致検索になってしまって意図しない挙動になるかと There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 主題から少し逸れますが, "contents warning comment" はどのように表記するのが正解なのでしょうか? CwComment のような略も存在していたと思いますが, ここでは "CW(C)" と頭文字まで切り詰めています. |
||
| { type: 'created'; less: Date } | ||
| { type: 'updated'; less: Date } | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. updatedはタイムラインでは必要ないかと. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 如何なるタイムラインでも更新日時は並び順に一切考慮されない (考慮されるのは投稿日時のみ) ということで良いですよね? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, 現時点ではそうです |
||
| { type: 'deleted'; has: boolean }; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,122 @@ | ||
import { z } from '@hono/zod-openapi'; | ||
import { Option, Result } from '@mikuroxina/mini-fn'; | ||
|
||
import type { | ||
AccountID, | ||
AccountName, | ||
} from '../../../accounts/model/account.js'; | ||
import type { FetchAccountService } from '../../../accounts/service/fetchAccount.js'; | ||
import type { FetchAccountFollowService } from '../../../accounts/service/fetchAccountFollow.js'; | ||
import type { ID } from '../../../id/type.js'; | ||
import type { NoteFilter } from '../../../notes/model/repository.js'; | ||
import type { FetchNoteService } from '../../../notes/service/fetch.js'; | ||
import type { NoteSchema } from '../validator/schema.js'; | ||
|
||
export class TimelineController { | ||
constructor( | ||
private readonly fetchAccountService: FetchAccountService, | ||
private readonly fetchAccountFollowService: FetchAccountFollowService, | ||
private readonly fetchNoteService: FetchNoteService, | ||
Comment on lines
+17
to
+19
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. これらはintermoduleモジュールからimportしたものを使ってください There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. そうしてください |
||
) {} | ||
|
||
async getTimeline({ | ||
target, | ||
hasAttachment, | ||
isNsfw, | ||
beforeID, | ||
}: { | ||
target?: string; | ||
hasAttachment?: boolean; | ||
isNsfw?: boolean; | ||
beforeID?: string; | ||
}): Promise<Result.Result<Error, z.infer<typeof NoteSchema>[]>> { | ||
const filters: NoteFilter[] = []; | ||
|
||
if (target !== undefined) { | ||
const resFollows = isID<AccountID>(target) | ||
? await this.fetchAccountFollowService.fetchFollowsByID(target) | ||
: isAccountName(target) | ||
? await this.fetchAccountFollowService.fetchFollowsByName(target) | ||
: panic(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. throwするよりResult.errを返した方が良いかと |
||
|
||
if (Result.isErr(resFollows)) { | ||
return resFollows; | ||
} | ||
|
||
filters.push({ | ||
type: 'author', | ||
any: resFollows[1].map((af) => af.getTargetID()), | ||
}); | ||
} | ||
|
||
if (hasAttachment !== undefined) { | ||
filters.push({ type: 'attachment', more: hasAttachment ? 0 : -1 }); | ||
} | ||
|
||
if (isNsfw !== undefined) { | ||
filters.push({ type: 'cw', is: 'nsfw' }); | ||
} | ||
|
||
if (beforeID !== undefined && isID<AccountID>(beforeID)) { | ||
const beforeNote = await this.fetchNoteService | ||
.fetchNoteByID(beforeID) | ||
.then((o) => Option.unwrap(o)); | ||
|
||
filters.push({ type: 'created', less: beforeNote.getCreatedAt() }); | ||
} | ||
|
||
const resNotes = await this.fetchNoteService.fetchNotesWithFilters(filters); | ||
if (Result.isErr(resNotes)) { | ||
return resNotes; | ||
} | ||
|
||
const notes = []; | ||
for (const n of resNotes[1]) { | ||
const resAccount = await this.fetchAccountService.fetchAccountByID( | ||
n.getAuthorID(), | ||
); | ||
|
||
if (Result.isErr(resAccount)) { | ||
return resAccount; | ||
} | ||
|
||
const a = resAccount[1]; | ||
|
||
notes.push({ | ||
id: n.getID(), | ||
content: n.getContent(), | ||
contents_warning_comment: n.getCwComment(), | ||
send_to: n.getSendTo()[1], | ||
visibility: n.getVisibility(), | ||
created_at: n.getCreatedAt().toUTCString(), | ||
author: { | ||
id: a.getID(), | ||
name: a.getName(), | ||
display_name: a.getNickname(), | ||
bio: a.getBio(), | ||
avatar: '', | ||
header: '', | ||
followed_count: 0, | ||
following_count: 0, | ||
}, | ||
}); | ||
} | ||
|
||
return Result.ok(notes); | ||
} | ||
} | ||
|
||
const isID = <T>(s: string): s is ID<T> => { | ||
return z | ||
.string() | ||
.regex(/^\d{64}$/) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pulsate-SnowflakeIDは固定長ではないのでこれは落ちると思います There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Zod によるパーサ (少なくとも型判定ができるもの) で少なくとも There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. どちらも既知の実装はありません There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Notionに記載があります |
||
.safeParse(s).success; | ||
}; | ||
|
||
const isAccountName = (s: string): s is AccountName => { | ||
return /@\w+@\w+/.test(s); | ||
}; | ||
|
||
const panic = () => { | ||
throw new Error('panic!'); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
import { z } from '@hono/zod-openapi'; | ||
|
||
// TODO: もっとどうにかならんのか | ||
import { GetNoteResponseSchema } from '../../../notes/adaptor/validator/schema.js'; | ||
|
||
export const NoteSchema = GetNoteResponseSchema; | ||
|
||
// TODO: 逃げてる | ||
export const CommonErrorResponseSchema = z.object({ | ||
error: z | ||
.enum([ | ||
'INVALID_TIMELINE_TYPE', | ||
'YOU_ARE_BLOCKED', | ||
'NOTHING_LEFT', | ||
'ACCOUNT_NOT_FOUND', | ||
]) | ||
.or(z.string()) | ||
.openapi({ | ||
description: 'Error code', | ||
}), | ||
}); | ||
|
||
export const GetTimelineResponseSchema = NoteSchema.array(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(実際にPR出すときはこれ入れた理由をお願いしますね)