Skip to content
This repository was archived by the owner on Feb 23, 2026. It is now read-only.

Commit 9f5c51a

Browse files
committed
Merge remote-tracking branch 'refs/remotes/origin/develop' into 81-feat_send_point
# Conflicts: # packages/backend/src/server/api/endpoint-list.ts
2 parents a5e26f3 + c497f6f commit 9f5c51a

File tree

12 files changed

+410
-17
lines changed

12 files changed

+410
-17
lines changed

packages/backend/src/core/FanoutTimelineService.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,17 @@ export type FanoutTimelineName = (
1313
// home timeline
1414
| `homeTimeline:${string}`
1515
| `homeTimelineWithFiles:${string}` // only notes with files are included
16+
1617
// local timeline
1718
| 'localTimeline' // replies are not included
1819
| 'localTimelineWithFiles' // only non-reply notes with files are included
1920
| 'localTimelineWithReplies' // only replies are included
2021
| `localTimelineWithReplyTo:${string}` // Only replies to specific local user are included. Parameter is reply user id.
2122

23+
// local home timeline
24+
| `localHomeTimeline:${string}`
25+
| `localHomeTimelineWithFiles:${string}` // only notes with files are included
26+
2227
// antenna
2328
| `antennaTimeline:${string}`
2429

@@ -37,9 +42,9 @@ export type FanoutTimelineName = (
3742

3843
// role timelines
3944
| `roleTimeline:${string}` // any notes are included
40-
);
4145

4246
| `remoteLocalTimeline:${string}`
47+
);
4348

