Skip to content
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: ホームタイムラインとローカルくっつけたみたいなTLを実装 #83

Merged
merged 1 commit into from
Feb 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/backend/src/core/FanoutTimelineService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,17 @@ export type FanoutTimelineName = (
// home timeline
| `homeTimeline:${string}`
| `homeTimelineWithFiles:${string}` // only notes with files are included

// local timeline
| 'localTimeline' // replies are not included
| 'localTimelineWithFiles' // only non-reply notes with files are included
| 'localTimelineWithReplies' // only replies are included
| `localTimelineWithReplyTo:${string}` // Only replies to specific local user are included. Parameter is reply user id.

// local home timeline
| `localHomeTimeline:${string}`
| `localHomeTimelineWithFiles:${string}` // only notes with files are included

// antenna
| `antennaTimeline:${string}`

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

// role timelines
| `roleTimeline:${string}` // any notes are included
);

| `remoteLocalTimeline:${string}`
);

@Injectable()
export class FanoutTimelineService {
Expand Down
10 changes: 10 additions & 0 deletions packages/backend/src/core/NoteCreateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -861,8 +861,11 @@ export class NoteCreateService implements OnApplicationShutdown {

for (const channelFollowing of channelFollowings) {
this.fanoutTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r);
this.fanoutTimelineService.push(`localHomeTimeline:${channelFollowing.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r);

if (note.fileIds.length > 0) {
this.fanoutTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r);
this.fanoutTimelineService.push(`localHomeTimelineWithFiles:${channelFollowing.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r);
}
}
} else {
Expand Down Expand Up @@ -901,8 +904,10 @@ export class NoteCreateService implements OnApplicationShutdown {
}

this.fanoutTimelineService.push(`homeTimeline:${following.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r);
if (user.host === null) this.fanoutTimelineService.push(`localHomeTimeline:${following.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r);
if (note.fileIds.length > 0) {
this.fanoutTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r);
if (user.host === null) this.fanoutTimelineService.push(`localHomeTimelineWithFiles:${following.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r);
}
}

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

if (note.visibility === 'public' && note.userHost == null) {
this.fanoutTimelineService.push('localTimeline', note.id, 1000, r);
this.fanoutTimelineService.push(`localHomeTimeline:${user.id}`, note.id, 1000, r);
if (note.fileIds.length > 0) {
this.fanoutTimelineService.push('localTimelineWithFiles', note.id, 500, r);
this.fanoutTimelineService.push(`localHomeTimelineWithFiles:${user.id}`, note.id, 1000, r);
}
}

if (note.visibility === 'public' && note.userHost !== null) {
this.fanoutTimelineService.push(`remoteLocalTimeline:${note.userHost}`, note.id, 1000, r);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/server/ServerModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { DriveChannelService } from './api/stream/channels/drive.js';
import { GlobalTimelineChannelService } from './api/stream/channels/global-timeline.js';
import { HashtagChannelService } from './api/stream/channels/hashtag.js';
import { HomeTimelineChannelService } from './api/stream/channels/home-timeline.js';
import { HomeLocalTimelineChannelService } from './api/stream/channels/home-local-timeline.js';
import { HybridTimelineChannelService } from './api/stream/channels/hybrid-timeline.js';
import { LocalTimelineChannelService } from './api/stream/channels/local-timeline.js';
import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js';
Expand Down Expand Up @@ -87,6 +88,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
ReversiChannelService,
ReversiGameChannelService,
HomeTimelineChannelService,
HomeLocalTimelineChannelService,
HybridTimelineChannelService,
LocalTimelineChannelService,
QueueStatsChannelService,
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/server/api/endpoint-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,3 +415,4 @@ export * as 'admin/inbox-rule/set' from './endpoints/admin/inbox-rule/set.js';
export * as 'admin/inbox-rule/delete' from './endpoints/admin/inbox-rule/delete.js';
export * as 'admin/inbox-rule/list' from './endpoints/admin/inbox-rule/list.js';
export * as 'users/lists/list-favorite' from './endpoints/users/lists/list-favorite.js';
export * as 'notes/home-local-timeline' from './endpoints/notes/home-local-timeline.js'
247 changes: 247 additions & 0 deletions packages/backend/src/server/api/endpoints/notes/home-local-timeline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project , Type4ny-project
* SPDX-License-Identifier: AGPL-3.0-only
*/

import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository, ChannelFollowingsRepository, MiMeta } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js';
import { IdService } from '@/core/IdService.js';
import { CacheService } from '@/core/CacheService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
import { MiLocalUser } from '@/models/User.js';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';

export const meta = {
tags: ['notes'],

requireCredential: true,
kind: 'read:account',

res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'Note',
},
},
} as const;

export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
sinceDate: { type: 'integer' },
untilDate: { type: 'integer' },
allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default
includeMyRenotes: { type: 'boolean', default: true },
includeRenotedMyNotes: { type: 'boolean', default: true },
includeLocalRenotes: { type: 'boolean', default: true },
withFiles: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true },
},
required: [],
} as const;

@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.meta)
private serverSettings: MiMeta,

@Inject(DI.notesRepository)
private notesRepository: NotesRepository,

@Inject(DI.channelFollowingsRepository)
private channelFollowingsRepository: ChannelFollowingsRepository,

private noteEntityService: NoteEntityService,
private activeUsersChart: ActiveUsersChart,
private idService: IdService,
private cacheService: CacheService,
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
private userFollowingService: UserFollowingService,
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null);

if (!this.serverSettings.enableFanoutTimeline) {
const timeline = await this.getFromDb({
untilId,
sinceId,
limit: ps.limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles,
withRenotes: ps.withRenotes,
}, me);

process.nextTick(() => {
this.activeUsersChart.read(me);
});

return await this.noteEntityService.packMany(timeline, me);
}

const [
followings,
] = await Promise.all([
this.cacheService.userFollowingsCache.fetch(me.id),
]);

const timeline = this.fanoutTimelineEndpointService.timeline({
untilId,
sinceId,
limit: ps.limit,
allowPartial: ps.allowPartial,
me,
useDbFallback: this.serverSettings.enableFanoutTimelineDbFallback,
redisTimelines: ps.withFiles ? [`localHomeTimelineWithFiles:${me.id}`] : [`localHomeTimeline:${me.id}`],
alwaysIncludeMyNotes: true,
excludePureRenotes: !ps.withRenotes,
noteFilter: note => {
if (note.reply && note.reply.visibility === 'followers') {
if (!Object.hasOwn(followings, note.reply.userId) && note.reply.userId !== me.id) return false;
}

return true;
},
dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({
untilId,
sinceId,
limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles,
withRenotes: ps.withRenotes,
}, me),
});

process.nextTick(() => {
this.activeUsersChart.read(me);
});

return timeline;
});
}

private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; includeMyRenotes: boolean; includeRenotedMyNotes: boolean; includeLocalRenotes: boolean; withFiles: boolean; withRenotes: boolean; }, me: MiLocalUser) {
const followees = await this.userFollowingService.getFollowees(me.id);
const followingChannels = await this.channelFollowingsRepository.find({
where: {
followerId: me.id,
},
});

//#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');

if (followees.length > 0 && followingChannels.length > 0) {
// ユーザー・チャンネルともにフォローあり
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
const followingChannelIds = followingChannels.map(x => x.followeeId);
query.andWhere(new Brackets(qb => {
qb
.where(new Brackets(qb2 => {
qb2
.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds })
.andWhere('note.channelId IS NULL');
}))
.orWhere('note.channelId IN (:...followingChannelIds)', { followingChannelIds });
}));
} else if (followees.length > 0) {
// ユーザーフォローのみ(チャンネルフォローなし)
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
query
.andWhere('note.channelId IS NULL')
.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
} else if (followingChannels.length > 0) {
// チャンネルフォローのみ(ユーザーフォローなし)
const followingChannelIds = followingChannels.map(x => x.followeeId);
query.andWhere(new Brackets(qb => {
qb
.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds })
.orWhere('note.userId = :meId', { meId: me.id });
}));
} else {
// フォローなし
query
.andWhere('note.channelId IS NULL')
.andWhere('note.userId = :meId', { meId: me.id });
}

query.andWhere(new Brackets(qb => {
qb
.where('note.replyId IS NULL') // 返信ではない
.orWhere(new Brackets(qb => {
qb // 返信だけど投稿者自身への返信
.where('note.replyId IS NOT NULL')
.andWhere('note.replyUserId = note.userId');
}));
}));

this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);

if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.userId != :meId', { meId: me.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}

if (ps.includeRenotedMyNotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserId != :meId', { meId: me.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}

if (ps.includeLocalRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserHost IS NOT NULL');
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}

if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}

if (ps.withRenotes === false) {
query.andWhere('note.renoteId IS NULL');
}
//#endregion

return await query.limit(ps.limit).getMany();
}
}
3 changes: 3 additions & 0 deletions packages/backend/src/server/api/stream/ChannelsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { HashtagChannelService } from './channels/hashtag.js';
import { RoleTimelineChannelService } from './channels/role-timeline.js';
import { ReversiChannelService } from './channels/reversi.js';
import { ReversiGameChannelService } from './channels/reversi-game.js';
import { HomeLocalTimelineChannelService } from './channels/home-local-timeline.js';
import { type MiChannelService } from './channel.js';

@Injectable()
Expand All @@ -42,6 +43,7 @@ export class ChannelsService {
private adminChannelService: AdminChannelService,
private reversiChannelService: ReversiChannelService,
private reversiGameChannelService: ReversiGameChannelService,
private homeLocalTimelineChannel: HomeLocalTimelineChannelService,
) {
}

Expand All @@ -64,6 +66,7 @@ export class ChannelsService {
case 'admin': return this.adminChannelService;
case 'reversi': return this.reversiChannelService;
case 'reversiGame': return this.reversiGameChannelService;
case 'homeLocalTimeline': return this.homeLocalTimelineChannel;

default:
throw new Error(`no such channel: ${name}`);
Expand Down
Loading
Loading