Skip to content

Commit 2e5b9b7

Browse files
authored
feat: impl public timeline for all public notes (#1168)
1 parent da916d5 commit 2e5b9b7

File tree

10 files changed

+654
-29
lines changed

10 files changed

+654
-29
lines changed

pkg/timeline/adaptor/controller/timeline.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type { FetchListService } from '../../service/fetchList.js';
1919
import type { FetchListMemberService } from '../../service/fetchMember.js';
2020
import type { HomeTimelineService } from '../../service/home.js';
2121
import type { ListTimelineService } from '../../service/list.js';
22+
import type { PublicTimelineService } from '../../service/public.js';
2223
import type { RemoveListMemberService } from '../../service/removeMember.js';
2324
import type {
2425
CreateListResponseSchema,
@@ -30,6 +31,7 @@ import type {
3031
GetHomeTimelineResponseSchema,
3132
GetListMemberResponseSchema,
3233
GetListTimelineResponseSchema,
34+
GetPublicTimelineResponseSchema,
3335
} from '../validator/timeline.js';
3436

3537
export class TimelineController {
@@ -47,6 +49,7 @@ export class TimelineController {
4749
private readonly removeListMemberService: RemoveListMemberService;
4850
private readonly fetchBookmarkTimelineService: FetchBookmarkService;
4951
private readonly fetchConversationService: FetchConversationService;
52+
private readonly publicTimelineService: PublicTimelineService;
5053

5154
constructor(args: {
5255
accountTimelineService: AccountTimelineService;
@@ -63,6 +66,7 @@ export class TimelineController {
6366
removeListMemberService: RemoveListMemberService;
6467
fetchBookmarkTimelineService: FetchBookmarkService;
6568
fetchConversationService: FetchConversationService;
69+
publicTimelineService: PublicTimelineService;
6670
}) {
6771
this.accountTimelineService = args.accountTimelineService;
6872
this.accountModule = args.accountModule;
@@ -78,6 +82,7 @@ export class TimelineController {
7882
this.removeListMemberService = args.removeListMemberService;
7983
this.fetchBookmarkTimelineService = args.fetchBookmarkTimelineService;
8084
this.fetchConversationService = args.fetchConversationService;
85+
this.publicTimelineService = args.publicTimelineService;
8186
}
8287

8388
private async getNoteAdditionalData(notes: readonly Note[]): Promise<
@@ -625,4 +630,66 @@ export class TimelineController {
625630
}),
626631
);
627632
}
633+
634+
async getPublicTimeline(
635+
hasAttachment: boolean,
636+
noNsfw: boolean,
637+
beforeId?: string,
638+
afterId?: string,
639+
): Promise<
640+
Result.Result<Error, z.infer<typeof GetPublicTimelineResponseSchema>>
641+
> {
642+
const res = await this.publicTimelineService.fetchPublicTimeline({
643+
hasAttachment,
644+
noNsfw,
645+
beforeId: beforeId as NoteID | undefined,
646+
afterID: afterId as NoteID | undefined,
647+
});
648+
if (Result.isErr(res)) {
649+
return res;
650+
}
651+
const publicNotes = Result.unwrap(res);
652+
653+
const noteAdditionalDataRes = await this.getNoteAdditionalData(publicNotes);
654+
if (Result.isErr(noteAdditionalDataRes)) {
655+
return noteAdditionalDataRes;
656+
}
657+
const noteAdditionalData = Result.unwrap(noteAdditionalDataRes);
658+
659+
const result = noteAdditionalData.map((v) => {
660+
return {
661+
id: v.note.getID(),
662+
content: v.note.getContent(),
663+
contents_warning_comment: v.note.getCwComment(),
664+
visibility: v.note.getVisibility(),
665+
created_at: v.note.getCreatedAt().toUTCString(),
666+
reactions: v.reactions.map((reaction) => ({
667+
emoji: reaction.getEmoji(),
668+
reacted_by: reaction.getAccountID(),
669+
})),
670+
attachment_files: v.attachments.map((file) => ({
671+
id: file.getId(),
672+
name: file.getName(),
673+
author_id: file.getAuthorId(),
674+
hash: file.getHash(),
675+
mime: file.getMime(),
676+
nsfw: file.isNsfw(),
677+
url: file.getUrl(),
678+
thumbnail: file.getThumbnailUrl(),
679+
})),
680+
author: {
681+
id: v.author.getID(),
682+
name: v.author.getName(),
683+
display_name: v.author.getNickname(),
684+
bio: v.author.getBio(),
685+
avatar: v.avatar,
686+
header: v.header,
687+
followed_count: v.followCount.followed,
688+
following_count: v.followCount.following,
689+
},
690+
};
691+
});
692+
693+
return Result.ok(result);
694+
}
628695
}

pkg/timeline/adaptor/repository/dummy.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
conversationRepoSymbol,
2020
type FetchAccountTimelineFilter,
2121
type FetchConversationNotesFilter,
22+
type FetchHomeTimelineFilter,
2223
type ListRepository,
2324
listRepoSymbol,
2425
type TimelineRepository,
@@ -103,6 +104,47 @@ export class InMemoryTimelineRepository implements TimelineRepository {
103104
return Result.ok(filtered);
104105
}
105106

107+
async getPublicTimeline(
108+
filter: FetchHomeTimelineFilter,
109+
): Promise<Result.Result<Error, Note[]>> {
110+
if (filter.afterID && filter.beforeId) {
111+
return Result.err(
112+
new TimelineInvalidFilterRangeError(
113+
'beforeID and afterID cannot be specified at the same time',
114+
{ cause: null },
115+
),
116+
);
117+
}
118+
119+
const publicNotes = [...this.data.values()]
120+
.filter((note) => note.getVisibility() === 'PUBLIC')
121+
.sort((a, b) => b.getCreatedAt().getTime() - a.getCreatedAt().getTime());
122+
123+
// ToDo: filter hasAttachment, noNSFW
124+
125+
if (filter.afterID) {
126+
const afterIndex = publicNotes.findIndex(
127+
(note) => note.getID() === filter.afterID,
128+
);
129+
130+
if (afterIndex === -1) {
131+
return Result.ok(publicNotes.slice(0, 20));
132+
}
133+
const startIndex = Math.max(0, afterIndex - 20);
134+
return Result.ok(publicNotes.slice(startIndex, afterIndex));
135+
}
136+
137+
if (filter.beforeId) {
138+
const beforeIndex = publicNotes.findLastIndex(
139+
(note) => note.getID() === filter.beforeId,
140+
);
141+
142+
return Result.ok(publicNotes.slice(beforeIndex + 1, beforeIndex + 21));
143+
}
144+
145+
return Result.ok(publicNotes.slice(0, 20));
146+
}
147+
106148
async fetchListTimeline(
107149
noteId: readonly NoteID[],
108150
): Promise<Result.Result<Error, Note[]>> {

pkg/timeline/adaptor/repository/prisma.ts

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
conversationRepoSymbol,
2424
type FetchAccountTimelineFilter,
2525
type FetchConversationNotesFilter,
26+
type FetchHomeTimelineFilter,
2627
type ListRepository,
2728
listRepoSymbol,
2829
type TimelineRepository,
@@ -33,6 +34,34 @@ export class PrismaTimelineRepository implements TimelineRepository {
3334
private readonly TIMELINE_NOTE_LIMIT = 20;
3435
constructor(private readonly prisma: PrismaClient) {}
3536

37+
private buildCursorFilter(
38+
filter: Pick<FetchHomeTimelineFilter, 'beforeId' | 'afterID'>,
39+
limit: number = this.TIMELINE_NOTE_LIMIT,
40+
) {
41+
return {
42+
orderBy: {
43+
createdAt: filter.afterID ? 'asc' : 'desc',
44+
} as const,
45+
...(filter.beforeId
46+
? {
47+
cursor: {
48+
id: filter.beforeId,
49+
},
50+
skip: 1,
51+
}
52+
: {}),
53+
...(filter.afterID
54+
? {
55+
cursor: {
56+
id: filter.afterID,
57+
},
58+
skip: 1,
59+
}
60+
: {}),
61+
take: limit,
62+
};
63+
}
64+
3665
private deserialize(
3766
data: Prisma.PromiseReturnType<typeof this.prisma.note.findMany & {}>,
3867
): Note[] {
@@ -87,27 +116,7 @@ export class PrismaTimelineRepository implements TimelineRepository {
87116
where: {
88117
authorId: accountId,
89118
},
90-
orderBy: {
91-
createdAt: filter.afterID ? 'asc' : 'desc',
92-
},
93-
...(filter.beforeId
94-
? {
95-
cursor: {
96-
id: filter.beforeId,
97-
},
98-
// NOTE: Not include specified record
99-
skip: 1,
100-
}
101-
: {}),
102-
...(filter.afterID
103-
? {
104-
cursor: {
105-
id: filter.afterID,
106-
},
107-
skip: 1,
108-
}
109-
: {}),
110-
take: this.TIMELINE_NOTE_LIMIT,
119+
...this.buildCursorFilter(filter),
111120
});
112121

113122
return Result.ok(this.deserialize(accountNotes));
@@ -130,6 +139,29 @@ export class PrismaTimelineRepository implements TimelineRepository {
130139
return Result.ok(this.deserialize(homeNotes));
131140
}
132141

142+
async getPublicTimeline(
143+
filter: FetchHomeTimelineFilter,
144+
): Promise<Result.Result<Error, Note[]>> {
145+
if (filter.afterID && filter.beforeId) {
146+
return Result.err(
147+
new TimelineInvalidFilterRangeError(
148+
'beforeID and afterID cannot be specified at the same time',
149+
{ cause: null },
150+
),
151+
);
152+
}
153+
154+
const publicNotes = await this.prisma.note.findMany({
155+
where: {
156+
visibility: 0, // NOTE: PUBLIC
157+
deletedAt: null,
158+
},
159+
...this.buildCursorFilter(filter),
160+
});
161+
162+
return Result.ok(this.deserialize(publicNotes));
163+
}
164+
133165
async fetchListTimeline(
134166
noteIDs: readonly NoteID[],
135167
): Promise<Result.Result<Error, Note[]>> {

pkg/timeline/adaptor/validator/timeline.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ export const GetAccountTimelineResponseSchema = z
4949

5050
export const GetHomeTimelineResponseSchema = z.array(TimelineNoteBaseSchema);
5151

52+
export const GetPublicTimelineResponseSchema = z
53+
.array(TimelineNoteBaseSchema)
54+
.openapi('GetPublicTimelineResponse');
55+
5256
export const GetListTimelineResponseSchema = z
5357
.array(
5458
z.object({

pkg/timeline/mod.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
GetHomeTimelineRoute,
5454
GetListMemberRoute,
5555
GetListTimelineRoute,
56+
GetPublicTimelineRoute,
5657
} from './router.js';
5758
import { accountTimeline } from './service/account.js';
5859
import { appendListMember } from './service/appendMember.js';
@@ -66,6 +67,7 @@ import { fetchListMember } from './service/fetchMember.js';
6667
import { homeTimeline } from './service/home.js';
6768
import { listTimeline } from './service/list.js';
6869
import { noteVisibility } from './service/noteVisibility.js';
70+
import { publicTimeline } from './service/public.js';
6971
import { removeListMember } from './service/removeMember.js';
7072

7173
const isProduction = process.env.NODE_ENV === 'production';
@@ -155,6 +157,9 @@ const controller = new TimelineController({
155157
Cat.cat(fetchConversation).feed(Ether.compose(conversationRepository))
156158
.value,
157159
),
160+
publicTimelineService: Ether.runEther(
161+
Cat.cat(publicTimeline).feed(Ether.compose(timelineRepository)).value,
162+
),
158163
});
159164

160165
export const timeline = new OpenAPIHono<{
@@ -500,3 +505,26 @@ timeline.openapi(GetConversationRoute, async (c) => {
500505

501506
return c.json(res[1], 200);
502507
});
508+
509+
timeline.openapi(GetPublicTimelineRoute, async (c) => {
510+
const { has_attachment, no_nsfw, before_id, after_id } = c.req.valid('query');
511+
const res = await controller.getPublicTimeline(
512+
has_attachment,
513+
no_nsfw,
514+
before_id,
515+
after_id,
516+
);
517+
if (Result.isErr(res)) {
518+
const error = Result.unwrapErr(res);
519+
timelineModuleLogger.warn(error);
520+
521+
if (error instanceof TimelineNoMoreNotesError) {
522+
return c.json({ error: 'NOTHING_LEFT' as const }, 404);
523+
}
524+
525+
timelineModuleLogger.error('Uncaught error', error);
526+
return c.json({ error: 'INTERNAL_ERROR' as const }, 500);
527+
}
528+
529+
return c.json(Result.unwrap(res), 200);
530+
});

pkg/timeline/model/repository.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@ export interface TimelineRepository {
4545
noteIDs: readonly NoteID[],
4646
): Promise<Result.Result<Error, Note[]>>;
4747

48+
/**
49+
* @description Fetch public timeline
50+
* @param filter Filter for fetching notes
51+
* @return {@link Note}[] list of public Notes, sorted by CreatedAt descending
52+
* */
53+
getPublicTimeline(
54+
filter: FetchHomeTimelineFilter,
55+
): Promise<Result.Result<Error, Note[]>>;
56+
4857
/**
4958
* @description Fetch list timeline
5059
* @param noteId IDs of the notes to be fetched

pkg/timeline/router.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
GetHomeTimelineResponseSchema,
2222
GetListMemberResponseSchema,
2323
GetListTimelineResponseSchema,
24+
GetPublicTimelineResponseSchema,
2425
} from './adaptor/validator/timeline.js'; /* NOTE: query params must use z.string() \
2526
cf. https://zenn.dev/loglass/articles/c237d89e238d42 (Japanese)\
2627
cf. https://github.com/honojs/middleware/issues/200#issuecomment-1773428171 (GitHub Issue)
@@ -90,6 +91,45 @@ export const GetHomeTimelineRoute = createRoute({
9091
},
9192
});
9293

94+
export const GetPublicTimelineRoute = createRoute({
95+
method: 'get',
96+
tags: ['timeline'],
97+
path: '/v0/timeline/public',
98+
request: {
99+
query: timelineFilterQuerySchema,
100+
},
101+
responses: {
102+
200: {
103+
description: 'OK',
104+
content: {
105+
'application/json': {
106+
schema: GetPublicTimelineResponseSchema,
107+
},
108+
},
109+
},
110+
404: {
111+
description: 'Nothing left',
112+
content: {
113+
'application/json': {
114+
schema: z.object({
115+
error: NothingLeft,
116+
}),
117+
},
118+
},
119+
},
120+
500: {
121+
description: 'Internal error',
122+
content: {
123+
'application/json': {
124+
schema: z.object({
125+
error: TimelineInternalError,
126+
}),
127+
},
128+
},
129+
},
130+
},
131+
});
132+
93133
export const GetAccountTimelineRoute = createRoute({
94134
method: 'get',
95135
tags: ['timeline'],

0 commit comments

Comments
 (0)