4449
@Injectable()
4550
export class FanoutTimelineService {

packages/backend/src/core/NoteCreateService.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,8 +861,11 @@ export class NoteCreateService implements OnApplicationShutdown {
861861

862862
for (const channelFollowing of channelFollowings) {
863863
this.fanoutTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r);
864+
this.fanoutTimelineService.push(`localHomeTimeline:${channelFollowing.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r);
865+
864866
if (note.fileIds.length > 0) {
865867
this.fanoutTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r);
868+
this.fanoutTimelineService.push(`localHomeTimelineWithFiles:${channelFollowing.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r);
866869
}
867870
}
868871
} else {
@@ -901,8 +904,10 @@ export class NoteCreateService implements OnApplicationShutdown {
901904
}
902905

903906
this.fanoutTimelineService.push(`homeTimeline:${following.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r);
907+
if (user.host === null) this.fanoutTimelineService.push(`localHomeTimeline:${following.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r);
904908
if (note.fileIds.length > 0) {
905909
this.fanoutTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r);
910+
if (user.host === null) this.fanoutTimelineService.push(`localHomeTimelineWithFiles:${following.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r);
906911
}
907912
}
908913

@@ -929,8 +934,10 @@ export class NoteCreateService implements OnApplicationShutdown {
929934
if (note.userHost == null) {
930935
if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) {
931936
this.fanoutTimelineService.push(`homeTimeline:${user.id}`, note.id, this.meta.perUserHomeTimelineCacheMax, r);
937+
this.fanoutTimelineService.push(`localHomeTimeline:${user.id}`, note.id, this.meta.perUserHomeTimelineCacheMax, r);
932938
if (note.fileIds.length > 0) {
933939
this.fanoutTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r);
940+
this.fanoutTimelineService.push(`localHomeTimelineWithFiles:${user.id}`, note.id, this.meta.perUserHomeTimelineCacheMax, r);
934941
}
935942
}
936943
}
@@ -953,10 +960,13 @@ export class NoteCreateService implements OnApplicationShutdown {
953960

954961
if (note.visibility === 'public' && note.userHost == null) {
955962
this.fanoutTimelineService.push('localTimeline', note.id, 1000, r);
963+
this.fanoutTimelineService.push(`localHomeTimeline:${user.id}`, note.id, 1000, r);
956964
if (note.fileIds.length > 0) {
957965
this.fanoutTimelineService.push('localTimelineWithFiles', note.id, 500, r);
966+
this.fanoutTimelineService.push(`localHomeTimelineWithFiles:${user.id}`, note.id, 1000, r);
958967
}
959968
}
969+
960970
if (note.visibility === 'public' && note.userHost !== null) {
961971
this.fanoutTimelineService.push(`remoteLocalTimeline:${note.userHost}`, note.id, 1000, r);
962972
}

packages/backend/src/server/ServerModule.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { DriveChannelService } from './api/stream/channels/drive.js';
3838
import { GlobalTimelineChannelService } from './api/stream/channels/global-timeline.js';
3939
import { HashtagChannelService } from './api/stream/channels/hashtag.js';
4040
import { HomeTimelineChannelService } from './api/stream/channels/home-timeline.js';
41+
import { HomeLocalTimelineChannelService } from './api/stream/channels/home-local-timeline.js';
4142
import { HybridTimelineChannelService } from './api/stream/channels/hybrid-timeline.js';
4243
import { LocalTimelineChannelService } from './api/stream/channels/local-timeline.js';
4344
import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js';
@@ -87,6 +88,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
8788
ReversiChannelService,
8889
ReversiGameChannelService,
8990
HomeTimelineChannelService,
91+
HomeLocalTimelineChannelService,
9092
HybridTimelineChannelService,
9193
LocalTimelineChannelService,
9294
QueueStatsChannelService,

packages/backend/src/server/api/endpoint-list.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,4 +415,5 @@ export * as 'admin/inbox-rule/set' from './endpoints/admin/inbox-rule/set.js';
415415
export * as 'admin/inbox-rule/delete' from './endpoints/admin/inbox-rule/delete.js';
416416
export * as 'admin/inbox-rule/list' from './endpoints/admin/inbox-rule/list.js';
417417
export * as 'users/lists/list-favorite' from './endpoints/users/lists/list-favorite.js';
418+
export * as 'notes/home-local-timeline' from './endpoints/notes/home-local-timeline.js'
418419
export * as 'point/send' from './endpoints/point/send.js';
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
/*
2+
* SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-project
3+
* SPDX-License-Identifier: AGPL-3.0-only
4+
*/
5+
6+
import { Brackets } from 'typeorm';
7+
import { Inject, Injectable } from '@nestjs/common';
8+
import type { NotesRepository, ChannelFollowingsRepository, MiMeta } from '@/models/_.js';
9+
import { Endpoint } from '@/server/api/endpoint-base.js';
10+
import { QueryService } from '@/core/QueryService.js';
11+
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
12+
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
13+
import { DI } from '@/di-symbols.js';
14+
import { IdService } from '@/core/IdService.js';
15+
import { CacheService } from '@/core/CacheService.js';
16+
import { UserFollowingService } from '@/core/UserFollowingService.js';
17+
import { MiLocalUser } from '@/models/User.js';
18+
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
19+
20+
export const meta = {
21+
tags: ['notes'],
22+
23+
requireCredential: true,
24+
kind: 'read:account',
25+
26+
res: {
27+
type: 'array',
28+
optional: false, nullable: false,
29+
items: {
30+
type: 'object',
31+
optional: false, nullable: false,
32+
ref: 'Note',
33+
},
34+
},
35+
} as const;
36+
37+
export const paramDef = {
38+
type: 'object',
39+
properties: {
40+
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
41+
sinceId: { type: 'string', format: 'misskey:id' },
42+
untilId: { type: 'string', format: 'misskey:id' },
43+
sinceDate: { type: 'integer' },
44+
untilDate: { type: 'integer' },
45+
allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default
46+
includeMyRenotes: { type: 'boolean', default: true },
47+
includeRenotedMyNotes: { type: 'boolean', default: true },
48+
includeLocalRenotes: { type: 'boolean', default: true },
49+
withFiles: { type: 'boolean', default: false },
50+
withRenotes: { type: 'boolean', default: true },
51+
},
52+
required: [],
53+
} as const;
54+
55+
@Injectable()
56+
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
57+
constructor(
58+
@Inject(DI.meta)
59+
private serverSettings: MiMeta,
60+
61+
@Inject(DI.notesRepository)
62+
private notesRepository: NotesRepository,
63+
64+
@Inject(DI.channelFollowingsRepository)
65+
private channelFollowingsRepository: ChannelFollowingsRepository,
66+
67+
private noteEntityService: NoteEntityService,
68+
private activeUsersChart: ActiveUsersChart,
69+
private idService: IdService,
70+
private cacheService: CacheService,
71+
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
72+
private userFollowingService: UserFollowingService,
73+
private queryService: QueryService,
74+
) {
75+
super(meta, paramDef, async (ps, me) => {
76+
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
77+
const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null);
78+
79+
if (!this.serverSettings.enableFanoutTimeline) {
80+
const timeline = await this.getFromDb({
81+
untilId,
82+
sinceId,
83+
limit: ps.limit,
84+
includeMyRenotes: ps.includeMyRenotes,
85+
includeRenotedMyNotes: ps.includeRenotedMyNotes,
86+
includeLocalRenotes: ps.includeLocalRenotes,
87+
withFiles: ps.withFiles,
88+
withRenotes: ps.withRenotes,
89+
}, me);
90+
91+
process.nextTick(() => {
92+
this.activeUsersChart.read(me);
93+
});
94+
95+
return await this.noteEntityService.packMany(timeline, me);
96+
}
97+
98+
const [
99+
followings,
100+
] = await Promise.all([
101+
this.cacheService.userFollowingsCache.fetch(me.id),
102+
]);
103+
104+
const timeline = this.fanoutTimelineEndpointService.timeline({
105+
untilId,
106+
sinceId,
107+
limit: ps.limit,
108+
allowPartial: ps.allowPartial,
109+
me,
110+
useDbFallback: this.serverSettings.enableFanoutTimelineDbFallback,
111+
redisTimelines: ps.withFiles ? [`localHomeTimelineWithFiles:${me.id}`] : [`localHomeTimeline:${me.id}`],
112+
alwaysIncludeMyNotes: true,
113+
excludePureRenotes: !ps.withRenotes,
114+
noteFilter: note => {
115+
if (note.reply && note.reply.visibility === 'followers') {
116+
if (!Object.hasOwn(followings, note.reply.userId) && note.reply.userId !== me.id) return false;
117+
}
118+
119+
return true;
120+
},
121+
dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({
122+
untilId,
123+
sinceId,
124+
limit,
125+
includeMyRenotes: ps.includeMyRenotes,
126+
includeRenotedMyNotes: ps.includeRenotedMyNotes,
127+
includeLocalRenotes: ps.includeLocalRenotes,
128+
withFiles: ps.withFiles,
129+
withRenotes: ps.withRenotes,
130+
}, me),
131+
});
132+
133+
process.nextTick(() => {
134+
this.activeUsersChart.read(me);
135+
});
136+
137+
return timeline;
138+
});
139+
}
140+
141+
private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; includeMyRenotes: boolean; includeRenotedMyNotes: boolean; includeLocalRenotes: boolean; withFiles: boolean; withRenotes: boolean; }, me: MiLocalUser) {
142+
const followees = await this.userFollowingService.getFollowees(me.id);
143+
const followingChannels = await this.channelFollowingsRepository.find({
144+
where: {
145+
followerId: me.id,
146+
},
147+
});
148+
149+
//#region Construct query
150+
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
151+
.innerJoinAndSelect('note.user', 'user')
152+
.leftJoinAndSelect('note.reply', 'reply')
153+
.leftJoinAndSelect('note.renote', 'renote')
154+
.leftJoinAndSelect('reply.user', 'replyUser')
155+
.leftJoinAndSelect('renote.user', 'renoteUser');
156+
157+
if (followees.length > 0 && followingChannels.length > 0) {
158+
// ユーザー・チャンネルともにフォローあり
159+
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
160+
const followingChannelIds = followingChannels.map(x => x.followeeId);
161+
query.andWhere(new Brackets(qb => {
162+
qb
163+
.where(new Brackets(qb2 => {
164+
qb2
165+
.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds })
166+
.andWhere('note.channelId IS NULL');
167+
}))
168+
.orWhere('note.channelId IN (:...followingChannelIds)', { followingChannelIds });
169+
}));
170+
} else if (followees.length > 0) {
171+
// ユーザーフォローのみ(チャンネルフォローなし)
172+
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
173+
query
174+
.andWhere('note.channelId IS NULL')
175+
.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
176+
} else if (followingChannels.length > 0) {
177+
// チャンネルフォローのみ(ユーザーフォローなし)
178+
const followingChannelIds = followingChannels.map(x => x.followeeId);
179+
query.andWhere(new Brackets(qb => {
180+
qb
181+
.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds })
182+
.orWhere('note.userId = :meId', { meId: me.id });
183+
}));
184+
} else {
185+
// フォローなし
186+
query
187+
.andWhere('note.channelId IS NULL')
188+
.andWhere('note.userId = :meId', { meId: me.id });
189+
}
190+
191+
query.andWhere(new Brackets(qb => {
192+
qb
193+
.where('note.replyId IS NULL') // 返信ではない
194+
.orWhere(new Brackets(qb => {
195+
qb // 返信だけど投稿者自身への返信
196+
.where('note.replyId IS NOT NULL')
197+
.andWhere('note.replyUserId = note.userId');
198+
}));
199+
}));
200+
201+
this.queryService.generateVisibilityQuery(query, me);
202+
this.queryService.generateMutedUserQuery(query, me);
203+
this.queryService.generateBlockedUserQuery(query, me);
204+
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
205+
206+
if (ps.includeMyRenotes === false) {
207+
query.andWhere(new Brackets(qb => {
208+
qb.orWhere('note.userId != :meId', { meId: me.id });
209+
qb.orWhere('note.renoteId IS NULL');
210+
qb.orWhere('note.text IS NOT NULL');
211+
qb.orWhere('note.fileIds != \'{}\'');
212+
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
213+
}));
214+
}
215+
216+
if (ps.includeRenotedMyNotes === false) {
217+
query.andWhere(new Brackets(qb => {
218+
qb.orWhere('note.renoteUserId != :meId', { meId: me.id });
219+
qb.orWhere('note.renoteId IS NULL');
220+
qb.orWhere('note.text IS NOT NULL');
221+
qb.orWhere('note.fileIds != \'{}\'');
222+
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
223+
}));
224+
}
225+
226+
if (ps.includeLocalRenotes === false) {
227+
query.andWhere(new Brackets(qb => {
228+
qb.orWhere('note.renoteUserHost IS NOT NULL');
229+
qb.orWhere('note.renoteId IS NULL');
230+
qb.orWhere('note.text IS NOT NULL');
231+
qb.orWhere('note.fileIds != \'{}\'');
232+
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
233+
}));
234+
}
235+
236+
if (ps.withFiles) {
237+
query.andWhere('note.fileIds != \'{}\'');
238+
}
239+
240+
if (ps.withRenotes === false) {
241+
query.andWhere('note.renoteId IS NULL');
242+
}
243+
//#endregion
244+
245+
return await query.limit(ps.limit).getMany();
246+
}
247+
}

packages/backend/src/server/api/stream/ChannelsService.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { HashtagChannelService } from './channels/hashtag.js';
2121
import { RoleTimelineChannelService } from './channels/role-timeline.js';
2222
import { ReversiChannelService } from './channels/reversi.js';
2323
import { ReversiGameChannelService } from './channels/reversi-game.js';
24+
import { HomeLocalTimelineChannelService } from './channels/home-local-timeline.js';
2425
import { type MiChannelService } from './channel.js';
2526

2627
@Injectable()
@@ -42,6 +43,7 @@ export class ChannelsService {
4243
private adminChannelService: AdminChannelService,
4344
private reversiChannelService: ReversiChannelService,
4445
private reversiGameChannelService: ReversiGameChannelService,
46+
private homeLocalTimelineChannel: HomeLocalTimelineChannelService,
4547
) {
4648
}
4749

@@ -64,6 +66,7 @@ export class ChannelsService {
6466
case 'admin': return this.adminChannelService;
6567
case 'reversi': return this.reversiChannelService;
6668
case 'reversiGame': return this.reversiGameChannelService;
69+
case 'homeLocalTimeline': return this.homeLocalTimelineChannel;
6770

6871
default:
6972
throw new Error(`no such channel: ${name}`);

0 commit comments

Comments
 (0)