diff --git a/pkg/accounts/adaptor/controller/account.ts b/pkg/accounts/adaptor/controller/account.ts index f4878d58..735e1bb2 100644 --- a/pkg/accounts/adaptor/controller/account.ts +++ b/pkg/accounts/adaptor/controller/account.ts @@ -6,6 +6,7 @@ import { type AccountID, type AccountName } from '../../model/account.js'; import type { AuthenticateService } from '../../service/authenticate.js'; import type { EditService } from '../../service/edit.js'; import type { FetchService } from '../../service/fetch.js'; +import type { FetchFollowService } from '../../service/fetchFollow.js'; import type { FollowService } from '../../service/follow.js'; import type { FreezeService } from '../../service/freeze.js'; import type { RegisterService } from '../../service/register.js'; @@ -15,6 +16,8 @@ import { type UnfollowService } from '../../service/unfollow.js'; import type { VerifyAccountTokenService } from '../../service/verifyToken.js'; import { type CreateAccountResponseSchema, + type GetAccountFollowerSchema, + type GetAccountFollowingSchema, type GetAccountResponseSchema, type LoginResponseSchema, type UpdateAccountResponseSchema, @@ -30,6 +33,7 @@ export class AccountController { private readonly silenceService: SilenceService; private readonly followService: FollowService; private readonly unFollowService: UnfollowService; + private readonly fetchFollowService: FetchFollowService; private readonly resendTokenService: ResendVerifyTokenService; constructor(args: { @@ -42,6 +46,7 @@ export class AccountController { silenceService: SilenceService; followService: FollowService; unFollowService: UnfollowService; + fetchFollowService: FetchFollowService; resendTokenService: ResendVerifyTokenService; }) { this.registerService = args.registerService; @@ -53,6 +58,7 @@ export class AccountController { this.silenceService = args.silenceService; this.followService = args.followService; this.unFollowService = args.unFollowService; + this.fetchFollowService = args.fetchFollowService; this.resendTokenService = args.resendTokenService; } @@ -285,4 +291,86 @@ export class AccountController { return Result.ok(undefined); } + + async fetchFollowing( + id: string, + ): Promise>> { + const res = await this.fetchFollowService.fetchFollowingsByID( + id as ID, + ); + if (Result.isErr(res)) { + return res; + } + const accounts = await Promise.all( + res[1].map((v) => this.fetchService.fetchAccountByID(v.getTargetID())), + ); + return Result.ok( + accounts + .filter((v) => Result.isOk(v)) + .map((v) => { + const unwrapped = Result.unwrap(v); + // ToDo: make optional some fields + return { + id: unwrapped.getID(), + email: unwrapped.getMail(), + name: unwrapped.getName(), + nickname: unwrapped.getNickname(), + bio: unwrapped.getBio(), + // ToDo: fill the following fields + avatar: '', + header: '', + followed_count: 0, + following_count: 0, + note_count: 0, + created_at: unwrapped.getCreatedAt(), + role: unwrapped.getRole(), + frozen: unwrapped.getFrozen(), + status: unwrapped.getStatus(), + silenced: unwrapped.getSilenced(), + }; + }), + ); + } + + async fetchFollower( + id: string, + ): Promise>> { + const res = await this.fetchFollowService.fetchFollowersByID( + id as ID, + ); + if (Result.isErr(res)) { + return Result.err(res[1]); + } + const accounts = await Promise.all( + res[1].map( + async (v) => await this.fetchService.fetchAccountByID(v.getFromID()), + ), + ); + return Result.ok( + accounts + .filter((v) => Result.isOk(v)) + .map((v) => { + const unwrapped = Result.unwrap(v); + // ToDo: make optional some fields + return { + id: unwrapped.getID(), + email: unwrapped.getMail(), + name: unwrapped.getName(), + nickname: unwrapped.getNickname(), + bio: unwrapped.getBio(), + // ToDo: fill the following fields + avatar: '', + header: '', + followed_count: 0, + following_count: 0, + note_count: 0, + created_at: unwrapped.getCreatedAt(), + role: unwrapped.getRole(), + frozen: unwrapped.getFrozen(), + status: unwrapped.getStatus(), + silenced: unwrapped.getSilenced(), + }; + }), + ); + } } diff --git a/pkg/accounts/adaptor/validator/schema.ts b/pkg/accounts/adaptor/validator/schema.ts index e31bd844..33079682 100644 --- a/pkg/accounts/adaptor/validator/schema.ts +++ b/pkg/accounts/adaptor/validator/schema.ts @@ -257,3 +257,10 @@ export const GetAccountResponseSchema = z export const FollowAccountResponseSchema = z .object({}) .openapi('FollowAccountResponse'); + +export const GetAccountFollowingSchema = z + .array(GetAccountResponseSchema) + .openapi('GetAccountFollowingResponse'); +export const GetAccountFollowerSchema = z + .array(GetAccountResponseSchema) + .openapi('GetAccountFollowerResponse'); diff --git a/pkg/accounts/mod.ts b/pkg/accounts/mod.ts index 1990b638..c8ff0ea8 100644 --- a/pkg/accounts/mod.ts +++ b/pkg/accounts/mod.ts @@ -23,6 +23,8 @@ import { CreateAccountRoute, FollowAccountRoute, FreezeAccountRoute, + GetAccountFollowerRoute, + GetAccountFollowingRoute, GetAccountRoute, LoginRoute, RefreshRoute, @@ -39,6 +41,7 @@ import { authenticateToken } from './service/authenticationTokenService.js'; import { edit } from './service/edit.js'; import { etag } from './service/etagService.js'; import { fetch } from './service/fetch.js'; +import { fetchFollow } from './service/fetchFollow.js'; import { follow } from './service/follow.js'; import { freeze } from './service/freeze.js'; import { register } from './service/register.js'; @@ -134,6 +137,11 @@ export const controller = new AccountController({ .feed(Ether.compose(verifyAccountTokenService)) .feed(Ether.compose(dummy)).value, ), + fetchFollowService: Ether.runEther( + Cat.cat(fetchFollow) + .feed(Ether.compose(accountFollowRepository)) + .feed(Ether.compose(accountRepository)).value, + ), }); // ToDo: load secret from config file @@ -151,7 +159,10 @@ accounts.doc('/accounts/doc.json', { }, }); -export type AccountModuleHandlerType = typeof GetAccountHandler; +export type AccountModuleHandlerType = + | typeof GetAccountHandler + | typeof getAccountFollowingRoute + | typeof getAccountFollowerRoute; accounts.post('/accounts', CaptchaMiddleware.handle()); accounts.openapi(CreateAccountRoute, async (c) => { @@ -313,3 +324,56 @@ accounts.openapi(UnFollowAccountRoute, async (c) => { return new Response(null, { status: 204 }); }); + +const getAccountFollowingRoute = accounts.openapi( + GetAccountFollowingRoute, + async (c) => { + const id = c.req.param('id'); + const res = await controller.fetchFollowing(id); + if (Result.isErr(res)) { + return c.json({ error: res[1].message }, { status: 400 }); + } + const unwrap = Result.unwrap(res); + return c.json( + unwrap.map((v) => { + return { + id: v.id, + name: v.name, + nickname: v.nickname, + bio: v.bio, + avatar: '', + header: '', + followed_count: v.followed_count, + following_count: v.following_count, + note_count: v.note_count, + }; + }), + ); + }, +); +const getAccountFollowerRoute = accounts.openapi( + GetAccountFollowerRoute, + async (c) => { + const id = c.req.param('id'); + const res = await controller.fetchFollower(id); + if (Result.isErr(res)) { + return c.json({ error: res[1].message }, { status: 400 }); + } + const unwrap = Result.unwrap(res); + return c.json( + unwrap.map((v) => { + return { + id: v.id, + name: v.name, + nickname: v.nickname, + bio: v.bio, + avatar: '', + header: '', + followed_count: v.followed_count, + following_count: v.following_count, + note_count: v.note_count, + }; + }), + ); + }, +); diff --git a/pkg/accounts/router.ts b/pkg/accounts/router.ts index f4e43781..764017e6 100644 --- a/pkg/accounts/router.ts +++ b/pkg/accounts/router.ts @@ -5,6 +5,7 @@ import { CreateAccountRequestSchema, CreateAccountResponseSchema, FollowAccountResponseSchema, + GetAccountFollowingSchema, GetAccountResponseSchema, LoginRequestSchema, LoginResponseSchema, @@ -589,3 +590,68 @@ export const UnFollowAccountRoute = createRoute({ }, }, }); + +export const GetAccountFollowingRoute = createRoute({ + method: 'get', + tags: ['accounts'], + path: '/accounts/:id/following', + request: { + params: z.object({ + id: z.string().min(3).max(64).openapi({ + example: 'example_man', + description: + 'Characters must be [A-Za-z0-9-.] The first and last characters must be [A-Za-z0-9-.]', + }), + }), + }, + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: GetAccountFollowingSchema, + }, + }, + }, + 404: { + description: 'Not Found', + content: { + 'application/json': { + schema: CommonErrorResponseSchema, + }, + }, + }, + }, +}); +export const GetAccountFollowerRoute = createRoute({ + method: 'get', + tags: ['accounts'], + path: '/accounts/:id/follower', + request: { + params: z.object({ + id: z.string().min(3).max(64).openapi({ + example: 'example_man', + description: + 'Characters must be [A-Za-z0-9-.] The first and last characters must be [A-Za-z0-9-.]', + }), + }), + }, + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: GetAccountFollowingSchema, + }, + }, + }, + 404: { + description: 'Not Found', + content: { + 'application/json': { + schema: CommonErrorResponseSchema, + }, + }, + }, + }, +}); diff --git a/pkg/intermodule/account.ts b/pkg/intermodule/account.ts index 186b4221..e89135cc 100644 --- a/pkg/intermodule/account.ts +++ b/pkg/intermodule/account.ts @@ -13,6 +13,13 @@ import { } from '../accounts/model/account.js'; import type { ID } from '../id/type.js'; +export interface PartialAccount { + id: ID; + name: AccountName; + nickname: string; + bio: string; +} + export class AccountModule { // NOTE: This is a temporary solution to use hono client // ToDo: base url should be configurable @@ -54,4 +61,56 @@ export class AccountModule { return Result.ok(account); } + + async fetchFollowings( + id: ID, + ): Promise> { + const res = await this.client.accounts[':id'].following.$get({ + param: { id }, + }); + if (!res.ok) { + return Result.err(new Error('Failed to fetch followings')); + } + + const body = await res.json(); + if ('error' in body) { + return Result.err(new Error(body.error)); + } + return Result.ok( + body.map((v): PartialAccount => { + return { + id: v.id as ID, + name: v.name as AccountName, + nickname: v.nickname, + bio: v.bio, + }; + }), + ); + } + + async fetchFollowers( + id: ID, + ): Promise> { + const res = await this.client.accounts[':id'].follower.$get({ + param: { id }, + }); + if (!res.ok) { + return Result.err(new Error('Failed to fetch followers')); + } + + const body = await res.json(); + if ('error' in body) { + return Result.err(new Error(body.error)); + } + return Result.ok( + body.map((v): PartialAccount => { + return { + id: v.id as ID, + name: v.name as AccountName, + nickname: v.nickname, + bio: v.bio, + }; + }), + ); + } } diff --git a/pkg/timeline/adaptor/controller/timeline.ts b/pkg/timeline/adaptor/controller/timeline.ts new file mode 100644 index 00000000..03d63037 --- /dev/null +++ b/pkg/timeline/adaptor/controller/timeline.ts @@ -0,0 +1,89 @@ +import { type z } from '@hono/zod-openapi'; +import { Result } from '@mikuroxina/mini-fn'; + +import { + type Account, + type AccountID, +} from '../../../accounts/model/account.js'; +import type { ID } from '../../../id/type.js'; +import type { AccountModule } from '../../../intermodule/account.js'; +import type { AccountTimelineService } from '../../service/account.js'; +import type { GetAccountTimelineResponseSchema } from '../validator/timeline.js'; + +export class TimelineController { + private readonly accountTimelineService: AccountTimelineService; + private readonly accountModule: AccountModule; + constructor(args: { + accountTimelineService: AccountTimelineService; + accountModule: AccountModule; + }) { + this.accountTimelineService = args.accountTimelineService; + this.accountModule = args.accountModule; + } + + async getAccountTimeline( + targetId: string, + fromId: string, + hasAttachment: boolean, + noNsfw: boolean, + beforeId?: string, + ): Promise< + Result.Result> + > { + const res = await this.accountTimelineService.handle( + targetId as ID, + { + id: fromId as ID, + hasAttachment, + noNsfw, + beforeId: beforeId as ID, + }, + ); + if (Result.isErr(res)) { + return res; + } + const accountNotes = Result.unwrap(res); + + const accountIDSet = new Set>( + accountNotes.map((v) => v.getAuthorID()), + ); + const accountData = await Promise.all( + [...accountIDSet].map((v) => this.accountModule.fetchAccount(v)), + ); + + // ToDo: N+1 + const accounts = accountData + .filter((v) => Result.isOk(v)) + .map((v) => Result.unwrap(v)); + const accountsMap = new Map, Account>( + accounts.map((v) => [v.getID(), v]), + ); + + const result = accountNotes + .filter((v) => accountsMap.has(v.getAuthorID())) + .map((v) => { + // NOTE: This variable is safe because it is filtered by the above filter + const account = accountsMap.get(v.getAuthorID())!; + + return { + id: v.getID(), + content: v.getContent(), + contents_warning_comment: v.getCwComment(), + visibility: v.getVisibility(), + created_at: v.getCreatedAt().toUTCString(), + author: { + id: account.getID(), + name: account.getName(), + display_name: account.getNickname(), + bio: account.getBio(), + avatar: '', + header: '', + followed_count: 0, + following_count: 0, + }, + }; + }); + + return Result.ok(result); + } +} diff --git a/pkg/timeline/adaptor/repository/dummy.ts b/pkg/timeline/adaptor/repository/dummy.ts new file mode 100644 index 00000000..eb25643a --- /dev/null +++ b/pkg/timeline/adaptor/repository/dummy.ts @@ -0,0 +1,35 @@ +import { Result } from '@mikuroxina/mini-fn'; + +import type { AccountID } from '../../../accounts/model/account.js'; +import type { ID } from '../../../id/type.js'; +import type { Note } from '../../../notes/model/note.js'; +import type { + FetchAccountTimelineFilter, + TimelineRepository, +} from '../../model/repository.js'; + +export class InMemoryTimelineRepository implements TimelineRepository { + private readonly data: Set; + + constructor(data: readonly Note[] = []) { + this.data = new Set(data); + } + + async getAccountTimeline( + accountId: ID, + filter: FetchAccountTimelineFilter, + ): Promise> { + const accountNotes = [...this.data].filter( + (note) => note.getAuthorID() === accountId, + ); + + // ToDo: filter hasAttachment, noNSFW + accountNotes.sort( + (a, b) => b.getCreatedAt().getTime() - a.getCreatedAt().getTime(), + ); + const beforeIndex = filter.beforeId + ? accountNotes.findIndex((note) => note.getID() === filter.beforeId) + : accountNotes.length - 1; + return Result.ok(accountNotes.slice(0, beforeIndex)); + } +} diff --git a/pkg/timeline/adaptor/repository/prisma.ts b/pkg/timeline/adaptor/repository/prisma.ts new file mode 100644 index 00000000..74121e28 --- /dev/null +++ b/pkg/timeline/adaptor/repository/prisma.ts @@ -0,0 +1,74 @@ +import { Option, Result } from '@mikuroxina/mini-fn'; +import type { Prisma, PrismaClient } from '@prisma/client'; + +import type { AccountID } from '../../../accounts/model/account.js'; +import type { ID } from '../../../id/type.js'; +import { + Note, + type NoteID, + type NoteVisibility, +} from '../../../notes/model/note.js'; +import type { + FetchAccountTimelineFilter, + TimelineRepository, +} from '../../model/repository.js'; + +export class PrismaTimelineRepository implements TimelineRepository { + constructor(private readonly prisma: PrismaClient) {} + + private deserialize( + data: Prisma.PromiseReturnType, + ): Note[] { + return data.map((v) => { + const visibility = (): NoteVisibility => { + switch (v.visibility) { + case 0: + return 'PUBLIC'; + case 1: + return 'HOME'; + case 2: + return 'FOLLOWERS'; + case 3: + return 'DIRECT'; + default: + throw new Error('Invalid Visibility'); + } + }; + return Note.reconstruct({ + id: v.id as ID, + content: v.text, + authorID: v.authorId as ID, + createdAt: v.createdAt, + deletedAt: !v.deletedAt ? Option.none() : Option.some(v.deletedAt), + contentsWarningComment: '', + originalNoteID: !v.renoteId + ? Option.some(v.renoteId as ID) + : Option.none(), + // ToDo: add SendTo field to db schema + sendTo: Option.none(), + updatedAt: Option.none(), + visibility: visibility() as NoteVisibility, + }); + }); + } + + async getAccountTimeline( + accountId: ID, + filter: FetchAccountTimelineFilter, + ): Promise> { + console.log(filter); + const accountNotes = await this.prisma.note.findMany({ + where: { + authorId: accountId, + }, + orderBy: { + createdAt: 'desc', + }, + cursor: { + id: filter.beforeId ?? '', + }, + }); + console.log(accountNotes); + return Result.ok(this.deserialize(accountNotes)); + } +} diff --git a/pkg/timeline/adaptor/validator/timeline.ts b/pkg/timeline/adaptor/validator/timeline.ts new file mode 100644 index 00000000..58830bd2 --- /dev/null +++ b/pkg/timeline/adaptor/validator/timeline.ts @@ -0,0 +1,38 @@ +import { z } from '@hono/zod-openapi'; + +export const GetAccountTimelineResponseSchema = z + .array( + z.object({ + id: z.string().openapi({ + example: '38477395', + description: 'Note ID', + }), + content: z.string().openapi({ + example: 'hello world!', + description: 'Note content', + }), + contents_warning_comment: z.string().openapi({ + example: '(if length not 0) This note contains sensitive content', + description: 'Contents warning comment', + }), + visibility: z.string().openapi({ + example: 'PUBLIC', + description: 'Note visibility (PUBLIC/HOME/FOLLOWERS/DIRECT)', + }), + created_at: z.string().datetime().openapi({ + example: '2021-01-01T00:00:00Z', + description: 'Note created date', + }), + author: z.object({ + id: z.string(), + name: z.string(), + display_name: z.string(), + bio: z.string(), + avatar: z.string(), + header: z.string(), + followed_count: z.number(), + following_count: z.number(), + }), + }), + ) + .openapi('GetAccountTimelineResponse'); diff --git a/pkg/timeline/mod.ts b/pkg/timeline/mod.ts new file mode 100644 index 00000000..fba4306a --- /dev/null +++ b/pkg/timeline/mod.ts @@ -0,0 +1,46 @@ +import { OpenAPIHono } from '@hono/zod-openapi'; +import { Result } from '@mikuroxina/mini-fn'; + +import { AccountModule } from '../intermodule/account.js'; +import { TimelineController } from './adaptor/controller/timeline.js'; +import { InMemoryTimelineRepository } from './adaptor/repository/dummy.js'; +import { GetAccountTimelineRoute } from './router.js'; +import { AccountTimelineService } from './service/account.js'; +import { NoteVisibilityService } from './service/noteVisibility.js'; + +const accountModule = new AccountModule(); +const timelineRepository = new InMemoryTimelineRepository(); +const controller = new TimelineController({ + accountTimelineService: new AccountTimelineService({ + noteVisibilityService: new NoteVisibilityService(accountModule), + timelineRepository: timelineRepository, + }), + accountModule, +}); + +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, + ); + if (Result.isErr(res)) { + return c.json({ error: res[1].message, status: 400 }); + } + + return c.json(res[1]); + }); diff --git a/pkg/timeline/model/repository.ts b/pkg/timeline/model/repository.ts new file mode 100644 index 00000000..2a813dbb --- /dev/null +++ b/pkg/timeline/model/repository.ts @@ -0,0 +1,29 @@ +import { Ether, type Result } from '@mikuroxina/mini-fn'; + +import type { AccountID } from '../../accounts/model/account.js'; +import type { ID } from '../../id/type.js'; +import type { Note, NoteID } from '../../notes/model/note.js'; + +export interface FetchAccountTimelineFilter { + id: ID; + /** @default false */ + hasAttachment: boolean; + /** @default false */ + noNsfw: boolean; + /** @default undefined + * @description if undefined, Retrieved from latest notes */ + beforeId?: ID; +} + +export interface TimelineRepository { + /** + * @description Fetch account timeline + * @param accountId ID of the account from which the Note is obtained + * @param filter Filter for fetching notes + * */ + getAccountTimeline( + accountId: ID, + filter: FetchAccountTimelineFilter, + ): Promise>; +} +export const timelineRepoSymbol = Ether.newEtherSymbol(); diff --git a/pkg/timeline/router.ts b/pkg/timeline/router.ts new file mode 100644 index 00000000..296bc480 --- /dev/null +++ b/pkg/timeline/router.ts @@ -0,0 +1,58 @@ +import { createRoute, z } from '@hono/zod-openapi'; + +import { CommonErrorResponseSchema } from '../accounts/adaptor/validator/schema.js'; +import { GetAccountTimelineResponseSchema } from './adaptor/validator/timeline.js'; + +export const GetAccountTimelineRoute = createRoute({ + method: 'get', + tags: ['timeline'], + path: '/timeline/accounts/:id', + request: { + params: z.object({ + id: z.string().openapi('Account ID'), + }), + // NOTE: query params must use z.string() + // cf. https://zenn.dev/loglass/articles/c237d89e238d42 (Japanese) + // cf. https://github.com/honojs/middleware/issues/200#issuecomment-1773428171 (GitHub Issue) + query: z.object({ + has_attachment: z + .string() + .optional() + .pipe(z.coerce.boolean().default(false)) + .openapi({ + type: 'boolean', + description: 'If true, only return notes with attachment', + }), + no_nsfw: z + .string() + .optional() + .pipe(z.coerce.boolean().default(false)) + .openapi({ + type: 'boolean', + description: 'If true, only return notes without sensitive content', + }), + before_id: z.string().optional().openapi({ + description: + 'Return notes before this note ID. specified note ID is not included', + }), + }), + }, + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: GetAccountTimelineResponseSchema, + }, + }, + }, + 404: { + description: 'Account not found', + content: { + 'application/json': { + schema: CommonErrorResponseSchema, + }, + }, + }, + }, +}); diff --git a/pkg/timeline/service/account.test.ts b/pkg/timeline/service/account.test.ts new file mode 100644 index 00000000..6049d9a4 --- /dev/null +++ b/pkg/timeline/service/account.test.ts @@ -0,0 +1,128 @@ +import { Option, Result } from '@mikuroxina/mini-fn'; +import { describe, expect, it, vi } from 'vitest'; + +import { Account, type AccountID } from '../../accounts/model/account.js'; +import type { ID } from '../../id/type.js'; +import { + AccountModule, + type PartialAccount, +} from '../../intermodule/account.js'; +import { Note, type NoteID } from '../../notes/model/note.js'; +import { InMemoryTimelineRepository } from '../adaptor/repository/dummy.js'; +import { AccountTimelineService } from './account.js'; +import { NoteVisibilityService } from './noteVisibility.js'; + +describe('AccountTimelineService', () => { + const accountModule = new AccountModule(); + const noteVisibilityService = new NoteVisibilityService(accountModule); + + const dummyPublicNote = Note.new({ + id: '1' as ID, + authorID: '100' as ID, + content: 'Hello world', + contentsWarningComment: '', + createdAt: new Date(), + originalNoteID: Option.none(), + sendTo: Option.none(), + visibility: 'PUBLIC', + }); + const dummyHomeNote = Note.new({ + id: '2' as ID, + authorID: '100' as ID, + content: 'Hello world to Home', + contentsWarningComment: '', + createdAt: new Date(), + originalNoteID: Option.none(), + sendTo: Option.none(), + visibility: 'HOME', + }); + const dummyFollowersNote = Note.new({ + id: '3' as ID, + authorID: '100' as ID, + content: 'Hello world to followers', + contentsWarningComment: '', + createdAt: new Date(), + originalNoteID: Option.none(), + sendTo: Option.none(), + visibility: 'FOLLOWERS', + }); + const dummyDirectNote = Note.new({ + id: '4' as ID, + authorID: '100' as ID, + content: 'Hello world to direct', + contentsWarningComment: '', + createdAt: new Date(), + originalNoteID: Option.none(), + sendTo: Option.some('101' as ID), + visibility: 'DIRECT', + }); + const dummyAccount1 = Account.new({ + id: '101' as ID, + bio: 'this is test user', + mail: 'john@example.com', + name: '@john@example.com', + nickname: 'John Doe', + passphraseHash: '', + role: 'normal', + silenced: 'normal', + status: 'active', + frozen: 'normal', + createdAt: new Date(), + }); + const partialAccount1: PartialAccount = { + id: dummyAccount1.getID(), + name: dummyAccount1.getName(), + nickname: dummyAccount1.getNickname(), + bio: dummyAccount1.getBio(), + }; + const timelineRepository = new InMemoryTimelineRepository([ + dummyPublicNote, + dummyHomeNote, + + dummyFollowersNote, + dummyDirectNote, + ]); + const accountTimelineService = new AccountTimelineService({ + noteVisibilityService, + timelineRepository, + }); + + it('if following', async () => { + vi.spyOn(accountModule, 'fetchFollowers').mockImplementation(async () => { + return Result.ok([partialAccount1]); + }); + const res = await accountTimelineService.handle('100' as ID, { + id: '101' as ID, + hasAttachment: false, + noNsfw: false, + }); + const unwrapped = Result.unwrap(res); + + expect(unwrapped.length).toBe(3); + // NOTE: AccountTimeline not include direct note + expect( + unwrapped.map((v) => v.getVisibility() === 'DIRECT').includes(true), + ).toBe(false); + }); + + it('if not following', async () => { + vi.spyOn(accountModule, 'fetchFollowers').mockImplementation(async () => { + return Result.ok([partialAccount1]); + }); + const res = await accountTimelineService.handle('100' as ID, { + id: '0' as ID, + hasAttachment: false, + noNsfw: false, + }); + const unwrapped = Result.unwrap(res); + + expect(unwrapped.length).toBe(2); + // NOTE: AccountTimeline not include direct note + expect( + unwrapped.map((v) => v.getVisibility() === 'DIRECT').includes(true), + ).toBe(false); + expect( + unwrapped.map((v) => v.getVisibility() === 'FOLLOWERS').includes(true), + ).toBe(false); + }); +}); diff --git a/pkg/timeline/service/account.ts b/pkg/timeline/service/account.ts new file mode 100644 index 00000000..b07ef41d --- /dev/null +++ b/pkg/timeline/service/account.ts @@ -0,0 +1,66 @@ +import { Ether, Result } from '@mikuroxina/mini-fn'; + +import type { AccountID } from '../../accounts/model/account.js'; +import type { ID } from '../../id/type.js'; +import type { Note } from '../../notes/model/note.js'; +import { + type FetchAccountTimelineFilter, + type TimelineRepository, + timelineRepoSymbol, +} from '../model/repository.js'; +import { + type NoteVisibilityService, + noteVisibilitySymbol, +} from './noteVisibility.js'; + +export class AccountTimelineService { + private readonly noteVisibilityService: NoteVisibilityService; + private readonly timelineRepository: TimelineRepository; + + constructor(args: { + noteVisibilityService: NoteVisibilityService; + timelineRepository: TimelineRepository; + }) { + this.noteVisibilityService = args.noteVisibilityService; + this.timelineRepository = args.timelineRepository; + } + + async handle( + targetId: ID, + filter: FetchAccountTimelineFilter, + ): Promise> { + const res = await this.timelineRepository.getAccountTimeline( + targetId, + filter, + ); + if (Result.isErr(res)) { + return Result.err(res[1]); + } + + // NOTE: AccountTimeline not include direct note + const directFiltered = res[1].filter((v) => v.getVisibility() !== 'DIRECT'); + + const filtered: Note[] = []; + for (const v of directFiltered) { + const isVisible = await this.noteVisibilityService.handle({ + accountID: filter.id, + note: v, + }); + if (isVisible) { + filtered.push(v); + } + } + + return Result.ok(filtered); + } +} +export const accountTimelineSymbol = + Ether.newEtherSymbol(); +export const accountTimeline = Ether.newEther( + accountTimelineSymbol, + (deps) => new AccountTimelineService(deps), + { + timelineRepository: timelineRepoSymbol, + noteVisibilityService: noteVisibilitySymbol, + }, +); diff --git a/pkg/timeline/service/noteVisibility.test.ts b/pkg/timeline/service/noteVisibility.test.ts new file mode 100644 index 00000000..dadd9868 --- /dev/null +++ b/pkg/timeline/service/noteVisibility.test.ts @@ -0,0 +1,180 @@ +import { Option, Result } from '@mikuroxina/mini-fn'; +import { describe, expect, it, vi } from 'vitest'; + +import { Account, type AccountID } from '../../accounts/model/account.js'; +import type { ID } from '../../id/type.js'; +import { + AccountModule, + type PartialAccount, +} from '../../intermodule/account.js'; +import { Note, type NoteID } from '../../notes/model/note.js'; +import { NoteVisibilityService } from './noteVisibility.js'; + +describe('NoteVisibilityService', () => { + const accountModule = new AccountModule(); + const visibilityService = new NoteVisibilityService(accountModule); + + const dummyPublicNote = Note.new({ + id: '1' as ID, + authorID: '100' as ID, + content: 'Hello world', + contentsWarningComment: '', + createdAt: new Date(), + originalNoteID: Option.none(), + sendTo: Option.none(), + visibility: 'PUBLIC', + }); + const dummyHomeNote = Note.new({ + id: '2' as ID, + authorID: '100' as ID, + content: 'Hello world to Home', + contentsWarningComment: '', + createdAt: new Date(), + originalNoteID: Option.none(), + sendTo: Option.none(), + visibility: 'HOME', + }); + const dummyFollowersNote = Note.new({ + id: '3' as ID, + authorID: '100' as ID, + content: 'Hello world to followers', + contentsWarningComment: '', + createdAt: new Date(), + originalNoteID: Option.none(), + sendTo: Option.none(), + visibility: 'FOLLOWERS', + }); + const dummyDirectNote = Note.new({ + id: '4' as ID, + authorID: '100' as ID, + content: 'Hello world to direct', + contentsWarningComment: '', + createdAt: new Date(), + originalNoteID: Option.none(), + sendTo: Option.some('101' as ID), + visibility: 'DIRECT', + }); + const dummyAccount1 = Account.new({ + id: '101' as ID, + bio: 'this is test user', + mail: 'john@example.com', + name: '@john@example.com', + nickname: 'John Doe', + passphraseHash: '', + role: 'normal', + silenced: 'normal', + status: 'active', + frozen: 'normal', + createdAt: new Date(), + }); + const partialAccount1: PartialAccount = { + id: dummyAccount1.getID(), + name: dummyAccount1.getName(), + nickname: dummyAccount1.getNickname(), + bio: dummyAccount1.getBio(), + }; + + it("when author's note: return true", async () => { + vi.spyOn(accountModule, 'fetchFollowers').mockImplementation(async () => { + return Result.ok([partialAccount1]); + }); + + const testObjects = [ + dummyPublicNote, + dummyHomeNote, + dummyFollowersNote, + dummyDirectNote, + ]; + for (const note of testObjects) { + expect( + await visibilityService.handle({ + accountID: '100' as ID, + note, + }), + ).toBe(true); + } + }); + + it('when direct note: return true if sendTo is accountID', async () => { + vi.spyOn(accountModule, 'fetchFollowers').mockImplementation(async () => { + return Result.ok([partialAccount1]); + }); + + const res = await visibilityService.handle({ + accountID: '101' as ID, + note: dummyDirectNote, + }); + expect(res).toBe(true); + + const res2 = await visibilityService.handle({ + accountID: '0' as ID, + note: dummyDirectNote, + }); + expect(res2).toBe(false); + }); + + it('when following: return true if public,home,followers', async () => { + vi.spyOn(accountModule, 'fetchFollowers').mockImplementation(async () => { + return Result.ok([partialAccount1]); + }); + // public + expect( + await visibilityService.handle({ + accountID: '101' as ID, + note: dummyPublicNote, + }), + ).toBe(true); + // home + expect( + await visibilityService.handle({ + accountID: '101' as ID, + note: dummyHomeNote, + }), + ).toBe(true); + // followers + expect( + await visibilityService.handle({ + accountID: '101' as ID, + note: dummyFollowersNote, + }), + ).toBe(true); + }); + + it('when not following: return true if public, home', async () => { + vi.spyOn(accountModule, 'fetchFollowers').mockImplementation(async () => { + return Result.ok([partialAccount1]); + }); + + expect( + await visibilityService.handle({ + accountID: '102' as ID, + note: dummyPublicNote, + }), + ).toBe(true); + expect( + await visibilityService.handle({ + accountID: '102' as ID, + note: dummyHomeNote, + }), + ).toBe(true); + + expect( + await visibilityService.handle({ + accountID: '102' as ID, + note: dummyFollowersNote, + }), + ).toBe(false); + }); + + it('always return true if public', async () => { + vi.spyOn(accountModule, 'fetchFollowers').mockImplementation(async () => { + return Result.ok([partialAccount1]); + }); + + const res = await visibilityService.handle({ + accountID: '0' as ID, + note: dummyPublicNote, + }); + expect(res).toBe(true); + }); +}); diff --git a/pkg/timeline/service/noteVisibility.ts b/pkg/timeline/service/noteVisibility.ts new file mode 100644 index 00000000..4a56ccaf --- /dev/null +++ b/pkg/timeline/service/noteVisibility.ts @@ -0,0 +1,55 @@ +import { Ether, Option, Result } from '@mikuroxina/mini-fn'; + +import type { AccountID } from '../../accounts/model/account.js'; +import type { ID } from '../../id/type.js'; +import { type AccountModule } from '../../intermodule/account.js'; +import type { Note } from '../../notes/model/note.js'; + +export interface NoteVisibilityCheckArgs { + // account id of the user who is trying to see the note + accountID: ID; + note: Note; +} + +export class NoteVisibilityService { + constructor(private readonly accountModule: AccountModule) {} + + public async handle(args: NoteVisibilityCheckArgs): Promise { + if (args.accountID === args.note.getAuthorID()) { + return true; + } + if (args.note.getVisibility() === 'PUBLIC') { + return true; + } + if (args.note.getVisibility() === 'HOME') { + return true; + } + if (args.note.getVisibility() === 'DIRECT') { + if (Option.unwrapOr('')(args.note.getSendTo()) === args.accountID) { + return true; + } + } + if (args.note.getVisibility() === 'FOLLOWERS') { + const followers = await this.accountModule.fetchFollowers(args.accountID); + if (Result.isErr(followers)) { + return false; + } + for (const v of followers[1]) { + if (v.id === args.accountID) { + return true; + } + } + } + + return false; + } +} +export const noteVisibilitySymbol = + Ether.newEtherSymbol(); +export const noteVisibility = Ether.newEther( + noteVisibilitySymbol, + ({ accountModule }) => new NoteVisibilityService(accountModule), + { + accountModule: Ether.newEtherSymbol(), + }, +);